
/* --- schedule module (ob-*) ------------------------------------------- */
.ob-schedule { font-family: var(--ob-font-body); color: var(--ob-text); }
.ob-schedule a { color: var(--ob-link); text-decoration: underline; text-underline-offset: 2px; }
.ob-schedule a:hover { color: var(--ob-link-hover); }

.ob-heading {
  font-family: var(--ob-font-heading);
  color: var(--ob-heading-color, var(--ob-text));
  font-size: 1.6rem;
  font-weight: 500;
  margin: 3.5rem 0 1rem;
}
.ob-year-heading {
  font-family: var(--ob-font-heading);
  color: var(--ob-heading-color, var(--ob-text));
  font-size: 1.35rem;
  font-weight: 500;
  margin: 2rem 0 0.5rem;
}
.ob-empty { color: var(--ob-text-muted); margin: 1rem 0; }

/* Event unit — list variant: date column beside the body.

   The column width is a hook because it depends on the *date style* in use:
   6.5rem holds "16/10/26" and "16 Oct 2026" but not "16 November 2026", and
   .ob-event-date is nowrap, so a long style silently overprints the heading
   rather than wrapping. Found in a browser, on the first design that asked
   for the long date style.

   The style used to be the design's alone and invisible from CSS, so a
   design could only pick one width and hope. The data-ob-date-style
   attribute on the root (dateStyleMark, in this module's renderer) makes it
   addressable, which is what an author-adjustable date style needs: the
   design answers per style. This module still declares no width of its own
   beyond the fallback — which column suits which style is drawing, and
   drawing is the design's.

   Two things this file cannot hold, both of them checked by tests rather
   than remembered: no backtick, since the whole stylesheet is one template
   literal; and nothing that reads as a bare class selector, since the CSS
   contract test scrapes for them and a filename with an extension is one. */
.ob-schedule--list .ob-event {
  display: grid;
  grid-template-columns: var(--ob-event-date-width, 6.5rem) 1fr;
  gap: 0.35rem 2rem;
  padding: 1.25rem 0;
  border-bottom: 1px solid var(--ob-line);
}
@media (max-width: 40rem) {
  .ob-schedule--list .ob-event { grid-template-columns: 1fr; gap: 0.15rem; }
}

/* Event unit — cards variant: bordered card in a responsive grid. */
.ob-schedule-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1.25rem;
  align-items: start;
  margin: 1.25rem 0;
}
@media (min-width: 40rem) { .ob-schedule-grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 64rem) { .ob-schedule-grid { grid-template-columns: repeat(3, 1fr); } }
.ob-schedule--cards .ob-event {
  border: 1px solid var(--ob-line);
  border-radius: var(--ob-radius);
  padding: 1.25rem;
}
.ob-schedule--cards .ob-event-date { margin-bottom: 0.35rem; }

/* The card's own top edge — musicworks' photograph above a date badge
   (eventSchema's imageId, cards only — see the renderer's own comment on why
   the other two variants have nowhere to put one). Sits inside the card's
   padding rather than bled to its border, the news module's own cards
   treatment (.ob-news--cards .ob-post-image) one region over; a design
   wanting the photograph flush with the card's edge overrides this rule and
   the card's own padding together, which is a design decision this file
   leaves to it. */
.ob-schedule--cards .ob-event-image {
  display: block;
  width: 100%;
  aspect-ratio: 3 / 2;
  object-fit: cover;
  border-radius: var(--ob-radius);
  margin-bottom: 0.9rem;
}

.ob-event-date { color: var(--ob-text); white-space: nowrap; }
.ob-event-chip {
  display: inline-block;
  background: var(--ob-chip-bg, var(--ob-accent));
  color: var(--ob-chip-fg, var(--ob-on-accent));
  font-size: 0.85em;
  line-height: 1.3;
  padding: 0.25em 0.7em;
  border-radius: var(--ob-radius);
  white-space: normal;
}
.ob-event-heading { font-family: var(--ob-font-heading); font-size: 1.1rem; margin: 0; }
.ob-event-heading a { color: inherit; }
.ob-event-heading a:hover { color: var(--ob-link-hover); }
.ob-event-meta { color: var(--ob-text-muted); margin: 0.1rem 0 0; }
.ob-event-programme { margin: 0.1rem 0 0; }
.ob-event-note { font-style: italic; font-size: 0.95em; margin: 0.35rem 0 0; }
.ob-event-note em { font-style: normal; }
.ob-event-links { margin: 0.4rem 0 0; }
.ob-event-links a { margin-right: 1rem; }
/* Self-hosted downloads ride the links row; the arrow marks the affordance
   (worksModule's data-kind="download" idiom). */
.ob-event-links a[data-kind="download"]::before { content: "\2193\00a0"; }
.ob-event-status {
  color: var(--ob-accent);
  font-family: var(--ob-font-body);
  font-size: 0.85rem;
  text-decoration: none;
  display: inline-block;
}
.ob-event--off .ob-event-heading { text-decoration: line-through; color: var(--ob-text-muted); }
.ob-event--off .ob-event-status { text-decoration: none; }

.ob-event-quote { margin: 0.75rem 0 0; padding: 0; border: 0; }
.ob-event-quote p { font-style: italic; margin: 0; }
/* Emphasis inside italic goes upright — see the press module's note. */
.ob-event-quote p em { font-style: normal; }
.ob-event-quote cite {
  display: block;
  font-style: normal;
  color: var(--ob-text-muted);
  margin-top: 0.15rem;
  font-size: 0.9em;
}

/* Past years: native details accordions, newest first, first one open. */
.ob-year { border-bottom: 1px solid var(--ob-line); }
.ob-year-summary {
  font-family: var(--ob-font-heading);
  color: var(--ob-heading-color, var(--ob-text));
  font-size: 1.25rem;
  padding: 0.75rem 0;
  cursor: pointer;
  list-style: none;
}
.ob-year-summary::-webkit-details-marker { display: none; }
.ob-year-summary::after { content: "+"; float: right; color: var(--ob-text-muted); }
.ob-year[open] > .ob-year-summary::after { content: "\2013"; }

/* The all-dates link under an upcoming teaser. Link colours come from the
   module-wide .ob-schedule a rules; this is only spacing and emphasis. */
.ob-schedule-all { margin: 1.5rem 0 0; }
.ob-schedule--cards .ob-schedule-all { margin-top: 0.25rem; }

.ob-event-compact {
  margin: 0;
  padding: 0.4rem 0;
  border-top: 1px solid var(--ob-line);
  font-size: 0.95rem;
}
.ob-event-compact .ob-event-date { color: var(--ob-text-muted); margin-right: 0.5rem; }
.ob-event-compact.ob-event--off { text-decoration: line-through; color: var(--ob-text-muted); }

/* A concert's own page (the eventPages page flag): the venue name, its
   address as somebody would write it on an envelope, and a Directions link
   that opens the reader's own map app — no iframe, no third-party script,
   nothing to consent to. */
.ob-event-venue { font-family: var(--ob-font-heading); color: var(--ob-heading-color, var(--ob-text)); }
.ob-event-address { font-style: normal; color: var(--ob-text-muted); }
.ob-event-directions { margin: 0.4rem 0 0; }


/* --- news module (ob-*) ------------------------------------------------ */
.ob-news { font-family: var(--ob-font-body); color: var(--ob-text); }
.ob-news a { color: var(--ob-link); text-decoration: underline; text-underline-offset: 2px; }
.ob-news a:hover { color: var(--ob-link-hover); }
.ob-news .ob-empty { color: var(--ob-text-muted); margin: 1rem 0; }

.ob-post-title {
  font-family: var(--ob-font-heading);
  color: var(--ob-heading-color, var(--ob-text));
  font-size: 1.35rem;
  font-weight: 500;
  margin: 0.15rem 0 0.75rem;
}
.ob-post-date { color: var(--ob-text-muted); margin: 0; font-size: 0.9rem; }
.ob-post-chip {
  display: inline-block;
  background: var(--ob-chip-bg, var(--ob-accent));
  color: var(--ob-chip-fg, var(--ob-on-accent));
  font-size: 0.85em;
  line-height: 1.3;
  padding: 0.25em 0.7em;
  border-radius: var(--ob-radius);
}
.ob-post-body p { margin: 0 0 0.9rem; }
.ob-post-body p:last-child { margin-bottom: 0; }
.ob-post-image {
  display: block;
  width: 100%;
  border-radius: var(--ob-radius);
}

/* Post unit — stacked variant: full-width articles separated by hairlines,
   photo in a side column on wide screens (the sonata/madrigal ground). */
.ob-news--stacked .ob-post {
  padding: 1.75rem 0;
  border-bottom: 1px solid var(--ob-line);
}
.ob-news--stacked .ob-post--imaged {
  display: grid;
  grid-template-columns: 1fr 18rem;
  gap: 1.5rem;
  align-items: start;
}
.ob-news--stacked .ob-post--imaged .ob-post-main { grid-column: 1; grid-row: 1; }
.ob-news--stacked .ob-post--imaged .ob-post-image { grid-column: 2; grid-row: 1; }
@media (max-width: 40rem) {
  .ob-news--stacked .ob-post--imaged { grid-template-columns: 1fr; }
  .ob-news--stacked .ob-post--imaged .ob-post-main { grid-column: 1; grid-row: 2; }
  .ob-news--stacked .ob-post--imaged .ob-post-image { grid-column: 1; grid-row: 1; }
}

/* Post unit — cards variant: bordered cards in a responsive grid, photo on
   top (the fanfare ground). */
.ob-news-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1.5rem;
  align-items: start;
  margin: 1.25rem 0;
}
@media (min-width: 40rem) { .ob-news-grid { grid-template-columns: repeat(2, 1fr); } }
.ob-news--cards .ob-post {
  border: 1px solid var(--ob-line);
  border-radius: var(--ob-radius);
  padding: 1.25rem;
}
.ob-news--cards .ob-post-image {
  aspect-ratio: 16 / 9;
  object-fit: cover;
  margin-bottom: 0.9rem;
}

/* The archive: everything before this year, one details per year, newest
   open. Declared here as well as in the schedule module, and deliberately
   identically — a design may place news without ever placing a diary, and a
   module that leaned on another module's stylesheet would render an
   unstyled accordion on exactly those sites. */
.ob-news .ob-year { border-bottom: 1px solid var(--ob-line); }
.ob-news .ob-year-summary {
  font-family: var(--ob-font-heading);
  color: var(--ob-heading-color, var(--ob-text));
  font-size: 1.25rem;
  padding: 0.75rem 0;
  cursor: pointer;
  list-style: none;
}
.ob-news .ob-year-summary::-webkit-details-marker { display: none; }
.ob-news .ob-year-summary::after { content: "+"; float: right; color: var(--ob-text-muted); }
.ob-news .ob-year[open] > .ob-year-summary::after { content: "\2013"; }

/* The link through to the full news page under a teaser — the all-dates
   link's twin. Spacing and emphasis only; link colours come from the
   module-wide rules above. */
.ob-news-all { margin: 1.5rem 0 0; }
.ob-news--cards .ob-news-all { margin-top: 0.25rem; }


/* --- gallery module (ob-*) -------------------------------------------- */
.ob-gallery { font-family: var(--ob-font-body); color: var(--ob-text); }
.ob-gallery .ob-empty { color: var(--ob-text-muted); margin: 1rem 0; }

.ob-album-title {
  font-family: var(--ob-font-heading);
  color: var(--ob-heading-color, var(--ob-text));
  font-size: 1.35rem;
  font-weight: 500;
  margin: 2.5rem 0 1rem;
}
.ob-album:first-child .ob-album-title { margin-top: 0; }

/* Photo layout — grid variant: two across, three on wide screens. */
.ob-album-photos {
  display: grid;
  gap: 1rem;
  align-items: start;
  margin: 1rem 0 0;
}
.ob-gallery--grid .ob-album-photos { grid-template-columns: repeat(2, 1fr); }
@media (min-width: 64rem) { .ob-gallery--grid .ob-album-photos { grid-template-columns: repeat(3, 1fr); } }

/* Photo layout — rows variant: large single column, doubling on wide. */
.ob-gallery--rows .ob-album-photos { grid-template-columns: 1fr; gap: 2rem; }
@media (min-width: 64rem) { .ob-gallery--rows .ob-album-photos { grid-template-columns: repeat(2, 1fr); gap: 1.5rem; } }

/* Photo layout — masonry variant: unequal-length columns, the photograph's
   own proportions, nothing cropped. CSS columns is the honest tool here —
   true masonry needs grid-template-rows: masonry, which is not shipped
   widely enough to build on — so the container drops out of the shared
   grid rule above and back to display: block, and each photo carries
   break-inside: avoid so a tile never splits across the column break.

   The cost this technique is known for: source order runs down each column
   before across to the next one, where the grid and rows variants (and a
   sighted visitor's usual left-to-right scan) run across the row first. For
   a photograph wall that cost doesn't bite. A gallery has no sequence a
   visitor is meant to follow — unlike the diary or the works catalogue,
   nothing here is numbered or dated, so there is no "next" to skip past.
   The DOM order is untouched — columns reformats visually and reorders
   nothing — so a screen reader and anyone tabbing through the photo
   links still meets every photo in the album's own order, caption paired
   correctly with its own picture; only the sighted, scanning eye's path
   changes shape, and it changes into the shape a masonry wall is supposed
   to have. */
.ob-gallery--masonry .ob-album-photos {
  display: block;
  columns: 2;
  column-gap: 1rem;
}
@media (min-width: 64rem) { .ob-gallery--masonry .ob-album-photos { columns: 3; column-gap: 1.5rem; } }
.ob-gallery--masonry .ob-photo { break-inside: avoid; margin: 0 0 1rem; }

/* Photo layout — squares variant: the grid's own column layout, every tile
   forced to a strict square rather than 3/2 — see .ob-photo-img below. */
.ob-gallery--squares .ob-album-photos { grid-template-columns: repeat(2, 1fr); }
@media (min-width: 64rem) { .ob-gallery--squares .ob-album-photos { grid-template-columns: repeat(3, 1fr); } }

.ob-photo { margin: 0; }
.ob-photo-frame {
  display: block;
  overflow: hidden;
  border-radius: var(--ob-radius);
  background: var(--ob-surface);
}
/* The frame is an <a> to the master, so a photo opens full-size with or
 * without a shell lightbox. It carries no link colour or underline — it is
 * a picture, not a piece of text. */
.ob-photo-link { cursor: zoom-in; text-decoration: none; color: inherit; }
.ob-photo-img {
  display: block;
  width: 100%;
  transition: transform 0.4s ease, opacity 0.4s ease;
}
/* Grid tiles crop to a steady ratio (no distortion); rows and masonry keep
 * the photograph's own aspect; squares crop to a strict 1/1. */
.ob-gallery--grid .ob-photo-img { aspect-ratio: 3 / 2; object-fit: cover; }
.ob-gallery--rows .ob-photo-img { height: auto; }
.ob-gallery--masonry .ob-photo-img { height: auto; }
.ob-gallery--squares .ob-photo-img { aspect-ratio: 1 / 1; object-fit: cover; }
.ob-photo:hover .ob-photo-img { transform: scale(1.03); opacity: 0.92; }

.ob-photo-caption {
  color: var(--ob-text-muted);
  font-size: 0.9rem;
  margin-top: 0.4rem;
}
.ob-album-credit {
  color: var(--ob-text-muted);
  font-size: 0.85rem;
  margin: 0.75rem 0 0;
}

/* The way through to the whole of it, beside a limited region. */
.ob-gallery-all { margin: 1.5rem 0 0; }


/* --- press module (ob-*) ----------------------------------------------- */
.ob-press { font-family: var(--ob-font-body); color: var(--ob-text); }
.ob-press a { color: var(--ob-link); text-decoration: underline; text-underline-offset: 2px; }
.ob-press a:hover { color: var(--ob-link-hover); }
.ob-press .ob-empty { color: var(--ob-text-muted); margin: 1rem 0; }

/* Pull-quote unit — shared by the quote wall and the articles fallback:
   accent-edged blockquote (the sonata/nocturne/toccata ground). */
.ob-quote {
  border-left: 2px solid var(--ob-accent);
  margin: 0;
  padding: 0.25rem 0 0.25rem 1.25rem;
}
.ob-quote-text { font-size: 1.15rem; line-height: 1.6; font-style: italic; margin: 0; }
/* Emphasis inside an italic quotation goes upright — the typesetter's rule,
   and the only way an italicised work title reads at all in here. Paired with
   the declaration above: a design that sets this block roman overrides both. */
.ob-quote-text em { font-style: normal; }
.ob-quote-stars { color: var(--ob-accent); font-style: normal; white-space: nowrap; }
.ob-quote-source {
  display: block;
  margin-top: 0.6rem;
  font-size: 0.9rem;
  font-style: normal;
  color: var(--ob-text-muted);
}
.ob-quote-date { white-space: nowrap; }
.ob-quote-link { margin: 0.5rem 0 0; font-size: 0.9rem; }

/* Quote wall — responsive 1→2 column grid. */
.ob-press-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: 2rem 2.5rem;
  align-items: start;
  margin: 1.25rem 0;
}
@media (min-width: 48rem) { .ob-press-grid { grid-template-columns: repeat(2, 1fr); } }

/* Long-form articles — hairline-separated full-width units; paragraph-less
   entries keep the pull-quote presentation in the same flow (the
   madrigal/fanfare ground). */
.ob-press--articles .ob-quote { margin: 1.75rem 0; }
.ob-article { padding: 1.75rem 0; border-bottom: 1px solid var(--ob-line); }
.ob-article:last-child { border-bottom: 0; }
.ob-article-heading {
  font-family: var(--ob-font-heading);
  color: var(--ob-heading-color, var(--ob-text));
  font-size: 1.35rem;
  font-weight: 500;
  margin: 0 0 0.35rem;
}
.ob-article-date { color: var(--ob-text-muted); font-size: 0.9rem; margin: 0 0 0.35rem; }
.ob-article-context { font-style: italic; color: var(--ob-accent-deep); margin: 0 0 0.5rem; }
.ob-article-lede { margin: 0.5rem 0 0.9rem; }
.ob-article-body p { margin: 0 0 0.9rem; }
.ob-article-body p:last-child { margin-bottom: 0; }
.ob-article-link { margin: 0.5rem 0 0; font-size: 0.9rem; }

/* The way through to the whole of it, beside a limited region. */
.ob-press-all { margin: 1.5rem 0 0; }


/* --- recordings module (ob-*) ----------------------------------------- */
.ob-recordings { font-family: var(--ob-font-body); color: var(--ob-text); }
.ob-recordings a { color: var(--ob-link); text-decoration: underline; text-underline-offset: 2px; }
.ob-recordings a:hover { color: var(--ob-link-hover); }
.ob-recordings .ob-empty { color: var(--ob-text-muted); margin: 1rem 0; }

/* Author-named discography sections (a recording's group field) — the works
   module's heading, in this module's vocabulary. */
.ob-recordings-heading {
  font-family: var(--ob-font-heading);
  color: var(--ob-heading-color, var(--ob-text));
  font-size: 1.35rem;
  font-weight: 500;
  margin: 2.5rem 0 0.75rem;
}
.ob-recordings-group:first-child .ob-recordings-heading { margin-top: 0; }

/* Card grid — two across, four on wide screens (the prelude/bravura/
   fanfare ground): cover on top, notes beneath. */
.ob-recordings-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 2.5rem 1.75rem;
  align-items: start;
  margin: 1.25rem 0;
}
@media (min-width: 64rem) { .ob-recordings-grid { grid-template-columns: repeat(4, 1fr); } }

.ob-recording { margin: 0; }
.ob-recording-cover {
  overflow: hidden;
  border-radius: var(--ob-radius);
  background: var(--ob-surface);
}
.ob-recording-img {
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  transition: transform 0.4s ease;
}
.ob-recording:hover .ob-recording-img { transform: scale(1.03); }

.ob-recording-title {
  font-family: var(--ob-font-heading);
  color: var(--ob-heading-color, var(--ob-text));
  font-size: 1.15rem;
  font-weight: 500;
  line-height: 1.3;
  margin: 0.85rem 0 0.25rem;
}
.ob-recording-credit { color: var(--ob-text-muted); font-size: 0.9rem; margin: 0 0 0.35rem; }
.ob-recording-personnel { font-size: 0.9rem; margin: 0 0 0.5rem; }
.ob-recording-personnel p { margin: 0 0 0.15rem; }
.ob-recording-blurb { line-height: 1.65; margin: 0.5rem 0; }

/* Track listings — detailed units only; headings pick up the heading face. */
.ob-recording-tracks { font-size: 0.9rem; color: var(--ob-text-muted); margin: 0.75rem 0; }
.ob-track-heading {
  font-family: var(--ob-font-heading);
  color: var(--ob-text);
  margin: 0.75rem 0 0.25rem;
}
.ob-recording-tracks > :first-child { margin-top: 0; }
.ob-track-line { margin: 0 0 0.15rem; }

/* Accolades — a compact awards line (madrigal/fanfare). */
.ob-recording-accolades {
  margin: 0.75rem 0 0;
  font-size: 0.8rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
.ob-accolade { color: var(--ob-accent-deep); margin-right: 0.75rem; }
.ob-recordings a.ob-accolade { text-decoration: none; }
.ob-recordings a.ob-accolade:hover { color: var(--ob-accent); text-decoration: underline; }
/* A roundel or chart-graphic before the label — the award's own image,
   at text scale rather than a cover's. */
.ob-accolade-badge {
  display: inline-block;
  width: 1.1rem;
  height: 1.1rem;
  margin-right: 0.35rem;
  vertical-align: -0.2rem;
  object-fit: cover;
}

/* Review quote under its recording (the opt-in recordingQuotes ctx knob). */
.ob-recording-quote {
  border-left: 2px solid var(--ob-accent);
  margin: 0.9rem 0 0;
  padding: 0.1rem 0 0.1rem 0.9rem;
  font-size: 0.9rem;
}
.ob-recording-quote p { font-style: italic; margin: 0; }
/* Emphasis inside italic goes upright — see the press module's note. */
.ob-recording-quote p em { font-style: normal; }
.ob-recording-stars { color: var(--ob-accent); font-style: normal; white-space: nowrap; }
.ob-recording-quote cite {
  display: block;
  margin-top: 0.3rem;
  font-style: normal;
  color: var(--ob-text-muted);
}

/* Listening / buying / review links. */
.ob-recording-links { margin: 0.85rem 0 0; font-size: 0.9rem; }
.ob-recording-link { margin-right: 1rem; white-space: nowrap; }
/* Self-hosted downloads ride the links row; the arrow marks the affordance
   (worksModule's data-kind="download" idiom). A download's label is the
   file's own, and an author's label runs long — "BBC Radio 3 Record Review –
   Harriet Smith and Andrew McGregor discuss the Op 20 CD" — so unlike the
   short outbound labels it wraps rather than overflowing the column. */
.ob-recording-link[data-kind="download"] { white-space: normal; }
.ob-recording-link[data-kind="download"]::before { content: "\2193\00a0"; }

/* Detailed units — full width, cover beside the notes, hairline separated. */
.ob-recordings--detailed .ob-recording {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1.5rem;
  padding: 2rem 0;
  border-bottom: 1px solid var(--ob-line);
}
.ob-recordings--detailed .ob-recording:first-child { padding-top: 0.5rem; }
.ob-recordings--detailed .ob-recording:last-child { border-bottom: 0; }
@media (min-width: 48rem) {
  .ob-recordings--detailed .ob-recording { grid-template-columns: 16rem 1fr; gap: 2.5rem; }
}
.ob-recordings--detailed .ob-recording-title { font-size: 1.4rem; margin-top: 0; }

/* Titled units — the title as its own full-width row, then the same
   cover-beside-details grid as the detailed units underneath (the Haydn
   Quartet's discography). Below the breakpoint it is just source order:
   title, cover, body, stacked. */
.ob-recordings--titled .ob-recording {
  padding: 2rem 0;
  border-bottom: 1px solid var(--ob-line);
}
.ob-recordings--titled .ob-recording:first-child { padding-top: 0.5rem; }
.ob-recordings--titled .ob-recording:last-child { border-bottom: 0; }
.ob-recordings--titled .ob-recording-title { font-size: 1.4rem; margin: 0 0 1rem; }
@media (min-width: 48rem) {
  .ob-recordings--titled .ob-recording {
    display: grid;
    grid-template-columns: 16rem 1fr;
    gap: 1.5rem 2.5rem;
  }
  .ob-recordings--titled .ob-recording-title { grid-column: 1 / -1; margin-bottom: 0.5rem; }
}

/* The way through to the whole of it, beside a limited region. */
.ob-recordings-all { margin: 1.5rem 0 0; }


/* --- videos module (ob-*) --------------------------------------------- */
.ob-videos { font-family: var(--ob-font-body); color: var(--ob-text); }
.ob-videos a { color: var(--ob-link); text-decoration: underline; text-underline-offset: 2px; }
.ob-videos a:hover { color: var(--ob-link-hover); }
.ob-videos .ob-empty { color: var(--ob-text-muted); margin: 1rem 0; }

/* Embed grid — one across on a phone, always: six columns on a phone is
   unreadable, so the narrow step is never the author's to move. The
   wide-screen count is data-ob-video-columns, 1-6 (the slot vocabulary's
   videoColumns, an author-facing adjust: a count is narrowing, which is
   the author's side of the line, where a gallery shape is arrangement and
   stays the design's).
   Absent, these three rules are the base ladder and match what this grid
   always rendered: one across, two at 48rem, three on wide screens.

   The tablet step is min(chosen, 2), never the chosen count itself: a
   count of 1 or 2 must not be *widened* by the 48rem breakpoint (a single
   lead video does not earn a second column just because the screen grew),
   so only videoColumns="1" overrides the tablet step below, back down to
   one. Every other value already gets two at 48rem from the base rule
   above, unchanged. The wide-screen rules below then set the chosen count
   itself, 64rem being the width the value actually names. videoColumns="3"
   is included for completeness though it repeats the base rule exactly —
   which is the point: absent and videoColumns="3" render byte-identical,
   so this option needs no whenUnset word, unlike limit's "All". */
.ob-videos-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: 2rem 1.5rem;
  align-items: start;
  margin: 1.25rem 0;
}
@media (min-width: 48rem) { .ob-videos-grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 64rem) { .ob-videos-grid { grid-template-columns: repeat(3, 1fr); } }

@media (min-width: 48rem) {
  .ob-videos-grid[data-ob-video-columns="1"] { grid-template-columns: 1fr; }
}
@media (min-width: 64rem) {
  .ob-videos-grid[data-ob-video-columns="1"] { grid-template-columns: 1fr; }
  .ob-videos-grid[data-ob-video-columns="2"] { grid-template-columns: repeat(2, 1fr); }
  .ob-videos-grid[data-ob-video-columns="3"] { grid-template-columns: repeat(3, 1fr); }
  .ob-videos-grid[data-ob-video-columns="4"] { grid-template-columns: repeat(4, 1fr); }
  .ob-videos-grid[data-ob-video-columns="5"] { grid-template-columns: repeat(5, 1fr); }
  .ob-videos-grid[data-ob-video-columns="6"] { grid-template-columns: repeat(6, 1fr); }
}

.ob-video-frame {
  aspect-ratio: 16 / 9;
  overflow: hidden;
  border-radius: var(--ob-radius);
  background: var(--ob-surface);
}
.ob-video-embed { display: block; width: 100%; height: 100%; border: 0; }

/* A url neither host's regex claims falls back to a plain link — the works
   module's ob-work-links, this module's vocabulary. */
.ob-video-links { margin: 0.75rem 0; font-size: 0.9rem; }
.ob-video-link { white-space: nowrap; }

/* Poster still — the chosen frame linking out to the watch page. The play
   badge and the caption scrim sit over a photograph, so their blacks and
   whites are literals like every scrim's: the picture isn't themed. The
   badge takes the site's accent on hover, which is the themed part. */
.ob-videos .ob-video-poster {
  position: relative;
  display: block;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  border-radius: var(--ob-radius);
  background: var(--ob-surface);
  text-decoration: none;
}
.ob-video-still {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.ob-video-play {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 4.25rem;
  height: 3rem;
  border-radius: 0.75rem;
  background: rgba(0, 0, 0, 0.6);
  transition: background 0.15s ease;
}
.ob-video-play::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-42%, -50%);
  border-style: solid;
  border-width: 0.55rem 0 0.55rem 0.95rem;
  border-color: transparent transparent transparent #ffffff;
}
.ob-video-poster:hover .ob-video-play,
.ob-video-poster:focus-visible .ob-video-play {
  background: var(--ob-accent);
}
.ob-video-caption {
  position: absolute;
  inset: auto 0 0 0;
  padding: 2.25rem 0.9rem 0.7rem;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0));
  color: #ffffff;
  font-family: var(--ob-font-heading);
  font-size: 0.95rem;
  line-height: 1.35;
  white-space: pre-line;
}
.ob-video--lead .ob-video-caption { font-size: 1.15rem; }

.ob-video-title {
  font-family: var(--ob-font-heading);
  color: var(--ob-heading-color, var(--ob-text));
  font-size: 1.05rem;
  font-weight: 500;
  line-height: 1.3;
  margin: 0.6rem 0 0;
}
/* Titles above the frame keep their breathing room on the other side. */
.ob-video-title:first-child { margin: 0 0 0.6rem; }

/* Lead embed — the featured variant's single large video (madrigal's
   home-page treatment), centred above the rest of the grid. */
.ob-video--lead { max-width: 48rem; margin: 0 auto 2.5rem; }
.ob-video--lead .ob-video-title { font-size: 1.35rem; }

/* The way through to the whole of it, beside a limited region. */
.ob-videos-all { margin: 1.5rem 0 0; }


/* --- works module (ob-*) ---------------------------------------------- */
.ob-works { font-family: var(--ob-font-body); color: var(--ob-text); }
.ob-works a { color: var(--ob-link); text-decoration: underline; text-underline-offset: 2px; }
.ob-works a:hover { color: var(--ob-link-hover); }
.ob-works .ob-empty { color: var(--ob-text-muted); margin: 1rem 0; }

.ob-works-heading {
  font-family: var(--ob-font-heading);
  color: var(--ob-heading-color, var(--ob-text));
  font-size: 1.35rem;
  font-weight: 500;
  margin: 2.5rem 0 0.75rem;
}
.ob-works-group:first-child .ob-works-heading { margin-top: 0; }

/* List variant — a no-JS accordion, the source catalogue's own idiom. */
.ob-works--list .ob-work { border-bottom: 1px solid var(--ob-line); }
.ob-work-summary {
  font-family: var(--ob-font-heading);
  color: var(--ob-heading-color, var(--ob-text));
  font-size: 1.1rem;
  line-height: 1.4;
  margin: 0;
  padding: 0.85rem 1.5rem 0.85rem 0;
  cursor: pointer;
  list-style: none;
  position: relative;
}
.ob-work-summary::-webkit-details-marker { display: none; }
.ob-work-summary::after {
  content: "+";
  position: absolute;
  right: 0;
  color: var(--ob-text-muted);
}
.ob-work[open] > .ob-work-summary::after { content: "\2013"; }
/* A title-only work is a plain row: no widget, no toggle affordance. */
.ob-work--bare .ob-work-summary { cursor: default; }
.ob-work--bare .ob-work-summary::after { content: none; }

.ob-work-name { font-weight: 500; }
.ob-work-year { color: var(--ob-text-muted); font-size: 0.9em; white-space: nowrap; }
.ob-work-chip {
  display: inline-block;
  background: var(--ob-chip-bg, var(--ob-accent));
  color: var(--ob-chip-fg, var(--ob-on-accent));
  font-size: 0.85em;
  line-height: 1.3;
  padding: 0.2em 0.6em;
  border-radius: var(--ob-radius);
}

.ob-work-body { padding: 0 0 1.25rem; max-width: 46rem; }
.ob-work-body > :first-child { margin-top: 0; }

.ob-work-meta { color: var(--ob-text-muted); margin: 0 0 0.5rem; }
.ob-work-forces { font-style: italic; }
.ob-work-text { line-height: 1.65; margin: 0 0 0.6rem; }

.ob-work-figure {
  overflow: hidden;
  border-radius: var(--ob-radius);
  background: var(--ob-surface);
  margin: 0 0 0.9rem;
}
.ob-work-img { display: block; width: 100%; height: auto; }

.ob-work-frame {
  aspect-ratio: 16 / 9;
  overflow: hidden;
  border-radius: var(--ob-radius);
  background: var(--ob-surface);
  margin: 0.9rem 0;
}
.ob-work-embed { display: block; width: 100%; height: 100%; border: 0; }

.ob-work-links { margin: 0.75rem 0 0; font-size: 0.9rem; }
.ob-work-link { margin-right: 1rem; white-space: nowrap; }
/* Kind affordances: a score is a download, not a destination — and a
   self-hosted file always is, whatever it holds. */
.ob-work-link[data-kind="score"]::before,
.ob-work-link[data-kind="download"]::before { content: "\2193\00a0"; }
.ob-work-more { margin: 0.6rem 0 0; font-size: 0.9rem; }
.ob-work-more-link { color: var(--ob-accent-deep); }

/* A review quote under its work (the opt-in workQuotes ctx knob) — the
   recordings module's ob-recording-quote, this module's vocabulary. */
.ob-work-quote {
  border-left: 2px solid var(--ob-accent);
  margin: 0.9rem 0 0;
  padding: 0.1rem 0 0.1rem 0.9rem;
  font-size: 0.9rem;
}
.ob-work-quote p { font-style: italic; margin: 0; }
/* Emphasis inside italic goes upright — see the press module's note. */
.ob-work-quote p em { font-style: normal; }
.ob-work-quote cite {
  display: block;
  margin-top: 0.3rem;
  font-style: normal;
  color: var(--ob-text-muted);
}

/* Cards variant — image-forward tiles, one across → two → three. */
.ob-works-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: 2rem 1.5rem;
  align-items: start;
  margin: 1.25rem 0;
}
@media (min-width: 40rem) { .ob-works-grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 64rem) { .ob-works-grid { grid-template-columns: repeat(3, 1fr); } }

.ob-works--cards .ob-work {
  border: 1px solid var(--ob-line);
  border-radius: var(--ob-radius);
  padding: 1.25rem;
}
.ob-works--cards .ob-work-figure { margin: -1.25rem -1.25rem 1rem; border-radius: 0; }
.ob-works--cards .ob-work-img { aspect-ratio: 3 / 2; object-fit: cover; }
.ob-work-title {
  font-family: var(--ob-font-heading);
  color: var(--ob-heading-color, var(--ob-text));
  font-size: 1.15rem;
  font-weight: 500;
  line-height: 1.3;
  margin: 0 0 0.2rem;
}
.ob-works--cards .ob-work-year { display: block; margin: 0 0 0.5rem; }


/* --- press-kit module (ob-*) -------------------------------------------- */
.ob-presskit { font-family: var(--ob-font-body); margin: 0 0 2.5rem; }
.ob-presskit-link { color: var(--ob-link); text-decoration: none; }
.ob-presskit-link:hover { color: var(--ob-link-hover); }
.ob-presskit-affix { font-weight: 600; }

/* Line — a bare row of links. Flex + gap rather than margins, so a row
   that wraps keeps even spacing on every line. */
.ob-presskit--line,
.ob-presskit-links {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.35rem 1.25rem;
  margin: 0;
  font-size: 0.9rem;
}
.ob-presskit--line .ob-presskit-link + .ob-presskit-link::before,
.ob-presskit-links .ob-presskit-link + .ob-presskit-link::before {
  content: var(--ob-presskit-sep, "");
  color: var(--ob-text-muted);
  margin-right: 1.25rem;
}

/* Panel — the same row under a heading, between rules. */
.ob-presskit--panel {
  border-top: 1px solid var(--ob-line);
  border-bottom: 1px solid var(--ob-line);
  padding: 1rem 0;
}
.ob-presskit-heading {
  font-family: var(--ob-font-heading);
  color: var(--ob-heading-color, var(--ob-text));
  font-size: 0.85rem;
  text-transform: uppercase;
  letter-spacing: 0.2em;
  margin: 0 0 0.5rem;
}


/* --- bio module (ob-*) --------------------------------------------------- */
.ob-bio { font-family: var(--ob-font-body); color: var(--ob-text); }

.ob-bio-heading {
  font-family: var(--ob-font-heading);
  color: var(--ob-heading-color, var(--ob-text));
  font-size: 1.25rem;
  line-height: 1.3;
  margin: 2rem 0 1rem;
}
.ob-bio-section:first-child > .ob-bio-heading { margin-top: 0; }

/* A photograph introducing a section — the author breaking up their own long
   prose. No height here: the ladder (ob-cover--strip / standard / tall / full)
   is the design's to draw, and a design that draws none renders the picture at
   its own aspect, which is a photograph rather than a broken page. */
.ob-section-image {
  display: block;
  width: 100%;
  object-fit: cover;
  margin: 2rem 0;
  border-radius: var(--ob-radius);
}
.ob-section-image:first-child { margin-top: 0; }

/* The roster line. The separator is CSS so a design can change a middot to a
   slash without new markup, and the last player must not trail one. */
.ob-bio-roster {
  margin: 0 0 2rem;
  color: var(--ob-bio-roster-color, var(--ob-text-muted));
  font-size: var(--ob-bio-roster-size, 0.75rem);
  font-family: var(--ob-bio-roster-font, inherit);
  letter-spacing: var(--ob-bio-roster-tracking, 0.1em);
  text-transform: var(--ob-bio-roster-transform, uppercase);
  text-align: var(--ob-bio-roster-align, left);
}
.ob-bio-player + .ob-bio-player::before { content: var(--ob-bio-roster-sep, "\00a0\00b7\00a0"); }

.ob-bio-section p { margin: 0 0 1rem; line-height: 1.7; }
.ob-bio-section p:last-child { margin-bottom: 0; }
.ob-bio-section + .ob-bio-section { margin-top: 1rem; }

/* A structured list (`proseListSchema`) — a bulleted roll, a numbered
   process, or, once any item carries a `term`, a definition list for a
   "thing — price" line. Shared markup between a bio/custom-page section's
   own `list` and a custom page's top-level one, which is why the rule lives
   here rather than beside `.ob-page-credits` in a design's own sheet: one
   set of classes, styled once, wherever `renderProseList` is called from. */
.ob-list {
  margin: 0 0 1rem;
  padding-left: var(--ob-list-indent, 1.25rem);
  color: var(--ob-text);
}
.ob-list:last-child { margin-bottom: 0; }
.ob-list li { margin: 0 0 0.5rem; line-height: 1.6; }
.ob-list li:last-child { margin-bottom: 0; }

/* A term-bearing list carries no bullet or number of its own — the term
   already labels the row a marker would otherwise stand in for. Rows are
   `<div>`s (valid inside a `<dl>`) so a design can lay the term and its
   text out as a grid — the "tabular restraint" a fee line wants, the same
   idiom `.ob-event-date`'s `font-variant-numeric` gives a diary. */
.ob-list--terms { list-style: none; padding-left: 0; }
.ob-list-row {
  display: flex;
  flex-wrap: wrap;
  column-gap: 0.5em;
  margin: 0 0 0.5rem;
  line-height: 1.6;
}
.ob-list-row:last-child { margin-bottom: 0; }
.ob-list-row dt {
  font-weight: var(--ob-list-term-weight, 600);
  color: var(--ob-heading-color, var(--ob-text));
}
.ob-list-row dd { margin: 0; color: var(--ob-text); }

/* "lead" — the opening run, set larger. The size is a hook because the five
   designs that enlarge it disagree by a step and one of them (nocturne) also
   lightens the weight. */
.ob-bio-section--lead p {
  font-size: var(--ob-bio-lead-size, 1.125rem);
  font-weight: var(--ob-bio-lead-weight, inherit);
}

/* "panel" — a boxed run. Every part defaults to invisible in the
   --ob-chip-bg idiom, so a variant that panels on a design which sets none of
   them still reads as a plain run rather than an empty box. The border is
   split style/width/colour for contactModule's reason: three of the four
   designs that panel draw the border on one side only. */
.ob-bio-section--panel {
  margin: 1.5rem 0;
  background: var(--ob-bio-panel-bg, transparent);
  border-style: var(--ob-bio-panel-border-style, none);
  border-width: var(--ob-bio-panel-border-width, 0);
  border-color: var(--ob-bio-panel-border-color, var(--ob-accent));
  border-radius: var(--ob-radius);
  padding: var(--ob-bio-panel-pad, 0);
}
.ob-bio-section--panel > .ob-bio-heading { margin-top: 0; }

/* Roles. "cards" is a grid of boxes, "list" a plain run of entries, "steps"
   the same run with a numbered badge in a leading column. */
.ob-roles { display: grid; gap: var(--ob-role-gap, 1.5rem); margin-top: 1.5rem; }
@media (min-width: 768px) {
  .ob-roles--cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

.ob-roles--cards .ob-role {
  background: var(--ob-role-card-bg, transparent);
  border-style: var(--ob-role-card-border-style, none);
  border-width: var(--ob-role-card-border-width, 0);
  border-color: var(--ob-role-card-border-color, var(--ob-accent));
  border-radius: var(--ob-radius);
  box-shadow: var(--ob-role-card-shadow, none);
  padding: var(--ob-role-card-pad, 0);
}

.ob-roles--steps .ob-role { display: flex; gap: 1rem; align-items: flex-start; }
/* The badge is the module's; its shape is the design's. A design that sets
   nothing gets a plain number rather than a broken circle. */
.ob-role-step {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: var(--ob-role-step-size, 2rem);
  height: var(--ob-role-step-size, 2rem);
  border-radius: var(--ob-role-step-radius, 50%);
  background: var(--ob-role-step-bg, transparent);
  color: var(--ob-role-step-fg, var(--ob-text));
  font-weight: 700;
}
.ob-role-main { flex: 1; min-width: 0; }

.ob-role-org {
  font-family: var(--ob-font-heading);
  color: var(--ob-heading-color, var(--ob-text));
  font-size: 1.05rem;
  margin: 0;
}
.ob-role-link { color: inherit; text-decoration: none; }
.ob-role-link:hover { color: var(--ob-link-hover); }
.ob-role-meta { margin: 0.25rem 0 0.5rem; font-size: 0.9rem; color: var(--ob-text-muted); }
.ob-role-body p { margin: 0 0 0.75rem; font-size: 0.95rem; line-height: 1.65; }
.ob-role-body p:last-child { margin-bottom: 0; }

.ob-bio-empty { color: var(--ob-text-muted); }

/* The short variant: a home-page teaser, so it is set a step larger than
   body copy and the link reads as the page's one call to action. Both are
   hooks with fallbacks, the button being the part designs disagree about
   most — bravura fills it with the accent, nocturne leaves it a plain link. */
.ob-bio-short-line {
  font-size: var(--ob-bio-short-size, 1.125rem);
  line-height: 1.7;
  margin: 0 0 1rem;
  max-width: var(--ob-bio-short-measure, 48rem);
}
.ob-bio-more {
  display: inline-block;
  margin-top: 0.5rem;
  padding: var(--ob-bio-more-pad, 0.75rem 1.5rem);
  background: var(--ob-bio-more-bg, var(--ob-accent));
  color: var(--ob-bio-more-fg, var(--ob-on-accent));
  border-radius: var(--ob-radius);
  font-size: 0.875rem;
  letter-spacing: var(--ob-bio-more-tracking, 0.1em);
  text-transform: var(--ob-bio-more-transform, uppercase);
  text-decoration: none;
}
/* The hover rule states its own ink rather than leaving color to the base
   rule above. A bare background restatement here used to compete for color
   at (0,1,0), the same specificity the base rule already has, so a design's
   own blanket `a:hover` — one element plus one pseudo-class, (0,1,1) — won
   the property outright and repainted the label in whatever a design says
   about link hover, in place of the button's own ink. Ottavo's light
   palette is
   the case that made it visible: `--o-light-link-hover` and
   `--o-light-accent-hover` are the same #8c2831, so the label was painted
   in the exact colour the button had just become and the text disappeared.
   Stating color here makes this rule (0,2,0), which wins the property
   whatever a design's own link-hover rule says. */
.ob-bio-more:hover {
  background: var(--ob-bio-more-bg-hover, var(--ob-accent-hover));
  color: var(--ob-bio-more-fg, var(--ob-on-accent));
}


/* --- members module (ob-*) ---------------------------------------------- */
.ob-members { font-family: var(--ob-font-body); color: var(--ob-text); }

.ob-members-heading {
  font-family: var(--ob-font-heading);
  color: var(--ob-heading-color, var(--ob-text));
  font-size: 1.35rem;
  line-height: 1.3;
  margin: 0 0 1.5rem;
}

.ob-members-list {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--ob-member-gap, 2.5rem);
}

/* Cards and portraits are grids across; stacked and prose are one column of
   full-width units, so they ignore the column count entirely. */
@media (min-width: 768px) {
  .ob-members--cards .ob-members-list[data-ob-columns="2"],
  .ob-members--portraits .ob-members-list[data-ob-columns="2"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .ob-members--cards .ob-members-list[data-ob-columns="3"],
  .ob-members--portraits .ob-members-list[data-ob-columns="3"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .ob-members--cards .ob-members-list[data-ob-columns="4"],
  .ob-members--portraits .ob-members-list[data-ob-columns="4"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 1024px) {
  .ob-members--cards .ob-members-list[data-ob-columns="3"],
  .ob-members--portraits .ob-members-list[data-ob-columns="3"] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .ob-members--cards .ob-members-list[data-ob-columns="4"],
  .ob-members--portraits .ob-members-list[data-ob-columns="4"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}

/* The unit. The card treatment is hooks defaulting to invisible, in the
   --ob-chip-bg idiom: a design that wants a raised white card sets a ground,
   a padding and a shadow; a design that wants the block on the bare page
   sets none and gets exactly that. `--ob-member-rule` is the separate
   hairline-above treatment (fanfare rules each player off). */
.ob-member {
  background: var(--ob-member-card-bg, transparent);
  border-top: var(--ob-member-rule, none);
  border-radius: var(--ob-radius);
  box-shadow: var(--ob-member-card-shadow, none);
  padding: var(--ob-member-card-pad, 0);
}

/* Stacked — portrait in a side column beside the prose. The column width is
   a hook because the eight that do this disagree by design (10rem, 11rem,
   14rem, a third of the row) and it takes a fraction as readily as a length,
   which is what lets toccata's 3-column grid and sonata's fixed 11rem be the
   same rule. */
@media (min-width: 640px) {
  /* Only an imaged member becomes two columns: without the modifier a member
     with no photograph would lay its prose out in the *portrait* column. */
  .ob-members--stacked .ob-member--imaged {
    display: grid;
    grid-template-columns: var(--ob-member-portrait-width, 12rem) 1fr;
    gap: 1.5rem;
    align-items: start;
  }
}

.ob-member-portrait {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: var(--ob-member-portrait-aspect, auto);
  object-fit: cover;
  border: var(--ob-member-portrait-border, none);
  border-radius: var(--ob-member-portrait-radius, 0);
  box-shadow: var(--ob-member-portrait-shadow, none);
}
.ob-member-portrait-link { display: block; }

.ob-members--cards .ob-member-portrait,
.ob-members--portraits .ob-member-portrait { margin-bottom: 1rem; }

/* A portrait fills its cell, so *fewer* columns means *bigger* pictures —
   the opposite of what the column clamp is for. Two players in a wall
   declared four across would otherwise get half-page posters. The cap is
   above any cell width the declared counts produce at the measures these
   designs use, so it only bites when the clamp has widened the cell. */
.ob-members--portraits .ob-member {
  text-align: center;
  max-width: var(--ob-member-portrait-max, 15rem);
  margin-inline: auto;
}

.ob-member-name {
  font-family: var(--ob-font-heading);
  color: var(--ob-heading-color, var(--ob-text));
  font-size: 1.15rem;
  line-height: 1.35;
  margin: 0;
}
.ob-member-link { color: inherit; text-decoration: none; }
.ob-member-link:hover { color: var(--ob-link-hover); }
/* Madrigal's grid tells the reader the portrait goes somewhere. Decoration,
   so it is a hook rather than markup — the same call as --ob-presskit-sep. */
.ob-member-link::after { content: var(--ob-member-affix, ""); }

/* The instrument. Two designs put it on its own line under the name and six
   run it on, so the display is a hook; the separator that precedes it when
   inline is another, since three designs draw one and three don't. */
.ob-member-instrument {
  display: var(--ob-member-instrument-display, inline);
  font-family: var(--ob-font-body);
  font-size: 0.85rem;
  font-weight: 400;
  letter-spacing: 0.04em;
  color: var(--ob-member-instrument-color, var(--ob-text-muted));
}
/* No separator by default, because the default is no separator: the three
   designs that put the instrument on its own line want nothing before it,
   and a space there indents that line out of alignment with the name. The
   five that run it on set their own — with non-breaking spaces, ordinary
   trailing whitespace being dropped at the end of the pseudo-element box. */
.ob-member-instrument::before { content: var(--ob-member-sep, ""); }

.ob-member-body { margin-top: 0.85rem; line-height: 1.7; }
.ob-member-body p { margin: 0 0 0.85rem; }
.ob-member-body p:last-child { margin-bottom: 0; }
.ob-members-empty { color: var(--ob-text-muted); }


/* --- contact module (ob-*) ---------------------------------------------- */
.ob-contact { font-family: var(--ob-font-body); color: var(--ob-text); }

.ob-contact-cards {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1.5rem;
}
@media (min-width: 768px) {
  .ob-contact-cards[data-ob-columns="2"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .ob-contact-cards[data-ob-columns="3"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 1024px) {
  .ob-contact-cards[data-ob-columns="3"] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

.ob-contact-heading {
  font-family: var(--ob-font-heading);
  color: var(--ob-heading-color, var(--ob-text));
  font-size: 1.05rem;
  line-height: 1.3;
  margin: 0 0 0.75rem;
}
.ob-contact-name { font-weight: 600; margin: 0 0 0.25rem; }
.ob-contact-address { margin: 0 0 0.75rem; font-size: 0.9rem; line-height: 1.6; }
.ob-contact-address p { margin: 0; }
.ob-contact-details { font-size: 0.9rem; line-height: 1.6; }
.ob-contact-row { margin: 0; }
.ob-contact-row--email { overflow-wrap: anywhere; }
.ob-contact-link { color: var(--ob-link); text-decoration: none; }
.ob-contact-link:hover { color: var(--ob-link-hover); }
.ob-contact-empty { color: var(--ob-text-muted); }

/* Cards — each entry in its own box. The surface is a hook rather than the
   ramp's own `surface`, because four designs box the card against the page
   ground and two of those want white on a tinted page. */
.ob-contact--cards .ob-contact-card {
  background: var(--ob-contact-card-bg, transparent);
  border: 1px solid var(--ob-line);
  border-radius: var(--ob-radius);
  padding: 1.5rem;
}

/* Ruled — a hairline above each entry instead of a box (madrigal's whole
   design language). The rule is a hook so the width, style and colour move
   together; a design that doesn't set it gets no rule and no padding. */
.ob-contact--ruled .ob-contact-card {
  border-top: var(--ob-contact-rule, 1px solid var(--ob-line));
  padding-top: 1rem;
}

/* Plain — no chrome. The card is a group of paragraphs and nothing else. */

/* The form block's chrome is hooks, all defaulting to invisible, in the
   --ob-chip-bg/--ob-chip-fg idiom: a design that wants the form to read as a
   panel sets a surface, a border and the padding that goes with them; a design
   that wants it to sit on the page sets none and gets exactly that.

   The border is split into style/width/colour rather than one shorthand
   because two of the eight draw it on **one side** — madrigal rules the form
   off above it, toccata puts a 4px accent bar down its left — and a border
   shorthand cannot say that. Padding is its own hook for a related reason:
   CSS cannot ask whether a border was set, so the padding a panel needs has
   to be stated. */
.ob-contact-form {
  margin-top: 2.5rem;
  max-width: 36rem;
  background: var(--ob-contact-form-bg, transparent);
  border-style: var(--ob-contact-form-border-style, none);
  border-width: var(--ob-contact-form-border-width, 0);
  border-color: var(--ob-contact-form-border-color, var(--ob-line));
  border-radius: var(--ob-radius);
  padding: var(--ob-contact-form-pad, 0);
}
.ob-contact-form-heading {
  font-family: var(--ob-font-heading);
  color: var(--ob-heading-color, var(--ob-text));
  font-size: 1.05rem;
  margin: 0 0 0.5rem;
}
.ob-contact-form-text { margin: 0 0 1rem; font-size: 0.9rem; }
.ob-contact-fields { max-width: 36rem; }
/* The visible label wraps its control (ux-charter §11), so the between-field
   rhythm lives on the label and the field itself sits flush inside it. */
.ob-contact-label { display: block; margin: 0 0 0.9rem; }
.ob-contact-label-text {
  display: block;
  font-size: 0.78rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ob-text-muted);
  margin: 0 0 0.35rem;
}
.ob-contact-field {
  display: block;
  width: 100%;
  box-sizing: border-box;
  font-family: inherit;
  font-size: 0.95rem;
  color: var(--ob-text);
  background: var(--ob-contact-field-bg, transparent);
  border: 1px solid var(--ob-line);
  border-radius: var(--ob-radius);
  padding: 0.75rem 1rem;
  margin: 0;
}
.ob-contact-field:focus { outline: none; border-color: var(--ob-accent); }
.ob-contact-button {
  display: inline-block;
  font-family: inherit;
  font-size: 0.85rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  text-decoration: none;
  cursor: pointer;
  color: var(--ob-on-accent);
  background: var(--ob-accent);
  border: 1px solid var(--ob-accent);
  border-radius: var(--ob-radius);
  padding: 0.75rem 2rem;
}
/* The same fault as the bio module's "Read the full biography" button, and
   the second one the audit for it turned up: an <a> whose ink is set on the
   base rule at (0,1,0), with a hover rule restating only the background. A
   design's own `a:hover` takes the colour at (0,1,1), and under ottavo's
   light palette that is the button's own hover background, so the label
   vanished into it. See BIO_CSS for the full reasoning. */
.ob-contact-button:hover {
  background: var(--ob-accent-hover);
  border-color: var(--ob-accent-hover);
  color: var(--ob-on-accent);
}

/* =========================================================================
   Ottavo — the poster outside the hall.

   Stage-dark ground with a warm plum bias, warm ivory type, one carmine
   accent. Bodoni Moda carries the display voice (a concert poster's didone),
   Archivo does the quiet work. The photography is the design: the hero is
   the viewport, the page covers are full-bleed, and everything else stays
   out of the pictures' way.

   Plain CSS, no build step. The :root block is the --ob-* contract — the
   eighteen tokens the shared modules read without a fallback, then the
   per-design hooks they read *with* one. Module stylesheets are concatenated
   ahead of this file, so anything here wins without specificity tricks.

   TWO PALETTES, both hand-drawn (docs/slot-templates.md, "Colour modes").
   The dark one is this design's native voice and is unchanged to the byte.
   The light one is the same poster in daylight rather than the dark one
   inverted — inverting is the deleted ramp deriver and the one thing that
   must not come back. So: warm bone rather than white (a cold white would be
   a different design beside that ivory type), a plum-black ink that keeps the
   dark ground's bias, and the carmine taken *down* the lightness scale until
   it holds on paper, where in the dark it sits above the ground.

   Three mechanics, each with a reason that is not obvious:

   1. `light-dark()` carries both values in one declaration, so a token has
      one home rather than two to drift apart. The mode is chosen entirely by
      `color-scheme`: `light dark` follows the reader, and the two
      [data-ob-mode] rules at the end of this block force one. That is the
      whole implementation of site.colorMode here.
   2. The accent families are declared as --o-* and *mapped* to --ob-*.
      accents.ts refuses a property declared more than once — it has no way to
      tell which declaration the customer meant — so redeclaring --ob-accent
      per mode would break /color and the intake colour step outright. The
      gauge substitutes --o-accent-*, declared once; --ob-accent is this
      design's own mapping and the gauge never sees it. The gauge drives the
      **dark** family because dark is what this design ships and what its
      contrast pairs are declared against; the light family is the designer's
      until the gauge learns to rotate both.
   3. Every hook is a var() onto a contract token, so hooks follow the mode
      for free. --ob-chip-fg was the exception — a literal, and therefore
      exactly the hook that would have kept its dark value in the light — so
      it is a gauge property with a light twin like everything else.
   ========================================================================= */

:root {
  /* Both, so the reader's own setting decides; the two rules at the end of
     this block are what a site that has forced a mode gets instead. */
  color-scheme: dark light;

  /* --- the accent family, dark: the gauge's properties ------------------ */
  /* Exactly one declaration each — accents.json substitutes these by name.
     Not used directly; the mapping is under "the contract". */
  --o-accent: #b13a44;
  --o-accent-hover: #c94f59;
  --o-accent-active: #d96570;
  --o-accent-bright: #e2717b;
  --o-accent-soft: #33222a;
  --o-accent-deep: #6f1d26;
  --o-on-accent: #ffffff;
  --o-on-accent-muted: #f0cdd0;
  --o-link: #d98a91;
  --o-link-hover: #e5a3a9;
  --o-chip-fg: #e8b9be;

  /* --- the accent family, light: hand-drawn ----------------------------- */
  /* The carmine walked *down* rather than up: on bone it has to be the
     darker thing, so "soft" is a pale wash and "deep" the near-oxblood, the
     mirror of what they are in the dark. --o-light-on-accent stays white
     because the accent it sits on is dark in this mode too. */
  --o-light-accent: #a32f3a;
  --o-light-accent-hover: #8c2831;
  --o-light-accent-active: #75212a;
  --o-light-accent-bright: #c4444f;
  --o-light-accent-soft: #f4e2e3;
  --o-light-accent-deep: #5e1820;
  --o-light-on-accent: #ffffff;
  --o-light-on-accent-muted: #f0cdd0;
  --o-light-link: #a32f3a;
  --o-light-link-hover: #8c2831;
  --o-light-chip-fg: #7a2029;

  /* --- the neutrals, both modes ----------------------------------------- */
  --o-ground: #131016;
  --o-surface: #1c1821;
  --o-text: #ede7dd;
  --o-text-muted: #a89d90;
  --o-line: #2e2833;
  --o-light-ground: #f7f3ee;
  --o-light-surface: #ece5dc;
  --o-light-text: #1c161a;
  --o-light-text-muted: #6a6058;
  --o-light-line: #ddd3c7;

  /* --- the contract ---------------------------------------------------- */
  --ob-ground: light-dark(var(--o-light-ground), var(--o-ground));
  --ob-surface: light-dark(var(--o-light-surface), var(--o-surface));
  --ob-text: light-dark(var(--o-light-text), var(--o-text));
  --ob-text-muted: light-dark(var(--o-light-text-muted), var(--o-text-muted));
  --ob-accent: light-dark(var(--o-light-accent), var(--o-accent));
  --ob-accent-hover: light-dark(var(--o-light-accent-hover), var(--o-accent-hover));
  --ob-accent-active: light-dark(var(--o-light-accent-active), var(--o-accent-active));
  --ob-accent-bright: light-dark(var(--o-light-accent-bright), var(--o-accent-bright));
  --ob-accent-soft: light-dark(var(--o-light-accent-soft), var(--o-accent-soft));
  --ob-accent-deep: light-dark(var(--o-light-accent-deep), var(--o-accent-deep));
  --ob-on-accent: light-dark(var(--o-light-on-accent), var(--o-on-accent));
  --ob-on-accent-muted: light-dark(var(--o-light-on-accent-muted), var(--o-on-accent-muted));
  --ob-link: light-dark(var(--o-light-link), var(--o-link));
  --ob-link-hover: light-dark(var(--o-light-link-hover), var(--o-link-hover));
  --ob-font-heading: "Bodoni Moda", "Bodoni MT", Didot, "Times New Roman", serif;
  --ob-font-body: "Archivo", ui-sans-serif, system-ui, sans-serif;
  /* The weight each face is set at when it heads something. A hook rather
     than two of the eighteen — everything below consumes them with a
     fallback — but written by /type beside the families above, which is why
     they live here and not down with the module hooks.

     They exist because `font-weight: 500` was a lie this design told itself:
     it is one step over body in Bodoni, two steps' worth of ink in Oswald,
     the tamest register Syne has, and *nothing at all* in the five catalogued
     faces with no weight axis, where 500 resolves to the one weight they
     ship. That single step was the whole hierarchy, so on those faces the
     hierarchy simply wasn't there — a section heading lighter than the
     sentence under it (found by eye five times over in the 2026-08-20 type
     audit, before anyone read a font query). Each face now states the weight
     it wants and /type writes that number here, so the value is always one
     the font actually has and never one the browser has to invent.

     Ottavo's own pair is Bodoni Moda's 500 and Archivo's 500, which is what
     the file said everywhere before it could vary. */
  --ob-font-heading-weight: 500;
  --ob-font-body-weight: 500;
  --ob-line: light-dark(var(--o-light-line), var(--o-line));
  /* The corner, and theme.json's one knob (2026-08-21). Sharp is what this
     design is: a poster is trimmed, not rounded, and the ladder stops at 4px
     because a didone at StartUI's 1rem is a different and worse design. It
     reaches the boxes the modules paint — photo frames, covers, chips, cards,
     contact fields — plus the four this file paints for itself (the bio
     portrait, a custom page's photographs, the nav panel, the menu toggle).
     What it deliberately never reaches is anything full-bleed: the hero, the
     page cover and the bands run to the viewport edge, and a corner there
     would be a rounded *page*. */
  --ob-radius: 0px;

  /* --- hooks: read by the modules with a fallback ----------------------- */
  --ob-heading-color: var(--ob-text);
  --ob-chip-bg: var(--ob-accent-soft);
  --ob-chip-fg: light-dark(var(--o-light-chip-fg), var(--o-chip-fg));
  /* day-month dates ("14 March") in the schedule's list column. */
  --ob-event-date-width: 7.5rem;
  --ob-contact-rule: 1px solid var(--ob-line);
  /* A player's portrait is a photograph in a box like any other, so it takes
     the same corner rather than a second opinion about one. */
  --ob-member-portrait-radius: var(--ob-radius);
  --ob-member-instrument-color: var(--ob-text-muted);
  /* On its own line under the name — inline, it glues to the heading. */
  --ob-member-instrument-display: block;
  --ob-bio-roster-font: var(--ob-font-body);
  --ob-bio-roster-tracking: 0.16em;
  --ob-bio-roster-transform: uppercase;
  --ob-bio-roster-size: 0.72rem;
  --ob-bio-roster-color: var(--ob-text-muted);
  /* A list's marker sits close to its text, a poster's line rather than a
     memo's, and a term takes the heading face rather than a heavier body
     weight, the didone at display size already reading as the emphasis. */
  --ob-list-indent: 1.15rem;
  --ob-list-term-weight: 500;

  /* --- this design's own ------------------------------------------------ */
  --o-measure: 74rem;
  --o-narrow: 46rem;
  --o-gutter: 1.5rem;
  /* The bottom two rungs of the scale — a module's section heading, and the
     title of one item in a list. Named here rather than left to the eleven
     modules because a scale is a single judgement and they each hold a
     fragment of it; see "the rank that cannot come from ink" below. */
  --o-heading-section: 1.5rem;
  --o-heading-item: 1.25rem;
  --o-rule: 1px solid var(--ob-line);
  /* One inline geometry for the hero and the shell, so type over the
     photograph lines up with type on the page. */
  --o-inset: max(var(--o-gutter), calc((100vw - var(--o-measure)) / 2));
  /* Type over a photograph. Deliberately *not* light-dark(), which is the
     whole point: the hero's scrim is dark in both modes, so the type on top
     of it is the dark palette's ivory in both modes too. A photograph is not
     the page ground and type on it must not follow the page ground — in the
     light mode that is plum-black ink over a scrimmed picture, which is
     invisible on any photograph darker than the page, and a customer's one
     uploaded photograph is dark about half the time. */
  --o-on-photo: var(--o-text);
  --o-on-photo-muted: var(--o-text-muted);
  --o-photo-shadow: 0 1px 10px rgba(10, 8, 12, 0.8);
}

/* The forced modes (site.colorMode). `auto` stamps nothing, so a site that
   has not chosen keeps the `dark light` above and follows its reader. No
   palette belongs in these rules: switching the used colour scheme is what
   re-resolves every light-dark() on the page. */
:root[data-ob-mode="light"] { color-scheme: light; }
:root[data-ob-mode="dark"] { color-scheme: dark; }

/* -------------------------------------------------------------------------
   Base
   ------------------------------------------------------------------------- */

*, *::before, *::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  background: var(--ob-ground);
  color: var(--ob-text);
  font-family: var(--ob-font-body);
  font-size: 1rem;
  line-height: 1.7;
}

img { max-width: 100%; height: auto; }

a { color: var(--ob-link); }
a:hover { color: var(--ob-link-hover); }

:focus-visible {
  outline: 2px solid var(--ob-accent-bright);
  outline-offset: 3px;
}

::selection { background: var(--ob-accent-deep); color: var(--ob-on-accent); }

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  background: var(--ob-accent);
  color: var(--ob-on-accent);
  padding: 0.6rem 1rem;
  border-radius: var(--ob-radius);
  z-index: 40;
}
.skip-link:focus { left: 0; }

.shell {
  width: 100%;
  max-width: var(--o-measure);
  margin-inline: auto;
  padding-inline: var(--o-gutter);
}
/* Cap the children, not the container — a capped container re-centres and
   the page title drifts off the text's left edge (quarto's lesson). */
.shell--narrow > * { max-width: var(--o-narrow); }

.ob-event-date, .ob-track-number, .ob-track-duration, .ob-work-year, .ob-date, .ob-list-row dd {
  font-variant-numeric: tabular-nums;
}

/* -------------------------------------------------------------------------
   Masthead
   ------------------------------------------------------------------------- */

.bar {
  /* The bar's ink follows whatever is *behind* the bar, and that is the page
     ground everywhere except over the hero photograph, where it is the
     photograph. Held in variables rather than written as a chain of colour
     overrides because four things take the colour and there are three
     grounds to take it from, and `:has()` carries its argument's
     specificity — as overrides it is a specificity puzzle, as inherited
     variables it is a cascade. Anything inside the bar that paints its own
     ground (the stacked Menu, a group's panel) sets them back. */
  --o-bar-ink: var(--ob-text);
  --o-bar-ink-muted: var(--ob-text-muted);
  --o-bar-line: var(--ob-line);
  --o-bar-shadow: none;
  background: var(--ob-ground);
  border-bottom: var(--o-rule);
}
.bar-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem 1.5rem;
  padding-block: 1.1rem;
  /* The anchor for the More panel: its `top: 100%` is the bottom of the bar
     row rather than the bottom of the menu's own box, which sits inside the
     row's padding and would leave the panel overlapping the bar. */
  position: relative;
}

.brand { display: flex; align-items: baseline; gap: 1rem; min-width: 0; }
.mark { text-decoration: none; color: inherit; display: inline-flex; align-items: center; }
.ob-wordmark {
  font-family: var(--ob-font-heading);
  font-weight: var(--ob-font-heading-weight, 500);
  font-size: 1.05rem;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--o-bar-ink, var(--ob-text));
  text-shadow: var(--o-bar-shadow, none);
  white-space: nowrap;
}
.ob-logo { display: block; }
/* Sized by HEIGHT, not by a max-height, and that is not a style preference:
   `.mark` is an inline-flex box, an SVG with a viewBox and no width/height
   attributes has a ratio but no intrinsic size, and flexbox resolves that
   pair to a 0×0 item — the mark simply does not paint, in a browser only,
   with nothing wrong in the markup. A stated height gives the ratio
   something to multiply. `max-width` then keeps a genuinely wide wordmark
   from crowding the menu on a narrow bar. */
.ob-logo--wide { height: 2.4rem; width: auto; max-width: 15rem; }
.ob-logo--square { height: 2.4rem; width: auto; }

/* The logo slot asks for `data-ob-ground="auto"` — the bar's ground is
   --ob-ground and this design has two. Under a forced mode the engine picks
   one file and nothing here applies; under `auto` it emits both grounds'
   artwork and these rules choose. The one place light-dark() cannot help,
   `display` not being a colour, so it is the two-block pattern. The wordmark
   is classed alongside: class only the picture and a site whose artwork suits
   one ground shows its fallback text in both modes. */
.ob-logo--on-light, .ob-wordmark--on-light { display: none; }
@media (prefers-color-scheme: light) {
  :root:not([data-ob-mode="dark"]) .ob-logo--on-dark,
  :root:not([data-ob-mode="dark"]) .ob-wordmark--on-dark { display: none; }
  :root:not([data-ob-mode="dark"]) .ob-logo--on-light { display: block; }
  :root:not([data-ob-mode="dark"]) .ob-wordmark--on-light { display: inline; }
}
:root[data-ob-mode="light"] .ob-logo--on-dark,
:root[data-ob-mode="light"] .ob-wordmark--on-dark { display: none; }
:root[data-ob-mode="light"] .ob-logo--on-light { display: block; }
:root[data-ob-mode="light"] .ob-wordmark--on-light { display: inline; }
.bar-tagline {
  margin: 0;
  font-size: 0.72rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--o-bar-ink-muted, var(--ob-text-muted));
  text-shadow: var(--o-bar-shadow, none);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.bar-tagline:empty { display: none; }

/* Nav: a quiet row of tracked caps; the current page carries the accent. */
.ob-nav-list {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 0.35rem 1.15rem;
  list-style: none;
  margin: 0;
  padding: 0;
}
.ob-nav-link {
  display: inline-block;
  text-decoration: none;
  color: var(--o-bar-ink-muted, var(--ob-text-muted));
  text-shadow: var(--o-bar-shadow, none);
  font-size: 0.72rem;
  /* The body face's own label weight, not the heading face's: this row is set
     in --ob-font-body. Lato is the case that proves it needs asking — it
     ships 400 and 700 with nothing between, so the bar asked for 500 and got
     400, and the floating menu over the hero rendered a step light with
     nothing in the source to say why. */
  font-weight: var(--ob-font-body-weight, 500);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  padding-block: 0.25rem;
  border-bottom: 2px solid transparent;
}
.ob-nav-link:hover { color: var(--o-bar-ink, var(--ob-text)); }
.ob-nav-link--current {
  color: var(--o-bar-ink, var(--ob-text));
  border-bottom-color: var(--ob-accent);
}
/* A plain item and a group used to be two different boxes in the same row.
   A group's only in-flow child (the heading) sits flush at the top of its
   own flex column; a plain item held its <a> as `inline-block`, which sits
   on a line box sized by the <li>'s own inherited strut — 16px Archivo at
   line-height 27.2px, where the anchor's own text runs 11.52px at
   19.584px — so the anchor sat lower than the strut's top by the difference
   between the two, and a group's heading, with no strut to sit inside, did
   not. Measured before this rule: 1.5px (HOME's label top vs MEDIA's, both
   siblings in the same row, 1280px window). Fixed structurally rather than
   with a margin, because a margin is an arithmetic answer to a box-model
   question and drifts the moment a font or a line-height changes: every
   top-level item now gets the same column-flex box the group already had,
   so both labels are flex items flush at their container's own start edge
   and there is no strut in the equation at all. Scoped to the bar's own
   top-level list (excluding a group's nested list and the overflow panel):
   those items already stack in a column of their own, where nothing was
   ever measured sitting beside anything else. */
.bar-row .nav > .ob-nav-list:not(.nav-more-list) > .ob-nav-item,
.ob-nav-group {
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
}
.ob-nav-heading {
  display: block;
  font-size: 0.72rem;
  font-weight: var(--ob-font-body-weight, 500);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--o-bar-ink-muted, var(--ob-text-muted));
  text-shadow: var(--o-bar-shadow, none);
  padding-block: 0.25rem;
  border-bottom: 2px solid transparent;
}
.ob-nav-group > .ob-nav-list { gap: 0.15rem 0.9rem; }
/* Child-scoped, and that matters: a group whose label names a landing page
   renders as `<a class="ob-nav-heading ob-nav-link">`, so a descendant
   selector here restyles the *heading* into a member — one label in the row
   quietly lowercase and a size smaller than its neighbours. */
.ob-nav-group > .ob-nav-list .ob-nav-link {
  letter-spacing: 0.02em;
  text-transform: none;
  font-weight: 400;
  font-size: 0.78rem;
}

/* Groups as panels, above the collapse breakpoint only (2026-08-20).
   -----------------------------------------------------------------
   They used to sit open in the bar — heading in the row, members on a line
   beneath it — which is honest markup and reads fine with one group and
   four pages. It falls apart at the size a real site reaches: a full page
   set with two groups wrapped the bar to three ragged rows, and because
   every member is a sibling in the same wrapping flex row, a group's
   members came to rest beside a *different* group's heading. You could not
   tell what belonged to what, which is worse than a dropdown.
   So above the breakpoint a group's list becomes a panel under its own
   heading, and below it nothing changes: the whole nav is already the
   stacked Menu, where an open list is exactly right and no pointer is
   involved. That split is deliberate — a hover-only affordance on touch is
   the reason `nav-open` exists in site.js, not an argument for it here. */
@media (min-width: 64.01rem) {
  .ob-nav-group { position: relative; }
  /* The heading reads as openable: a caret, and a hit area that includes
     the gap the panel appears in. */
  .ob-nav-heading { cursor: default; }
  .ob-nav-group > .ob-nav-heading::after {
    content: "";
    display: inline-block;
    width: 0.32em;
    height: 0.32em;
    margin-left: 0.5em;
    vertical-align: 0.15em;
    border-right: 1px solid currentColor;
    border-bottom: 1px solid currentColor;
    transform: rotate(45deg);
    opacity: 0.7;
  }
  .ob-nav-group > .ob-nav-list {
    position: absolute;
    top: 100%;
    left: 0;
    z-index: 40;
    display: block;
    min-width: max-content;
    /* Flush under the heading, deliberately: a gap here is outside the
       group's own box, so the pointer crossing it loses :hover and the
       panel shuts on the way to it. */
    margin-top: 0;
    padding: 0.7rem 1rem;
    background: var(--ob-surface);
    border: var(--o-rule);
    border-radius: var(--ob-radius);
    box-shadow: 0 14px 34px light-dark(rgba(28, 22, 26, 0.14), rgba(0, 0, 0, 0.45));
    /* Not `display: none`: a hidden panel cannot take focus, so the
       keyboard could never reach a grouped page. `visibility` keeps it out
       of the tab order while shut and lets keyboard focus open it. */
    visibility: hidden;
    opacity: 0;
    /* The shut position: 4px above flush, transform only (never top/margin/
       height, so the box model above is untouched and the flush static
       position — the thing the hover corridor depends on — never moves).
       Negative only, on purpose: the open transition below travels from
       here up to 0 and never past it, so the panel is never further from
       the heading than its own flush position, which is what keeps this
       from opening a gap at any point in the animation. The hit area while
       shut is unaffected — `visibility: hidden` already takes it out of
       the tab order and out of hit-testing, whatever the transform says. */
    transform: translateY(-4px);
    /* Closing gets a short grace (this is the *destination* state, so its
       transition governs the close): a diagonal exit from the heading
       toward a panel wider than it can leave the group's box for a few
       pixels, and without the grace the panel shuts under the pointer on
       the way in. Visibility holds until the fade is done. The rise back
       to -4px on close takes a plain ease: Miles asked for a settle on
       opening, and a panel on its way out has nothing to settle into. */
    transition: opacity 120ms ease 120ms, visibility 0s linear 240ms, transform 120ms ease 120ms;
  }
  .ob-nav-group > .ob-nav-list > .ob-nav-item + .ob-nav-item { margin-top: 0.35rem; }
  /* Hover opens on intent, not on transit (2026-08-25): a pointer crossing
     the bar on its way somewhere else passes straight through both group
     headings, and without the beat each one flashed its panel open. 150ms
     is long enough to ignore a crossing and short enough to read as
     immediate on a deliberate stop. Hover only — the delay must not ride
     the shared open rule below. */
  .ob-nav-group:hover > .ob-nav-list {
    visibility: visible;
    opacity: 1;
    transform: translateY(0);
    /* Above its shut siblings *and* above a panel held open by the
       keyboard: two panels can still legitimately be open at once when
       focus is parked in one and the pointer is resting over another, and
       in that overlap the one under the pointer is the one being read. */
    z-index: 41;
    /* The settle (Miles: "a subtle bounce on opening"). The panel travels
       from -4px to 0 — arriving from just above rather than sliding sideways
       or growing — with a curve whose control points overshoot 1 before
       returning to it, the small mid-transition "too far, then back" that
       reads as a settle rather than a snap. The overshoot is bounded by how
       little there is to overshoot: at 4px of travel it peaks at a few
       tenths of a pixel past flush, sub-pixel enough that no rendered gap
       ever separates the panel from its heading — and unlike the diagonal-
       exit case above, the group is already under an active hover for the
       whole of this transition, so nothing here is relying on the pointer
       crossing that peak to keep the panel open. transform gets its own
       longer duration than the 120ms fade: a bounce wants a beat longer
       than a crossfade to read as one, and "quick" still means both are
       done inside a third of a second. */
    transition: opacity 120ms ease 150ms, visibility 0s linear 150ms,
      transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1) 150ms;
  }
  /* Keyboard and the click toggle are intent by definition, so they open
     with no delay — a Tab press that had to wait 150ms for visibility
     would let a second Tab skip clean past the panel. Placed after the
     hover rule on purpose: the three selectors tie on specificity, so
     source order is what lets instant win while hover also applies. The
     tie survives the swap below: `:has()` takes the specificity of its
     argument, so `.ob-nav-group:has(:focus-visible)` is a class plus a
     pseudo-class exactly as `.ob-nav-group:hover` is.

     `:has(:focus-visible)` rather than `:focus-within` (2026-08-29, Miles,
     off a bar with two panels open over each other). The heading carries
     `tabindex="0"` because that is the only way a keyboard reaches a group
     at all — the panel's links are `visibility: hidden` and so out of the
     tab order until something opens the panel, and the only something in
     reach is the heading. But a focusable element is also focused by a
     mouse click, and `:focus-within` cannot tell those apart: click a
     heading, move away, and the panel stayed open on focus the pointer had
     left behind. Then hovering the next group opened a second panel over
     the first, which is the screenshot. `:focus-visible` is the browser's
     own answer to that question and it answers it better than any heuristic
     here would — keyboard yes, mouse no — and it is well inside this
     design's floor, `light-dark()` in this same file being newer than both
     `:has()` and `:focus-visible`. */
  .ob-nav-group:has(:focus-visible) > .ob-nav-list,
  .ob-nav-group.is-open > .ob-nav-list {
    visibility: visible;
    opacity: 1;
    transform: translateY(0);
    /* Same settle as the hover rule, with no delay for the same reason
       visibility and opacity carry none here — intent by definition, so the
       bounce starts the instant the state is true rather than waiting out
       the hover-only 150ms. */
    transition: opacity 120ms ease, visibility 0s linear,
      transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1);
  }
  /* The last group sits at the right-hand end of a right-justified row, so
     its panel would hang off the page. */
  .bar-row .ob-nav-group:last-of-type > .ob-nav-list { left: auto; right: 0; }
}
@media (prefers-reduced-motion: reduce) {
  /* Duration to zero, delays kept: the fade is motion and goes; the
     hover-intent beat is timing, not motion, and stripping it would hand
     reduced-motion users back the accidental open everyone else just lost.
     All three states named because they tie the base on nothing — each
     sets the full shorthand, so a bare base override would lose to them.
     One rule zeroes all three of a state's transitions, the settle
     included: `transition-duration: 0s` is a single value against a
     three-entry `transition-property` list, so it repeats across all three
     rather than only reaching the first. With no duration, `transform`
     jumps straight to whatever value the matched state declares — 0 on the
     three open states, -4px on the shut one — so a reduced-motion visitor
     never sees the bounce and never lands mid-transform either: every state
     change is an instant cut to its own settled position. */
  .ob-nav-group > .ob-nav-list,
  .ob-nav-group:hover > .ob-nav-list,
  .ob-nav-group:has(:focus-visible) > .ob-nav-list,
  .ob-nav-group.is-open > .ob-nav-list {
    transition-duration: 0s;
  }
}

.nav-toggle {
  display: none;
  background: none;
  border: 1px solid var(--o-bar-line, var(--ob-line));
  border-radius: var(--ob-radius);
  /* The toggle sits on the bar's ground, not the menu's — it stays over the
     photograph while the menu it opens paints on the page ground under it. */
  color: var(--o-bar-ink, var(--ob-text));
  text-shadow: var(--o-bar-shadow, none);
  font: inherit;
  font-size: 0.72rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  padding: 0.45rem 0.8rem;
  cursor: pointer;
  /* The hit area, not the button: the drawn box measures 36px tall and a
     touch target wants 48, so an invisible pseudo-element extends the
     clickable box past the border without moving a pixel of layout. The
     stacked menu paints after the bar in DOM order, so the downward reach
     is inert over an open menu — the visible rows win — which is the right
     way round. */
  position: relative;
}
.nav-toggle::after {
  content: "";
  position: absolute;
  inset: -0.4rem -0.2rem;
}

.nav-toggle-more { display: none; }
/* The page you are on is one of the ones that moved (site.js). The row has
   nothing left to mark, so the button carries the accent instead. */
.nav-toggle--current {
  border-color: var(--ob-accent);
  color: var(--o-bar-ink, var(--ob-text));
}

@media (max-width: 64rem) {
  .nav-toggle { display: inline-block; }
  .nav { display: none; flex-basis: 100%; }
  .nav.is-open { display: block; background: var(--ob-ground); }
  /* `stretch`, overriding the row's flex-start: in a column the cross axis
     is horizontal, and flex-start shrink-wraps every item to its text — a
     41px-wide "Home" row in a 390px menu is a 41px target with 349px of
     dead strip beside it. */
  .ob-nav-list { flex-direction: column; align-items: stretch; gap: 0; padding-block: 0.9rem; }
  /* Both lists are one stacked menu down here, so the split stops meaning
     anything — the second list must not open a second block of padding. */
  .nav > .ob-nav-list + .ob-nav-list { padding-top: 0; }
  /* Stacked rows are touch targets, so each is a 48px-tall full-width block
     (2026-08-25; they measured 30px, and a thumb is not a cursor). The old
     0.6rem gap is folded into the rows' own padding — every pixel of the
     menu is now somebody's tap, with no dead strips between — which is why
     the list's gap above reads 0 rather than a rhythm choice. Headings
     stay as they were: below the breakpoint they toggle nothing. */
  .nav .ob-nav-link { display: block; padding-block: 0.85rem; }
  /* A block link's border-bottom runs the full menu width, so the current
     page turned into a carmine rule across the whole card. The mark goes
     back to the word: an underline is text-width by nature, and the hit
     box stays the row. */
  .nav .ob-nav-link--current {
    border-bottom-color: transparent;
    text-decoration: underline;
    text-decoration-color: var(--ob-accent);
    text-decoration-thickness: 2px;
    text-underline-offset: 0.45em;
  }
}

/* The overflow menu, above the breakpoint (2026-08-21).
   -----------------------------------------------------
   The row keeps five; site.js moves the rest into a second list inside the
   same <nav>, which the same toggle opens with the same `is-open` class.
   Painted as a panel here and as a plain continuation of the stacked menu
   below the breakpoint — one menu, two widths, which is what makes this a
   cap on the row rather than a navigation of its own. */
@media (min-width: 64.01rem) {
  /* Only when something actually overflowed: a "More" beside three pages is
     a control that opens an empty box. The class is site.js's, so a visitor
     with no JavaScript gets no button and no missing pages — the row simply
     wraps, exactly as it did before. */
  .bar-row.nav-has-more .nav-toggle {
    display: inline-block;
    /* Painted last, at the far right, after the menu whose tail it holds.
       DOM order keeps the button before the list it controls, which is the
       order a screen reader wants; this is the one place the design lets
       paint order and source order disagree. */
    order: 2;
  }
  .nav-toggle-label { display: none; }
  .nav-toggle-more { display: inline; }
  .nav-more-list {
    position: absolute;
    top: 100%;
    /* The gutter, not zero: `.bar-row` is also `.shell`, and an absolute box
       resolves against the **padding** box — so `right: 0` hung the panel a
       gutter's width past the button that opens it, which is visible and was
       found by measuring rather than by reading. */
    right: var(--o-gutter);
    z-index: 40;
    flex-direction: column;
    justify-content: flex-start;
    align-items: stretch;
    gap: 0.55rem;
    min-width: 12rem;
    max-width: min(20rem, calc(100vw - 2 * var(--o-gutter)));
    padding: 0.85rem 1.1rem;
    background: var(--ob-surface);
    border: var(--o-rule);
    border-radius: var(--ob-radius);
    box-shadow: 0 14px 34px light-dark(rgba(28, 22, 26, 0.14), rgba(0, 0, 0, 0.45));
    /* A group panel's reasoning, verbatim: not `display: none`, because a
       hidden panel cannot take focus and the keyboard could never reach a
       page that overflowed. */
    visibility: hidden;
    opacity: 0;
    transition: opacity 120ms ease;
  }
  .nav.is-open .nav-more-list { visibility: visible; opacity: 1; }
  /* A group in here is stacked open — the mobile treatment. A flyout off the
     side of a dropdown is a second hover target hanging over the page, and
     the panel is already a column. */
  .nav-more-list .ob-nav-group > .ob-nav-heading::after { display: none; }
  .nav-more-list .ob-nav-group > .ob-nav-list {
    position: static;
    display: flex;
    flex-direction: column;
    visibility: visible;
    opacity: 1;
    min-width: 0;
    margin: 0;
    padding: 0;
    background: none;
    border: 0;
    box-shadow: none;
  }
}
@media (prefers-reduced-motion: reduce) {
  .nav-more-list { transition: none; }
}

/* Touch above the collapse breakpoint (2026-08-25): a tablet in landscape
   gets the desktop bar with fingers, and every target in it measured
   30px. Three treatments, one number. Row links and group headings take
   their 48px as padding pulled back by negative margins — the painted box
   grows, the layout box does not, so the bar stands exactly as tall as it
   did (and a heading's deeper reach happens to extend its group's hover
   box toward the panel, which is the diagonal-exit grace again for free).
   Panel and More entries take real padding instead — a popup's height is
   invisible while shut and honest while open — and go full-width blocks,
   because "Videos" at 39px wide was the narrowest target in the whole
   bar. Mouse desktops keep the drawn rhythm untouched. */
@media (min-width: 64.01rem) and (pointer: coarse) {
  .bar-row .nav > .ob-nav-list:not(.nav-more-list) > .ob-nav-item > .ob-nav-link,
  .ob-nav-list:not(.nav-more-list) > .ob-nav-group > .ob-nav-heading {
    padding-block: 0.85rem;
    margin-block: -0.6rem;
  }
  .ob-nav-group > .ob-nav-list .ob-nav-link,
  .nav-more-list > .ob-nav-item > .ob-nav-link {
    display: block;
    padding-block: 0.85rem;
  }
  .ob-nav-group > .ob-nav-list > .ob-nav-item + .ob-nav-item { margin-top: 0; }
  /* `max-content` shrink-wraps a panel to its longest word, and the Media
     panel measured 42px across — the More panel's own floor is the right
     one here too. */
  .ob-nav-group > .ob-nav-list { min-width: 12rem; }
  .nav-more-list { gap: 0; }
}

/* The two things inside the bar that paint a ground of their own: the
   stacked Menu below the breakpoint and a group's panel above it. Both sit
   on --ob-ground/--ob-surface, so they take the page's ink back — over a
   hero photograph the bar's ink is ivory, and ivory on bone is the same
   invisible wordmark that rule exists to prevent. */
.bar .ob-nav-group > .ob-nav-list,
.bar .nav-more-list {
  --o-bar-ink: var(--ob-text);
  --o-bar-ink-muted: var(--ob-text-muted);
  --o-bar-line: var(--ob-line);
  --o-bar-shadow: none;
}
/* The stacked menu takes it too, but **only** below the breakpoint, and that
   guard is the whole reason this is a second rule rather than a third
   selector above. Above the breakpoint `is-open` no longer paints a ground —
   it reveals the More panel and leaves the row where it was, over the hero
   photograph — so an unguarded reset would turn the row plum-black on a
   picture the moment the button was pressed. */
@media (max-width: 64rem) {
  .bar .nav.is-open {
    --o-bar-ink: var(--ob-text);
    --o-bar-ink-muted: var(--ob-text-muted);
    --o-bar-line: var(--ob-line);
    --o-bar-shadow: none;
  }
}

/* The nav is a flex item beside the brand; without a shrink allowance it
   takes its intrinsic single-line width and the list inside never wraps —
   it just runs off the page at laptop widths. */
.bar-row .nav { flex: 1 1 12rem; min-width: 0; }
.bar-row .ob-nav-list { justify-content: flex-end; }
@media (max-width: 64rem) {
  .bar-row .ob-nav-list { justify-content: flex-start; }
}

/* Home, when there is a photograph: the bar floats over the hero, so its
   ground is the photograph. Both inks go to full ivory — muted grey vanished
   over a lit patch — with a breath of shadow under them. */
body:has(.hero-frame .ob-hero-image) .bar {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: 30;
  background: transparent;
  border-bottom-color: transparent;
  --o-bar-ink: var(--o-on-photo);
  --o-bar-ink-muted: var(--o-on-photo);
  --o-bar-line: color-mix(in srgb, var(--o-on-photo) 55%, transparent);
  --o-bar-shadow: var(--o-photo-shadow);
}
/* The mark takes the same rule as the ink, one step further round: over the
   photograph it is the on-dark artwork whatever the mode, because a mark is
   a picture and cannot inherit a colour. `display` is not a colour either,
   so this is the logo's two-block pattern gaining a third block rather than
   a token. The one-pager is excluded because its bar keeps a ground of its
   own — see the sticky rule below. */
body:has(.hero-frame .ob-hero-image):not(:has(.o-section)) .bar .ob-logo--on-light,
body:has(.hero-frame .ob-hero-image):not(:has(.o-section)) .bar .ob-wordmark--on-light {
  display: none;
}
body:has(.hero-frame .ob-hero-image):not(:has(.o-section)) .bar .ob-logo--on-dark {
  display: block;
}
body:has(.hero-frame .ob-hero-image):not(:has(.o-section)) .bar .ob-wordmark--on-dark {
  display: inline;
}
@media (max-width: 64rem) {
  /* The stacked Menu paints on the ground, so it needs the gutter the
     transparent bar does not. (This read 56rem until the panels moved the
     collapse breakpoint, which left the open menu gutterless on a tablet.) */
  body:has(.hero-frame .ob-hero-image) .nav.is-open { padding-inline: 0.75rem; }
}

/* One-page assembly (`.o-section` exists on no other document): the bar
   follows the scroll — a menu of anchors is the one navigation the reader
   needs in reach — translucent over whatever passes under it. The second
   selector re-states the hero condition because the floating rule above
   carries its specificity inside `:has()`, and this must win with a
   photograph on the page too. Bands clear the stuck bar through the engine's
   `--ob-scroll-offset` hook below. */
body:has(.o-section) .bar,
body:has(.o-section):has(.hero-frame .ob-hero-image) .bar {
  position: sticky;
  top: 0;
  left: auto;
  right: auto;
  z-index: 40;
  background: color-mix(in srgb, var(--ob-ground) 88%, transparent);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  border-bottom: var(--o-rule);
  /* 88% of the ground is a ground, so the ink is the page's again even with
     a photograph passing under it. */
  --o-bar-ink: var(--ob-text);
  --o-bar-ink-muted: var(--ob-text-muted);
  --o-bar-line: var(--ob-line);
  --o-bar-shadow: none;
}
/* How tall the bar stands, declared once because two things need it and they
   are the same measurement: a one-pager's bands clear the stuck bar through
   the engine's `--ob-scroll-offset` hook, and a full-screen page cover ends
   where the screen does rather than one bar-height past it. One bar row since
   groups became panels (2026-08-20) — the wrapped three-row bar this used to
   clear was the same defect the panels fixed.

   It is a nominal height, not a measured one: a site with enough top-level
   pages to wrap the bar exceeds it, and CSS cannot ask. Deliberately generous
   for that reason — erring tall makes a full cover finish a little short of
   the fold, where nothing is hidden, instead of tucking a sliver underneath
   it. */
:root {
  --o-bar-height: 5.25rem;
}
/* Below the menu breakpoint the nav collapses behind the toggle, so the bar
   is the brand line and the toggle line. */
@media (max-width: 64rem) {
  :root {
    --o-bar-height: 7.75rem;
  }
}
body:has(.o-section) {
  --ob-scroll-offset: var(--o-bar-height);
}

/* -------------------------------------------------------------------------
   Hero — the viewport is the poster
   ------------------------------------------------------------------------- */

.ob-hero {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  align-items: flex-start;
  gap: 0.5rem;
  min-height: 38vh;
  padding: 6.5rem var(--o-inset) 3.25rem;
}
.ob-hero:has(.ob-hero-image) { min-height: 92svh; }
.ob-hero-image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  z-index: 0;
}
/* The scrim: dark at the foot for the name, a breath at the top for the bar. */
.ob-hero:has(.ob-hero-image)::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  background:
    linear-gradient(to top, rgba(19, 16, 22, 0.88) 0%, rgba(19, 16, 22, 0.35) 34%, rgba(19, 16, 22, 0) 60%),
    linear-gradient(to bottom, rgba(19, 16, 22, 0.72) 0%, rgba(19, 16, 22, 0) 24%);
}
.ob-hero-name {
  position: relative;
  z-index: 2;
  font-family: var(--ob-font-heading);
  font-weight: var(--ob-font-heading-weight, 500);
  font-size: clamp(2.6rem, 8vw, 6.25rem);
  line-height: 0.98;
  letter-spacing: 0.01em;
  text-wrap: balance;
  color: var(--ob-text);
  margin: 0;
}
.ob-hero-tagline {
  position: relative;
  z-index: 2;
  margin: 0.25rem 0 0;
  font-size: 0.82rem;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--ob-text-muted);
}
/* The tagline is text over a photograph at 0.82rem (13.1px), so it owes SC
   1.4.3's 4.5:1 body-text minimum rather than the 3:1 large-text allowance
   `--ob-on-accent-muted`'s pair is declared at — that pair measures 3.64:1
   here, which is the large-text rung applied to a size that doesn't qualify
   for it. `--o-on-photo-muted` is the token its two siblings below already
   take for the same ground. */
.ob-hero:has(.ob-hero-image) .ob-hero-tagline { color: var(--o-on-photo-muted); }
/* The name and the photo credit sit *on the photograph*, under the scrim
   above — so they are ivory in both modes, not the page's ink. The tagline
   above takes the same photo tokens now, for the same reason. */
.ob-hero:has(.ob-hero-image) .ob-hero-name { color: var(--o-on-photo); }
.ob-hero:has(.ob-hero-image) .ob-photo-credit { color: var(--o-on-photo-muted); }
/* `ob-photo-credit` is the one class a photograph's attribution takes
   wherever it stands — the hero and a page cover both. Scoped rather than a
   bare rule, because the two are different grounds: the hero's frame is
   `position: relative` for its own reasons and the credit rides its bottom
   corner over the photograph; a page cover is scoped the same way one
   selector down. */
.ob-hero .ob-photo-credit {
  position: absolute;
  right: var(--o-gutter);
  bottom: 0.9rem;
  z-index: 2;
  margin: 0;
  font-size: 0.68rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ob-text-muted);
}

/* The banded hero (`hero-banded`): the same poster, below the masthead. The
   bar stays in flow on the page's own ground — the float above keys off
   `.hero-frame`, deliberately not this class — so the mark keeps the page's
   artwork (an onLight-only logo can head home) and the bar's ink never
   leaves the page. Everything *inside* stays the full hero's: the band is
   still a photograph, so the name and credit take the on-photo ivory and the
   foot scrim carries them. Only the top breath goes — that gradient exists
   for the floating bar, and under a bar in flow it is a smudge below the
   masthead's rule. Height is a band, not a viewport: generous because this
   design's voice is the poster, and short enough that the page visibly
   continues below the fold. */
.hero-band .ob-hero:has(.ob-hero-image) { min-height: 62svh; }
.hero-band .ob-hero:has(.ob-hero-image)::before {
  background:
    linear-gradient(to top, rgba(19, 16, 22, 0.88) 0%, rgba(19, 16, 22, 0.35) 34%, rgba(19, 16, 22, 0) 60%);
}

/* The textless hero (`hero-plain`): the same floating poster, minus the
   words. `.hero-frame--plain` still matches every `:has(.hero-frame …)`
   selector above (bar floats over it, the on-dark logo shows), because it
   carries `hero-frame` too — the name is visually hidden and stays in the
   document, so the page keeps its one `<h1>`. The credit line is left alone: it
   is a caption rather than a name over the picture, and the source pages
   this is drawn from still carry one. */
.hero-frame--plain .ob-hero-name {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* -------------------------------------------------------------------------
   Page covers, heads, bands
   ------------------------------------------------------------------------- */

/* The per-page art direction: a full-bleed cover under the bar, scrimmed
   into the ground so the page title reads as printed over its foot. */
/* The page cover is declared in each page file rather than once in
   layout.html, because home must not have one — its hero is already a
   photograph the size of the viewport. */
.page-cover { position: relative; }
.page-cover .ob-page-backdrop {
  display: block;
  width: 100%;
  object-fit: cover;
}
/* A page cover's credit, over its bottom corner exactly as the hero's is
   (`ob-photo-credit`, one class for the two). `.page-cover` is already
   `position: relative` for the backdrop's own crop, so the credit needs
   nothing further to sit on the photograph rather than under it. */
.page-cover .ob-photo-credit {
  position: absolute;
  right: var(--o-gutter);
  bottom: 0.6rem;
  z-index: 2;
  margin: 0;
  font-size: 0.68rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--o-on-photo-muted);
}
/* Four heights the author picks from (`cover.height` on the manifest), drawn
   here because only the design knows what a strip is on this page. `standard`
   is what a cover has always been on ottavo, so a site written before heights
   existed does not move. At `tall` and `full` the crop bites hard, which is
   what the focal point in /photos is for.

   One vocabulary, two scales, and the split is the point. `ob-cover--tall` is
   the author saying "tall" about three different boxes: a page cover, a band
   breaking up the page, and a photograph inside a section. The first two span
   the viewport and are measured against it; the third sits in a column of
   prose — inside the two-column about grid, no less — where a viewport height
   is a tall slot the width of a paragraph. So the *names* are shared and the
   *lengths* are per context, which is what having the design draw the ladder
   is for. */
.ob-page-backdrop.ob-cover--strip { height: 22vh; }
.ob-page-backdrop.ob-cover--standard { height: 44vh; }
.ob-page-backdrop.ob-cover--tall { height: 66vh; }
/* `svh`, not `vh`: on a phone `100vh` is the address bar's worth taller than
   the screen, so "full" would crop a face at the fold. The bar is in flow on
   every page that can carry a cover (the floating bar is the hero's, and home
   has no cover), so subtracting it makes the picture end where the screen
   does rather than one bar-height past it. */
.ob-page-backdrop.ob-cover--full {
  height: calc(100svh - var(--o-bar-height));
}
/* The section ladder: a photograph the author put between their own
   paragraphs. Measured in rem against the column rather than in vh against
   the screen — a "full" picture here is one that fills the reader's view of
   the column, not one that displaces the page. */
.ob-section-image.ob-cover--strip { height: 10rem; }
.ob-section-image.ob-cover--standard { height: 18rem; }
.ob-section-image.ob-cover--tall { height: 28rem; }
.ob-section-image.ob-cover--full { height: 40rem; }
/* No feather where the cover meets the page (2026-08-25, Miles, with a
   screenshot of a quartet's legs dissolving into mist): a photograph ends at
   its own edge. The fade-to-ground ::after that used to sit here — and the
   `--o-cover-fade` token that existed only for it — are deleted, not
   softened. The distinction that survives them is the one their comment
   drew: a *darkening* scrim where type is painted over a photograph (the
   hero's name and credit, the lightbox) stays, dark in both modes; a blend
   into the page ground was decoration, and it cost every cover its bottom
   third. */
/* When a cover ran, whatever the author put under it sits tight beneath —
   the page's name, usually, and its own writing on a custom page.

   Adjacency, and deliberately: under composed pages a photograph can land
   anywhere, so "tight under a cover" has to be a fact about what is *next to*
   it rather than about which page this is. */
.page-cover:has(.ob-page-backdrop) + .page-head { padding-top: 1.5rem; }
.page-cover:has(.ob-page-backdrop) + .band { padding-block-start: 1.5rem; }

/* The section tabs: another head-of-page element, so it takes the same top
   measure as `.page-head` does — a member page composes it first, with
   nothing above it. When it does sit above the page's own name, the two open
   tighter together than either does against ordinary content underneath. */
.sub-nav { padding-block: 4rem 0; }
.sub-nav + .page-head { padding-top: 1.5rem; }

.page-head { padding-block: 4rem 0; }
.page-title, .ob-page-title {
  font-family: var(--ob-font-heading);
  font-weight: var(--ob-font-heading-weight, 500);
  font-size: clamp(2.2rem, 5.5vw, 3.6rem);
  line-height: 1.04;
  letter-spacing: 0.01em;
  text-wrap: balance;
  color: var(--ob-text);
  margin: 0 0 1.5rem;
}
.page-title::after, .ob-page-title::after {
  content: "";
  display: block;
  width: 3.5rem;
  height: 3px;
  margin-top: 1.4rem;
  background: var(--ob-accent);
}

.band { padding-block: 4.5rem; }
.band--intro { padding-block: 4.5rem 3.5rem; }

/* The band under the page's name opens tighter — it is the top of the page's
   content, not another stop down it. */
.page-head + .band { padding-block: 2.25rem 5rem; }

/* Alternating grounds.
   ---------------------------------------------------------------------
   Painted by *position*, because with composed pages position is the author's
   and a band cannot know in its own markup whether it has landed on surface
   or on ground. `nth-of-type` counts sections, so a photograph between two
   bands does not flip the rhythm: a cover is a `<div>` and every region band
   is a `<section>`.

   This replaced a `--surface` class each page file set by hand, which read
   correctly on ten fixed pages and could not survive anybody moving one. */
.page-regions > section.band:nth-of-type(even) {
  background: var(--ob-surface);
  border-block: var(--o-rule);
}
/* Adjacent surface bands would double their rules. A photograph between them
   is not adjacency, so the general sibling combinator would be wrong here. */
.page-regions > section.band:nth-of-type(even) + section.band:nth-of-type(even) {
  border-top: 0;
}

/* A band the author placed whose region rendered nothing at all — an empty
   press kit, a page's own writing on a page that has none — would be 9rem of
   surface colour where the page should have ended. Gated on `[data-ob-item]`,
   the engine's own region hook that every module emits, so the rule does not
   have to be revisited each time a placement is added to the palette.

   The page's name is not a region and carries no hook, so it is spared by
   name; a module with an empty inventory *does* emit its hook and keeps its
   band, which is right: the author chose it, and the module's own empty
   line is the answer. Whether there is a line at all is the module's own
   business — a diary with nothing ahead of today but a full archive under
   it says nothing, having plenty to show (schedule/render.ts). */
.page-regions > .band:not(:has([data-ob-item])):not(:has(.ob-page-title)) {
  display: none;
}

/* The band label: tracked caps behind a short carmine dash. */
.eyebrow {
  display: flex;
  align-items: center;
  gap: 0.8rem;
  margin: 0 0 2.25rem;
  font-size: 0.72rem;
  /* The body face's weight, not the heading face's — the eyebrow sets no
     family, so it inherits --ob-font-body from `body`. It looked like a
     heading in the source and is a body-face label, which is exactly the
     confusion a literal 500 in both places hid. */
  font-weight: var(--ob-font-body-weight, 500);
  letter-spacing: 0.24em;
  text-transform: uppercase;
  color: var(--ob-text-muted);
}
.eyebrow::before {
  content: "";
  width: 2rem;
  height: 2px;
  background: var(--ob-accent);
}

/* Module section headings: the didone, one notch under the page title. */
.ob-heading {
  font-family: var(--ob-font-heading);
  letter-spacing: 0.01em;
  color: var(--ob-text);
}

/* The heading face, everywhere it heads something.
   -----------------------------------------------
   The eleven modules each reach for --ob-font-heading and then answer the
   weight question for themselves, three different ways: 500 in six of them,
   nothing at all in the schedule's event title (a <p>, so it inherits 400 and
   comes out *lighter* than the programme line under it), and the UA's bold in
   the four that render an <hN> — which on a face with one weight is not bold
   at all but a synthesised smear of its 400. All three are literals, and a
   literal is the thing this design is giving up: 500 is one step over body in
   Bodoni, the tamest register Syne has, already a slab in Oswald, and nothing
   whatever in DM Serif Display, Instrument Serif, Marcellus, Prata and
   Italiana, which ship one weight each. So ottavo answers it once, and asks
   the face rather than telling it.

   The figures go lining in the same breath, and it is a statement about this
   design rather than about any face: a concert poster sets its numbers as
   capitals. Cormorant Garamond defaults to old-style, which is right in a
   book and wrong here — curated *Programme* rendered "Vol. 1" as "Vol. ɪ",
   "Disc 1" as "Disc ɪ", and dropped the 2026 dividing the diary to x-height
   beside the lining figures in the date column an inch to its left. On a
   musician's site the numerals are opus numbers, catalogue numbers, volume
   numbers and years, so this is most of what the reader is scanning for. */
.ob-heading,
.ob-year-heading,
.ob-year-summary,
.ob-bio-heading,
.ob-members-heading,
.ob-member-name,
.ob-role-org,
.ob-works-heading,
.ob-work-summary,
.ob-work-title,
.ob-post-title,
.ob-article-heading,
.ob-recordings-heading,
.ob-recording-title,
.ob-track-heading,
.ob-video-title,
.ob-event-heading,
.ob-contact-heading,
.ob-contact-form-heading,
.ob-presskit-heading {
  font-weight: var(--ob-font-heading-weight, 500);
  font-variant-numeric: lining-nums;
}

/* The rank that cannot come from ink.
   ----------------------------------
   Every failure the type audit found was at item scale — a role heading, an
   event title, a recording title — and none at the hero or the page title,
   which came through all seventeen faces intact. That is the design's own
   doing: the scale is enormous at the top (a 6.25rem hero over a 3.6rem page
   title) and collapses at the bottom, where an item title arrived from its
   module at 1.05–1.15rem — five to fifteen per cent over the sentence beneath
   it — and left the weight step to carry the rest of the distance. On a face
   with no step there is nothing to carry it, and a narrow display serif at
   1.05rem reads smaller *and* lighter than the body text it is heading.

   Size is the one signal that means the same thing in every face: 1.25rem
   measures larger than 1rem whatever is drawing it, where a weight, a stroke
   contrast and an x-height all vary with the family. The other four
   candidates were looked at and set aside — case, because tracked capitals
   are already this design's word for "label" (the eyebrow, the nav, the
   roster, the wordmark) and a concert title is not a label; colour, because
   the one accent is spoken for and a diary of carmine event names would
   shout; tracking and space, because neither of them restores rank against
   the line directly underneath, which is the actual failure.

   Two tiers, because the modules ship eleven opinions about a scale they
   cannot see the rest of. `--lead` and the like keep their own steps: a
   module rule with two classes outranks these. */
.ob-heading,
.ob-year-heading,
.ob-year-summary,
.ob-bio-heading,
.ob-members-heading,
.ob-works-heading,
.ob-recordings-heading {
  font-size: var(--o-heading-section);
  line-height: 1.25;
}
.ob-member-name,
.ob-role-org,
.ob-work-summary,
.ob-work-title,
.ob-post-title,
.ob-article-heading,
.ob-recording-title,
.ob-video-title,
.ob-event-heading,
.ob-contact-heading,
.ob-contact-form-heading {
  font-size: var(--o-heading-item);
  line-height: 1.3;
}
/* A third rung, for the one heading that sits inside a block which shrank.
   The track listing is 0.9rem, so a work title heading a disc inherited that
   and came out the same size as "1. Allegro" beneath it — carried only by
   full-ink colour against the lines' muted grey, which a hairline display
   serif loses (Salon, Programme and Manuscript all showed it; Poster did
   not, which is why it survived the first pass). 1.05rem is a step over its
   own tracks and stays well under the recording title above it, which is
   what a sub-heading has to do in both directions at once. */
.ob-track-heading { font-size: 1.05rem; }

.ob-empty { color: var(--ob-text-muted); font-style: italic; }

.ob-prose { max-width: var(--o-narrow); margin: 0 0 1.1rem; }
.ob-inline-link {
  color: var(--ob-link);
  text-decoration: underline;
  text-underline-offset: 0.18em;
  text-decoration-thickness: 1px;
}

/* -------------------------------------------------------------------------
   Schedule
   ------------------------------------------------------------------------- */

/* The date column, sized for whichever style the author picked.

   `--ob-event-date-width` above is `day-month` ("27–31 Jul"), which is what
   all six diary bands set and what most acts will keep. The moment
   `date-style` became the author's to change (vocabulary.ts,
   `adjustableDateStyle`) one width stopped being an answer: `.ob-event-date`
   is `nowrap`, so a longer spelling under a column drawn for a shorter one
   does not wrap, it runs straight over the presenter's name beside it. The
   module stamps `data-ob-date-style` on the schedule root for exactly this.

   The four are sized for their **single** date and allowed to wrap on a
   range, and that split is the whole rule. A same-month range is close
   enough to a single date to share its column ("9–13 Nov", "9–13 / 11"), but
   a range that crosses a month spells the date twice — "9 November 2026 – 13
   November 2026" is thirty-four characters, and "Sun Sep 27 2026 – Sat Oct 3
   2026" thirty-two. No column a diary can afford holds those, so nowrap
   there is not a width problem to be solved but a promise that cannot be
   kept. Wrapping to a second line reads; overprinting takes the presenter's
   name with it. `day-month` keeps `nowrap` because it is the one style whose
   longest form ("27 Sep – 3 Oct") was measured against this column and fits.

   Scoped to the list variant, since that is the only one with a column:
   under `compact` the date is inline before the text, where a width means
   nothing and a wrap would break a date across lines mid-month. */
.ob-schedule--list[data-ob-date-style="day-slash-month"],
.ob-schedule--list[data-ob-date-style="padded-day-slash-month"] {
  --ob-event-date-width: 6.5rem;
}
.ob-schedule--list[data-ob-date-style="long"],
.ob-schedule--list[data-ob-date-style="abbrev-inline"] {
  --ob-event-date-width: 11.5rem;
}
.ob-schedule--list[data-ob-date-style="long"] .ob-event-date,
.ob-schedule--list[data-ob-date-style="abbrev-inline"] .ob-event-date,
.ob-schedule--list[data-ob-date-style="day-slash-month"] .ob-event-date,
.ob-schedule--list[data-ob-date-style="padded-day-slash-month"] .ob-event-date {
  white-space: normal;
}

/* `diary-cards` has no column either, and it is the variant where `nowrap`
   does the most damage. The module sets `white-space: nowrap` on every
   `.ob-event-date` (schedule/css.ts) so a date holds its line against the
   column beside it; inside a bordered card there is no column, and the cell
   is a grid `1fr` about 15rem wide at the three-across breakpoint. A date
   that cannot wrap simply runs out through the border it is drawn inside,
   which is worse than the list's overprint because the box makes the
   overflow look like a rendering fault rather than a tight column.

   Every style is released rather than the four the list names. The list
   keeps `day-month` on `nowrap` to protect an alignment; a card has no
   neighbour to align with, so wrapping costs it nothing and the narrower
   spellings will not wrap at that width anyway. */
.ob-schedule--cards .ob-event-date { white-space: normal; }

/* `diary-columns`: the list row's own elements, in three fields instead of
   two. The module already keeps a concert's *when* — its heading, its time
   and venue — apart from its *what* — the programme, the note, the links —
   as separate elements inside `.ob-event-body`; a second grid on that body
   puts them in their own columns rather than adding a wrapper the module
   never wrote. `dense` packing is what keeps the two columns reading as two
   independent runs rather than interleaving row by row: without it the
   first programme line lands a row below the heading whenever a meta line
   (time · venue) is present, because the auto-placement cursor is shared
   across both columns. Below the breakpoint this rule does not apply and
   the body falls back to its ordinary single-column stack — the same
   breakpoint the module's own two-column split already collapses at.

   The columns measured against Jenny Lewisohn's own concerts page (the
   benchmark Miles asked for): a narrow date at the left, then two content
   columns close to equal halves of the width that is left, with a generous
   gutter and hairline rules between rows. `13rem 1fr` fell well short of
   it: after the date column (`--ob-event-date-width`, 7.5rem by default,
   plus its own 2rem gap) the "what" column started around 24rem in, a
   quarter of the way across, while the "when" column stayed pinned to 13rem
   regardless, which wraps a presenter's name hard. `minmax(0, 1fr) minmax(0, 1fr)` reads as two
   equal shares of whatever width the date column left, with `column-gap`
   widened to 3rem for the generous gutter the benchmark shows; the `0` floor
   on each `minmax()` matters because this is a grid inside a grid: an
   ordinary `1fr` track takes its content's own min-content width as its
   *minimum*, so a long unbroken programme line would push the "what" column
   wider than its share and blow the row past the shell. */
@media (min-width: 40rem) {
  .band--diary-columns .ob-event-body {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    column-gap: 3rem;
    grid-auto-flow: dense;
  }
  .band--diary-columns .ob-event-heading,
  .band--diary-columns .ob-event-meta {
    grid-column: 1;
  }
  .band--diary-columns .ob-event-programme,
  .band--diary-columns .ob-event-note,
  .band--diary-columns .ob-event-links,
  .band--diary-columns .ob-event-quote {
    grid-column: 2;
  }
  /* "also not smaller font size for venue and event detail" (Miles). The
     venue/time line (`.ob-event-meta`) and the programme (`.ob-event-
     programme`) already render at the module's plain body size — neither
     sets its own font-size — so only the note needs answering: the module's
     default shrinks it to 0.95em for a line that, in the module's other
     placements, sits underneath a heading it is annotating. In this band it
     is a full column of running text opposite the programme, at the same
     rank as the text beside it, so it is set back to the body's own size —
     scoped to this placement, so the module's default (every other diary
     band) is untouched. */
  .band--diary-columns .ob-event-note {
    font-size: 1em;
  }
}

/* -------------------------------------------------------------------------
   Event page
   ------------------------------------------------------------------------- */

/* The venue block on a concert's own page (the eventPages page flag) — a
   name, an address written the way a person would post a letter, and a
   Directions link the reader chooses to follow. The type is already set by
   the schedule module's own classes; this is only spacing. */
.ob-event-venue { font-size: var(--o-heading-item); margin: 2rem 0 0.35rem; }
.ob-event-address { margin: 0 0 0.75rem; line-height: 1.5; }
.ob-event-directions { margin: 0 0 1.75rem; }

/* -------------------------------------------------------------------------
   About
   ------------------------------------------------------------------------- */

.about-grid { display: grid; gap: 2.5rem; }
.about-portrait { margin: 0; }
.about-portrait:empty { display: none; }
.ob-bio-portrait { display: block; width: 100%; height: auto; border-radius: var(--ob-radius); }
@media (min-width: 56rem) {
  .about-grid:has(.about-portrait:not(:empty)) {
    grid-template-columns: minmax(16rem, 24rem) minmax(0, 1fr);
    gap: 3.5rem;
  }
  .about-portrait { position: sticky; top: 2rem; align-self: start; }
}

/* A roster on a site with no roster is a band of nothing: a soloist's site
   carries this placement by default and has no members to put in it. The
   module emits its region hook either way, so this is gated on the list
   itself rather than on the hook the general rule above uses. */
.band--roster:not(:has(.ob-members-list)) { display: none; }
/* The portrait wall lives with no members exactly as the stacked roster
   does, for the same reason. */
.band--roster-portraits:not(:has(.ob-members-list)) { display: none; }
/* A wall wants a taller frame than the module's own default aspect gives a
   member's portrait — this band is nothing but pictures, so the picture
   gets to be the thing. */
.band--roster-portraits .ob-member-portrait { aspect-ratio: 4 / 5; }

/* The roster marks the member whose own page it is rendered on
   (`pages.memberPages`, `ctx.current` in the members module) — a ring on the
   portrait and a colour on the name. Both are marks rather than the accent's
   other job as a fill: the ring is a state indicator (SC 1.4.11, 3:1) and
   the name is text set at 1.25rem/1rem regular (SC 1.4.3, 4.5:1) — measured
   2.90:1 and 3.13:1 on `--ob-accent`, which is the fill's own ink-carrying
   colour rather than either rung the gauge holds a design to. `--ob-link`
   and `--ob-accent-bright` are ottavo's two mark tokens (worst measured
   4.82:1 and 3.37:1 across the arc) and each already fits its role: the
   name sits in a real self-referential `<a>` (`memberHref` is defined for
   every roster entry once `pages.memberPages` is on, current member
   included — traced through `packages/site-modules/src/members/render.ts`
   and `packages/slot-template/src/render.ts`'s `memberByEntry`), so
   `--ob-link` names it correctly rather than merely passing contrast; the
   ring is a state indicator, so `--ob-accent-bright`, ottavo's other mark
   rung. The ring's `outline-offset: 4px` puts it outside the portrait
   frame, so it sits against the band behind the roster rather than
   against the photograph. */
.ob-member--current .ob-member-portrait { outline: 2px solid var(--ob-accent-bright); outline-offset: 4px; }
.ob-member--current .ob-member-name { color: var(--ob-link); }

/* `prose-columns`: the biography's own headed prose, set `columns: 2` —
   the module's `plain` treatment already renders each section as one
   `.ob-bio-section` box of paragraphs and nothing else, so this is
   typesetting rather than new markup. The heading stays full width, above
   the columns it introduces, which is what `renderSections` already draws
   it as (a sibling before the section box rather than inside it). */
@media (min-width: 40rem) {
  .band--prose-columns .ob-bio-section--plain {
    columns: 2;
    column-gap: 2.5rem;
    max-width: 58rem;
  }
  .band--prose-columns .ob-bio-section--plain p { break-inside: avoid; }
}

/* -------------------------------------------------------------------------
   Press — the pull quotes are the showpiece
   ------------------------------------------------------------------------- */

.ob-press blockquote p,
.ob-press-quote p {
  font-family: var(--ob-font-heading);
  font-style: italic;
  font-weight: 400;
  font-size: clamp(1.35rem, 2.6vw, 1.8rem);
  line-height: 1.35;
  letter-spacing: 0.005em;
  color: var(--ob-text);
}

/* `reviews-grid`: the same full archive as a two-up grid of bordered cards
   rather than a stacked, hairline-separated column. `articles` already
   writes each review as its own `<article class="ob-article">` (and a
   paragraph-less one as `.ob-quote` in the same flow), so this only widens
   the shell and cards what were already separate siblings. */
.band--reviews-grid .ob-press--articles {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1.75rem;
  align-items: start;
}
@media (min-width: 42rem) {
  .band--reviews-grid .ob-press--articles { grid-template-columns: repeat(2, 1fr); }
}
.band--reviews-grid .ob-article {
  margin: 0;
  padding: 1.75rem;
  border: var(--o-rule);
  border-radius: var(--ob-radius);
}
.band--reviews-grid .ob-press--articles .ob-quote {
  margin: 0;
  padding: 1.5rem 1.5rem 1.5rem 1.25rem;
  border: var(--o-rule);
  border-left: 2px solid var(--ob-accent);
  border-radius: var(--ob-radius);
}
.band--reviews-grid .ob-press-all { grid-column: 1 / -1; }

/* -------------------------------------------------------------------------
   Recordings
   ------------------------------------------------------------------------- */

/* Per-sleeve review quotes on the cover grid (`discography-covers`, the
   opt-in `recording-quotes` adjust). The module's own treatment is a plain
   accent rule in the body face; this design's press quotes are the italic
   didone everywhere else, so a quote under a cover reads as this design's
   rather than as an unstyled default. */
.ob-recordings--grid .ob-recording-quote {
  font-family: var(--ob-font-heading);
}
.ob-recordings--grid .ob-recording-quote p {
  font-size: 0.95rem;
  line-height: 1.4;
}

/* -------------------------------------------------------------------------
   Custom pages
   ------------------------------------------------------------------------- */

.ob-page-images {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
  gap: 1.25rem;
  margin: 2.25rem 0;
}
.ob-page-image {
  width: 100%;
  aspect-ratio: 3 / 2;
  object-fit: cover;
  display: block;
  border-radius: var(--ob-radius);
}
/* A structured "name – role" credits list, between the title and the
   opening prose — Jenny Lewisohn's project pages. */
.ob-page-credits {
  list-style: none;
  margin: 0 0 1.75rem;
  padding: 0;
  font-size: 0.95rem;
  color: var(--ob-text-muted);
}
.ob-page-credits li { margin: 0.15rem 0; }

/* A structured list (`proseListSchema`) — a bulleted roll, a numbered
   process, or a "thing — price" line rendered as a definition list. Quiet
   and typographic rather than boxed, the register `.ob-page-credits` already
   sets for a list one step above it on the same page: a term-bearing row
   closes on a hairline instead of a border, and its amount gets the same
   tabular restraint `.ob-event-date` already owns, above, so a column of
   prices lines up rather than drifting a digit at a time. */
.ob-list-row {
  padding: 0.4rem 0;
  border-bottom: var(--o-rule);
  column-gap: 1.5rem;
}
.ob-list-row:last-child { border-bottom: none; }
.ob-page-quote { margin: 2.25rem 0; padding-left: 1.5rem; border-left: 3px solid var(--ob-accent); }
.ob-page-quote p {
  font-family: var(--ob-font-heading);
  font-style: italic;
  font-size: 1.35rem;
  line-height: 1.4;
  margin: 0 0 0.5rem;
}
.ob-page-quote cite {
  font-style: normal;
  font-size: 0.72rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ob-text-muted);
}

/* `prose-quote`: one paragraph left, the attributed pull quote right, in
   one band — a two-column treatment of the `prose` slot's own elements.
   Scoped to `:has(.ob-page-quote)` so a page with no quote falls straight
   through to `shell--narrow`'s ordinary single-column prose; every other
   child (the page's own title, its sections, its image grid, its links and
   downloads) spans both columns, which is what keeps this a rearrangement
   of the quote rather than a second page layout. */
@media (min-width: 48rem) {
  .band--prose-quote [data-ob-slot="prose"]:has(.ob-page-quote) {
    display: grid;
    grid-template-columns: 1fr 1fr;
    column-gap: 3rem;
    align-items: start;
  }
  .band--prose-quote [data-ob-slot="prose"]:has(.ob-page-quote) > * {
    grid-column: 1 / -1;
    max-width: none;
  }
  .band--prose-quote [data-ob-slot="prose"]:has(.ob-page-quote) > .ob-prose {
    grid-column: 1;
    max-width: none;
  }
  .band--prose-quote [data-ob-slot="prose"]:has(.ob-page-quote) > .ob-page-quote {
    grid-column: 2;
    /* The page's own title always renders first and always spans both
       columns (customPageBody's fixed order), which is what makes row 2 a
       safe place to pin the quote: it is always the row the first
       paragraph falls into, so the two align at the top regardless of how
       much else the page carries below them. */
    grid-row: 2;
    align-self: start;
    margin: 0;
    max-width: none;
  }
}
.ob-page-links, .ob-page-downloads {
  list-style: none;
  margin: 1.75rem 0 0;
  padding: 0;
  border-top: var(--o-rule);
}
.ob-page-links li, .ob-page-downloads li { border-bottom: var(--o-rule); }
.ob-page-link, .ob-download {
  display: block;
  padding: 0.8rem 0;
  text-decoration: none;
  color: var(--ob-text);
}
.ob-page-link:hover, .ob-download:hover { color: var(--ob-accent-bright); }
.ob-download::after { content: " ↓"; color: var(--ob-accent-bright); }

/* The section tabs (2026-08-28, the section-tabs brief) reuse this same
   `<nav>` — a nav group's other pages read exactly as a custom page's or a
   member's siblings do. A row carrying `ob-sibling-title` (the section's own
   name) gets a rule under it, since that row is now content in its own
   right; the title stays visible at phone width, the row wrapping under it,
   since it is the route to the section's other pages. */
.ob-sibling-nav {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 1.5rem;
  align-items: baseline;
  padding-bottom: 1.75rem;
  border-bottom: var(--o-rule);
}
/* The section's own name — set like a page's own title (same face, same
   clamp), so a section reads with the same weight a page does, but with no
   accent bar under it: the bar marks a page's own name, and this is one
   level up from that. Forced onto its own row ahead of the tabs, which then
   wrap beneath it as today. */
.ob-sibling-title {
  flex-basis: 100%;
  font-family: var(--ob-font-heading);
  font-weight: var(--ob-font-heading-weight, 500);
  font-size: clamp(2.2rem, 5.5vw, 3.6rem);
  line-height: 1.04;
  letter-spacing: 0.01em;
  text-wrap: balance;
  color: var(--ob-text);
  margin: 0 0 1.5rem;
}
.ob-sibling-link, .ob-sibling-current {
  font-size: 0.72rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
}
.ob-sibling-link { color: var(--ob-text-muted); text-decoration: none; }
.ob-sibling-link:hover { color: var(--ob-text); }
/* The underline is a state indicator rather than text, so SC 1.4.11's 3:1
   governs it, measured against the surface these tabs sit on (the even
   `.o-section` band). It takes ottavo's mark rung: `--ob-accent` measures
   2.90:1 there, `--ob-accent-bright` 3.37:1 worst case across the arc. */
.ob-sibling-current { color: var(--ob-text); border-bottom: 2px solid var(--ob-accent-bright); }

/* -------------------------------------------------------------------------
   One-page assembly
   ------------------------------------------------------------------------- */

.o-section { border-top: var(--o-rule); padding-block: 4.5rem; }
.o-section[data-ob-index]:nth-of-type(even) { background: var(--ob-surface); }

/* -------------------------------------------------------------------------
   Footer
   ------------------------------------------------------------------------- */

.foot {
  border-top: var(--o-rule);
  padding-block: 3.5rem;
  margin-top: 5rem;
}
.foot-grid {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 1.25rem 2rem;
}
.foot-name {
  font-family: var(--ob-font-heading);
  font-weight: var(--ob-font-heading-weight, 500);
  font-size: 1rem;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--ob-text);
  margin: 0;
}
.ob-social {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem 1.4rem;
  list-style: none;
  margin: 0;
  padding: 0;
}
.ob-social-link {
  color: var(--ob-text-muted);
  text-decoration: none;
  font-size: 0.72rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  border-bottom: 1px solid transparent;
}
.ob-social-link:hover { color: var(--ob-text); border-bottom-color: var(--ob-accent); }
.foot-credits {
  flex-basis: 100%;
  margin: 0;
  font-size: 0.8rem;
  color: var(--ob-text-muted);
}
.ob-credit-link { color: var(--ob-text); }
.ob-credit-dot { display: inline-block; text-decoration: none; }
.ob-nowrap { white-space: nowrap; }

/* -------------------------------------------------------------------------
   Lightbox (site.js)
   ------------------------------------------------------------------------- */

/* display is scoped to [open] — a bare display:flex would override the UA's
   dialog:not([open]) rule and paint the closed dialog (quarto's lesson). */
.o-lightbox[open] {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 5vmin;
  background: rgba(10, 8, 12, 0.94);
  border: 0;
}
.o-lightbox img {
  max-width: 100%;
  max-height: 90vh;
  object-fit: contain;
}
.o-lightbox-close {
  position: absolute;
  top: 1rem;
  right: 1.25rem;
  background: none;
  border: 0;
  color: #fff;
  font: inherit;
  font-size: 1.5rem;
  line-height: 1;
  cursor: pointer;
  padding: 0.5rem;
}

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

/* ---- The designed 404 (engine-owned body, this design's clothes) ------- */
.ob-not-found {
  width: 100%;
  max-width: var(--o-measure);
  margin-inline: auto;
  padding-block: 9rem 11rem;
  padding-inline: var(--o-gutter);
  text-align: center;
}
.ob-not-found-heading {
  font-family: var(--ob-font-heading);
  font-weight: var(--ob-font-heading-weight, 500);
  font-size: clamp(2rem, 5vw, 3rem);
  margin: 0 0 1rem;
}
.ob-not-found-text {
  color: var(--ob-text-muted);
  margin: 0 0 2.25rem;
}
/* A 404 link is text, so SC 1.4.3's 4.5:1 governs it — the same
   mark-versus-fill split as the roster above, where `--ob-accent` measures
   3.13:1. `--ob-link` is the mark rung for exactly this. */
.ob-not-found-link {
  color: var(--ob-link);
  text-decoration-color: color-mix(in srgb, var(--ob-link) 45%, transparent);
  text-underline-offset: 0.2em;
}

/* -------------------------------------------------------------------------
   Composite placements — a band holding two regions
   (docs/arrangement-audit.md, "Reading" item 4)
   ------------------------------------------------------------------------- */

/* `biography-roster`: the biography and the roster in one band. The main
   column is `about-grid` again — the group's portrait and prose are one
   arrangement whether or not a roster stands beside them — so nothing here
   restyles it. The sidebar carries `band--roster-portraits` as well as its
   own class, which is the whole of the reuse: that band's own collapse rule
   (no members, no band) already says what a soloist's copy of this
   placement needs, so the sidebar answers to it rather than to a second
   copy of the same rule. */
.biography-roster-grid { display: grid; gap: 3rem; }
@media (min-width: 62rem) {
  .biography-roster-grid:has(.biography-roster-members .ob-members-list) {
    grid-template-columns: minmax(0, 1fr) minmax(15rem, 19rem);
    gap: 3.5rem;
    align-items: start;
  }
}

/* `photo-prose`: a photograph beside a page's own writing rather than above
   it. `page-image` renders the same `.ob-page-backdrop` element the
   full-width `photo` placement does, whose `.ob-cover--*` ladder is answered
   in vh against the screen — right for a cover spanning the page, wrong for
   a picture keeping company with a paragraph, so this band answers it in a
   column-sized aspect ratio instead. */
.photo-prose-grid { display: grid; gap: 2rem; }
.photo-prose-photo:empty,
.photo-prose-prose:empty { display: none; }
.photo-prose-photo .ob-page-backdrop {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 5;
  object-fit: cover;
  border-radius: var(--ob-radius);
}
@media (min-width: 48rem) {
  .photo-prose-grid:has(.photo-prose-photo .ob-page-backdrop) {
    grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
    gap: 3rem;
    align-items: start;
  }
}

/* `project`: composed from `photo-prose` and `prose-quote` rather than
   drawn fresh — Jenny Lewisohn's own project pages are exactly the two
   already-proven arrangements, one beside the other: a photograph beside
   this page's writing, and that writing beside its own pull quote.

   The photo side reuses `.photo-prose-photo` byte for byte (same markup
   class, same rules above) — same aspect ratio, same collapse with no
   picture — so how a photograph sits beside text is not drawn a second
   time. `.project-grid` only adds the same outer split at the same
   breakpoint `photo-prose-grid` uses.

   The text side opens `prose-quote`'s own split once there is a quote to
   justify it, at 64rem rather than that placement's 48rem: here the text
   column is already sharing the band with a photograph, so there is
   markedly less width left to give two sub-columns before they read as
   cramped, and holding one column a while longer beats opening a split
   too narrow to read.

   The quote sits beside the first paragraph, the same row `prose-quote`
   pins it to — with one correction `prose-quote` never had to make. A page
   reaching for *this* placement almost always carries `credits` too (that
   is most of what it is for), and credits renders as its own full-width
   row between the title and the first paragraph, which pushes the first
   paragraph one row further down than `prose-quote` assumes. So the
   quote's row moves down to match whenever credits are on the page, and
   stays where `prose-quote` puts it when they are not.

   Not pinned to the foot of the column, on purpose. "Aligned to the
   bottom" was tried as `align-self: end` against a span reaching the text
   column's own last row, and set aside: the only way to make a grid item's
   span reach "the true last row" without knowing how long the prose is is
   either `-1`, which also reaches whatever row the links list lands on and
   can end up sharing a cell with it, or a fixed span far larger than any
   real page needs, which is a number nobody reading this file could
   explain or trust. And even bounded correctly, a short page — two
   paragraphs and a name list — would read exactly as the brief warned it
   might: a quote stranded a photograph's height below the writing it
   belongs to, worse than not being pinned at all. Beside the first
   paragraph is correct whether the page carries two lines of writing or
   twenty. */
.project-grid { display: grid; gap: 2rem; }
.project-prose:empty { display: none; }
@media (min-width: 48rem) {
  .project-grid:has(.photo-prose-photo .ob-page-backdrop) {
    grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
    gap: 3rem;
    align-items: start;
  }
}
@media (min-width: 64rem) {
  .project-prose:has(.ob-page-quote) {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    column-gap: 2.5rem;
    align-items: start;
  }
  .project-prose:has(.ob-page-quote) > * {
    grid-column: 1 / -1;
    max-width: none;
  }
  .project-prose:has(.ob-page-quote) > .ob-prose {
    grid-column: 1;
    max-width: none;
  }
  .project-prose:has(.ob-page-quote) > .ob-page-quote {
    grid-column: 2;
    grid-row: 2;
    align-self: start;
    margin: 0;
    max-width: none;
  }
  /* Credits push the first paragraph one row further down than
     `prose-quote` assumes (see the comment above) — the quote's row moves
     with it so the two still land side by side. */
  .project-prose:has(.ob-page-credits) > .ob-page-quote {
    grid-row: 3;
  }
}

/* A member's own page (`pages.memberPages`, `data-ob-page="member"`): the
   instrument leads the prose in `photo-prose` because neither
   `custom-page.html` nor the `page-title` slot has a subtitle to carry it —
   it wants the space under it the browser's own `<p>` margin doesn't reserve
   against the following paragraph's serif measure. */
[data-ob-page="member"] .ob-member-instrument { margin-bottom: 1.5rem; }
