/* ==========================================================================
   Soda Fountain — app.css
   Extracted from Soda Fountain v3.dc.html, which remains the design reference.
   Every value here is lifted from the prototype, not reinvented.

   Dark ("late shift") is the default. Light ("daylight") is a true cream-paper
   theme, not an inversion: brass darkens for contrast on cream and the teal
   actor names use their own ink. Put class="theme-light" on <html> to switch.

   Load order:  fonts -> this file.  No build step, no preprocessor.
   ========================================================================== */

/* ---------------------------------------------------------------- fonts -- */

/* Served from our own origin, not fonts.googleapis.com.
 *
 * The two <link>s that used to sit in the head cost three round trips before
 * the page could paint: the document had to arrive, then a render-blocking
 * stylesheet from a second origin (DNS + TCP + TLS, and it had no preconnect
 * of its own), and only from THAT could the browser learn the woff2 URLs on a
 * third origin. Lighthouse showed the shape of it plainly — FCP and LCP landed
 * on the same millisecond in all six regions, which is what a blocking chain
 * looks like: nothing paints, then everything does.
 *
 * These declarations live in app.css rather than in their own file because
 * app.css is already render-blocking and already fetched on the connection the
 * document arrived on, so they cost no request at all.
 *
 * latin and latin-ext only. Google also serves cyrillic and vietnamese; this is
 * an English-language archive and `unicode-range` means an unmatched subset is
 * never downloaded, so dropping them changes nothing a reader can see. latin-ext
 * is kept for exactly that reason in reverse — it is free unless a name needs
 * it, and names do (Rev. George Müller is inside latin, but the next one may
 * not be).
 *
 * `swap` is deliberate and unchanged: text stays visible in the fallback while
 * the face loads. It should now almost never be seen, because both latin files
 * are preloaded in layout.php and arrive on the already-open connection.
 *
 * Filenames are Google's own version-hashed basenames, so they are immutable.
 * Both faces are OFL. */

@font-face {
  font-family: 'Archivo';
  font-style: normal;
  font-weight: 400 900;
  font-stretch: 100%;
  font-display: swap;
  src: url('fonts/archivo-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
                 U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'Archivo';
  font-style: normal;
  font-weight: 400 900;
  font-stretch: 100%;
  font-display: swap;
  src: url('fonts/archivo-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
                 U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
                 U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* Nunito ships one weight here, 900. See --sf-display: the display rules
   inherit whatever weight their context sets, and loading exactly one face is
   what pins every one of them to the heavy cut. */
@font-face {
  font-family: 'Nunito';
  font-style: normal;
  font-weight: 900;
  font-display: swap;
  src: url('fonts/nunito900-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
                 U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'Nunito';
  font-style: normal;
  font-weight: 900;
  font-display: swap;
  src: url('fonts/nunito900-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
                 U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
                 U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* --------------------------------------------------------------- tokens -- */

:root {
  /* accent is user-tweakable in the prototype; brass is the shipped default */
  --sf-accent: #FFCE4A;
  --sf-accent-ink: #FFCE4A;      /* accent as *text*; darkens in light theme */

  --sf-bg: #0F0B14;
  /* The same colour as components, for gradients that fade the page out.
     Kept next to --sf-bg in both themes so they cannot drift apart. */
  --sf-bg-rgb: 15 11 20;
  --sf-panel: #17121F;
  --sf-panel2: #221B2E;

  --sf-rule: #2A2136;
  --sf-rule2: #33293F;
  --sf-rule3: #4A3D5C;

  --sf-head: #FFFFFF;            /* headings */
  --sf-text: #F6F1EA;            /* body */

  /* The quiet ramp.
   *
   * Lifted and pulled towards neutral in Aug 2026. It used to run
   * CFC2DD / 9C8CB0 / 7B6B8F / 6B5C7E, and two of those four were unreadable
   * rather than merely quiet: measured against the odd-row stripe, which is the
   * darker of the two list grounds, `muted2` came out at 3.83:1 and `muted3` at
   * 3.05:1. Both fail WCAG AA at any size, and they are what the airdate and the
   * runtime on every one of 1,081 episode rows were set in.
   *
   * Now, on that same stripe: 9.98 / 7.40 / 6.02, with text2 at 13.85.
   *
   * The hue shift matters as much as the lightness. The old ramp was a
   * saturated lavender sitting on a purple-black page, so it had little to
   * separate it from its own ground however bright it got; these are pulled
   * towards the cream the body text is set in, which is what makes them read as
   * quiet-but-legible rather than dim.
   *
   * Only these four moved. Body prose (--sf-text) measured 17.3:1 and was never
   * the problem. */
  --sf-text2: #E4DCEA;           /* secondary body */
  --sf-muted: #C6BACF;
  --sf-muted2: #AC9FB8;
  --sf-muted3: #9B8EA8;

  /* The fifth and faintest step, and the last one that is still text.
   *
   * Renamed from `--sf-tick` in Aug 2026, because that name had stopped being
   * true: it was described as "chart ticks, faintest text", and a sweep of the
   * stylesheet, the templates and app.js found no chart using it at all. Both
   * surviving consumers are words — the "N min read" in the reader's kicker and
   * the per-row count in the airdate calendar. A token named after a hairline
   * invites a hairline's contrast, which is how it came to sit at #4A3D5C:
   * 1.86:1 against the odd-row stripe, the worst figure on the site and less
   * than half what WCAG 1.4.3 asks.
   *
   * That mistake had already been made and fixed once — see
   * `.sf-crumbs__position` further down, which was pulled off this same token
   * for this same reason. Renaming it is what stops there being a third time.
   *
   * #8A7D96 measures 5.06 on the page, 4.81 on the stripe and 4.77 on a panel.
   * Both consumers are 800-weight but 12px and 10px, comfortably under the
   * 18.66px that would let them use the 3:1 large-text threshold, so 4.5:1 is
   * the bar on all three grounds. It stays a clear step below --sf-muted3
   * (6.02) so the hierarchy this token exists to express survives. */
  --sf-faint: #8A7D96;

  --sf-stripe: rgba(246, 241, 234, 0.030);
  --sf-glass: rgba(15, 11, 20, 0.93);

  --sf-on-accent: #100C16;       /* text ON an accent fill; dark in both themes */
  --sf-pop-ink: #FF4D5E;         /* the red — episode numbers, alerts */
  --sf-pop-hover: #FF8A95;
  /* The same red as a FILL rather than an ink, which is a different colour.
   *
   * --sf-pop-ink is tuned to be read as text on a near-black page, so it is
   * bright; put white on top of it and you get 3.24:1, and that is what the
   * selected filter pill and the active-filter count were. Both are 9-10px at
   * 900 weight, nowhere near the size that would let them use the 3:1
   * large-text threshold.
   *
   * Darkening the fill rather than blackening the text keeps the control
   * reading as "the red one", which is the whole job the colour is doing on a
   * bar where every other pill is an outline. White on #C41E31 is 5.87:1.
   *
   * This mirrors --sf-accent / --sf-accent-ink, which already splits the brass
   * the same way for the same reason. The light theme's red is dark to begin
   * with (white on it is 6.64:1), so there it is simply the same colour. */
  --sf-pop-fill: #C41E31;
  /* Actor and speaker names.
   *
   * Was #4FD8D8, which measured 11.26:1 on the page - the brightest ink on this
   * site after white itself. That is why a billing card read actor-first: the
   * character's name was set in plain white at 17:1 and lost anyway, because a
   * saturated cyan at 11:1 pulls harder than a neutral at any ratio. An ink
   * that says "this is a person" does not need to be the loudest thing in the
   * room to say it.
   *
   * #6FA8A4 is 7.25:1 on the page and 6.84 on a panel - comfortably past the
   * 4.5 these names need at 11px - and it is still unmistakably the teal.
   *
   * `-hi` is the hover, and it exists because there wasn't one: `.sf-actor:hover`
   * pointed at --sf-teal-ink2, which is DARKER, so hovering a link in the dark
   * theme dimmed it. Hover goes toward the page's foreground in each theme -
   * brighter on the dark ground, darker on cream. */
  --sf-teal-ink: #6FA8A4;
  --sf-teal-ink-hi: #A5D6D1;
  --sf-teal-ink2: #3FA9A9;
  /* The fourth ink, for the one thing that is neither reader-facing prose nor
     an actor: internal editorial content. Was a bare #B48BFF written into three
     templates, which is why the light theme had no way to darken it. */
  --sf-violet: #B48BFF;

  --sf-red-rule: #E8202F;        /* the 3px reader top rule; same in both themes */

  /* The ink every chart is drawn in — sparklines, row histograms, share bars.
     One token rather than `var(--sf-accent)` repeated at each chart, because
     the two themes want different answers and the charts have to agree with
     each other. On the dark page brass is the brightest thing available and
     reads at a two-pixel bar width; on cream it is nearly the paper colour, so
     forty brass bars on #F6F1EA came out as a smudge. Light switches the whole
     set to the red at once.

     The range sliders take this too, not --sf-accent. A slider is not a chart,
     but it sits directly beside one -- the year handles run along the top of
     the cast list whose every row is a 40-bar histogram -- and the two being
     different colours on cream was the mismatch. Brass also fails on cream for
     exactly the reason the bars did. */
  --sf-chart-ink: var(--sf-accent);
  /* The stub drawn for a year with nothing in it, so a gap reads as a gap. */
  --sf-chart-empty: var(--sf-rule);

  /* The airdate calendar's era hues, one per decade. Only the two lightest need
     a different answer on cream; the teal, the red and the violet carry across
     because they already darken or were dark to begin with. */
  --sf-decade-80: var(--sf-accent);
  --sf-decade-90: var(--sf-teal-ink);
  --sf-decade-00: var(--sf-pop-ink);
  --sf-decade-10: var(--sf-violet);
  --sf-decade-20: #8AE07C;

  /* elevation */
  /* The cover shadow is a rim plus an ambient, not an ambient alone. A plain
     50%-black shadow over #0F0B14 lands at #08060A — about seven levels of
     luminance separation, which is a shadow doing almost no work — and opacity
     cannot fix it, because even fully opaque black is only ~15 levels below
     this page. The 1px rim is what actually separates the art from the page on
     a dark screen; the ambient supplies the lift. */
  --sf-shadow-cover:
    0 0 0 1px rgba(246, 241, 234, 0.08),
    0 14px 32px rgba(0, 0, 0, 0.9);
  --sf-shadow-cover-hover:
    0 0 0 1px rgba(246, 241, 234, 0.14),
    0 24px 46px rgba(0, 0, 0, 0.95);
  --sf-shadow-pop: 0 30px 60px rgba(0, 0, 0, 0.55);
  --sf-shadow-bar: 0 -14px 40px rgba(0, 0, 0, 0.45);

  /* spacing
     One scale, used by every gap, padding and margin that is not answering to
     a specific measured thing (an avatar's 34px, a rail's 260px). The names are
     t-shirt sizes rather than numbers so inserting a step later does not
     renumber the ones after it. */
  /* The page's own gutter. A token because the sticky control bar has to
     cancel it exactly to reach the edge of the screen. */
  --sf-page-pad: 26px;

  --sf-space-3xs: 4px;
  --sf-space-2xs: 6px;
  --sf-space-xs: 8px;
  --sf-space-sm: 12px;
  --sf-space-md: 16px;
  --sf-space-lg: 22px;
  --sf-space-xl: 30px;
  --sf-space-2xl: 40px;

  /* The rhythm between one <section> and the next. Its own token rather than a
     step of the scale: it is the one measurement worth tuning on its own, and
     nine pages were each writing their own margin-top inline. */
  --sf-section-gap: 30px;

  /* type */
  /* Nunito in place of Alfa Slab One, at 900 throughout.
     Exactly one face is loaded, which is what fixes the weight: the twenty-odd
     display rules inherit whatever weight their context sets, and Alfa Slab One
     had a single very heavy face, so a request for 400 here would have rendered
     every heading thin. With only the 900 face available every request resolves
     to it, without threading a weight through every selector. */
  --sf-display: 'Nunito', system-ui, -apple-system, 'Segoe UI', sans-serif;
  --sf-body: 'Archivo', system-ui, -apple-system, 'Segoe UI', sans-serif;

  /* geometry */
  /* Type scale — a 1.25 ratio off a 16px base, rounded to whole pixels.
     Every heading and title in the site maps onto one of these. Before this
     there were three page-title sizes (52/40/34) and five item-title sizes
     (25/17/15/14/14), none derived from each other, and the 34px one was
     written inline on nine separate pages. A scale is not about the numbers
     being pretty; it is about there being a reason to reach for the next one
     rather than inventing it. */
  --sf-size-1: 52px;   /* the shelf's lede, once per site */
  --sf-size-2: 40px;   /* a page that is about one thing */
  --sf-size-3: 32px;   /* a page that is about a list of things */
  --sf-size-4: 25px;   /* a section heading inside a list */
  --sf-size-5: 19px;   /* a subsection heading */
  --sf-size-6: 15px;   /* the title of a row */

  /* The UI end of the same scale, added Aug 2026.
   *
   * The six steps above cover headings and titles and stop at 15px. Everything
   * below that - captions, eyebrows, pills, labels, loglines, which is most of
   * the type on this site - had no scale at all. An audit found 145 literal
   * font-sizes across 25 distinct values, eleven of them sitting between 8.5
   * and 14.5px. The small type read as inconsistent because it was.
   *
   * A 1px ladder rather than the 1.25 ratio the display end uses. A ratio is
   * right where the steps are far apart and each has to be visibly a different
   * size; down here the job is the opposite - a few near neighbours that are
   * nonetheless decided rather than improvised.
   *
   * The odd values folded onto the nearest step: 14.5 and 14 to size-7, 13.5
   * and 13 to size-8, 11.5 and 11 to size-10, 10.5 and 10 to size-11, 8.5 and 9
   * to size-12. Nothing moved by more than half a pixel. */
  --sf-size-7:  14px;  /* a logline; secondary prose */
  --sf-size-8:  13px;  /* dense prose; the reader's smaller body */
  --sf-size-9:  12px;  /* the workhorse label */
  --sf-size-10: 11px;  /* a caption */
  --sf-size-11: 10px;  /* a pill; a micro label */
  --sf-size-12:  9px;  /* the smallest thing here that is still text */

  /* Row titles are a ladder, not a step: the reader's SIZE control drives them
     through `:root[data-art]`, so the three live together or they drift. The
     small rung is --sf-size-6, which is why it is not repeated here. */
  --sf-row-title-md: 16px;
  --sf-row-title-lg: 17px;

  /* The album listing's ladder, one rung above the shelf's at each setting.
     Its middle rung is --sf-size-5. See the album contents block. */
  --sf-album-title-sm: 18px;
  --sf-album-title-lg: 21px;

  /* The big measured numeral - the figures strip and the tally values. */
  --sf-size-figure: 26px;

  /* Five sizes are deliberately NOT tokens, because each is used exactly once
     and a token used once is indirection with nothing on the other end:
     70/86px (the fallback cover's plate numeral), 34px, 24px, 23px and 15.5px.
     They are listed here so this block is still the one place to look. */

  /* Weight.
   *
   * Named for the job rather than the number, because the number is the thing
   * most likely to change. Nunito ships one face here (900), so a display
   * request resolves to it whatever this says; Archivo is variable 400-900 and
   * every one of these is a real choice. Before this there were 93 hand-written
   * font-weight declarations and no way to answer "make the titles lighter" in
   * fewer than 35 edits. */
  --sf-weight-display: 900;  /* Nunito; resolves to its only face regardless */
  --sf-weight-title:   800;  /* an item title, in Archivo */
  --sf-weight-label:   700;  /* eyebrows, pills, captions, table heads */
  --sf-weight-prose:   500;  /* a sentence on a dark ground */
  --sf-weight-body:    400;  /* a sentence */

  --sf-radius-xs: 4px;
  --sf-radius-sm: 6px;
  --sf-radius: 10px;
  --sf-radius-lg: 12px;
  --sf-radius-pill: 999px;

  /* Portraits are not circles. The art is a bust — head, neck and shoulders,
     cut out on transparency — and a circle's arc slices across exactly where
     that bust is widest, so every character came out looking beheaded. A flat
     bottom lets the shoulders run off the edge the way an ordinary headshot
     crop does. A percentage, not a pixel value, so the corner keeps its
     proportion from the 26px avatar up to the 132px bio portrait. */
  --sf-radius-portrait: 24%;
  --sf-page: 1400px;
  --sf-measure: 68ch;
}

.theme-light {
  --sf-accent-ink: #735D21;      /* brass × 0.45 per channel — the darken() rule */

  --sf-bg: #F6F1EA;
  --sf-bg-rgb: 246 241 234;
  --sf-panel: #FFFFFF;
  --sf-panel2: #EFE8DA;

  --sf-rule: #DCD2C0;
  --sf-rule2: #C9BDA8;
  --sf-rule3: #B6A992;

  --sf-head: var(--sf-on-accent);
  --sf-text: #1A1420;
  --sf-text2: #3D3348;
  --sf-muted: #6A5C7C;
  --sf-muted2: #6E6076;
  --sf-muted3: #6B5F72;
  /* Unchanged but for the name: on cream this already measured 5.23 on the
     page and 5.87 on a panel. Only the dark theme's value was failing. */
  --sf-faint: #6F6350;

  --sf-stripe: rgba(26, 20, 32, 0.045);
  --sf-glass: rgba(246, 241, 234, 0.92);

  --sf-pop-ink: #B3202F;
  --sf-pop-hover: #8E1A29;
  /* Already dark enough to carry white: 6.64:1. The dark theme is the only one
     that needed a separate fill. */
  --sf-pop-fill: var(--sf-pop-ink);
  --sf-teal-ink: #166767;
  /* Toward the ink on cream, which is the same rule the dark theme follows in
     the other direction. */
  --sf-teal-ink-hi: #0D4747;
  --sf-teal-ink2: #125555;
  --sf-violet: #6B3FB8;

  /* Charts go red on cream. Brass is a light colour: against #F6F1EA it has
     barely more contrast than the paper, and a row histogram is forty two-pixel
     bars with nothing else to identify them — at that width a low-contrast fill
     is not a faint chart, it is no chart. The red is the same ink the episode
     numbers use, so it stays inside the palette. */
  --sf-chart-ink: var(--sf-pop-ink);
  --sf-chart-empty: var(--sf-rule2);

  /* Brass, the pale green and the pale violet all vanish into cream paper. The
     violet follows --sf-violet, which the theme already darkens. */
  --sf-decade-80: var(--sf-accent-ink);
  --sf-decade-20: #3E8A2E;

  /* a 50%-black shadow reads as dirt on cream, so the light theme takes a
     browner and shallower one — same rim-plus-ambient construction, dark rim
     instead of light */
  --sf-shadow-cover:
    0 0 0 1px rgba(60, 40, 20, 0.10),
    0 10px 24px rgba(60, 40, 20, 0.22);
  --sf-shadow-cover-hover:
    0 0 0 1px rgba(60, 40, 20, 0.16),
    0 18px 36px rgba(60, 40, 20, 0.30);
  --sf-shadow-pop: 0 24px 50px rgba(60, 40, 20, 0.20);
  --sf-shadow-bar: 0 -14px 40px rgba(60, 40, 20, 0.16);

  color-scheme: light;
}

/* ----------------------------------------------------------------- base -- */

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

html, body {
  margin: 0;
  padding: 0;
  background: var(--sf-bg);
}

body {
  font-family: var(--sf-body);
  color: var(--sf-text);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

a { color: var(--sf-text); text-decoration: none; }
a:hover { color: var(--sf-accent-ink); }

/* Form controls do not inherit the page font on their own — left alone, every
   search box, filter and admin field falls back to the browser's UI font and
   quietly drops out of Archivo. */
button, input, select, textarea, optgroup { font-family: inherit; }
button { cursor: pointer; }
::placeholder { font-family: inherit; }

:focus-visible {
  outline: 2px solid var(--sf-accent-ink);
  outline-offset: 2px;
  border-radius: var(--sf-radius-xs);
}

h1, h2, h3 {
  font-family: var(--sf-display);
  font-weight: var(--sf-weight-body);               /* Alfa Slab One has one weight */
  color: var(--sf-head);
  text-wrap: pretty;
  margin: 0;
}

.sf-page { max-width: var(--sf-page); margin: 0 auto;
           padding: 30px var(--sf-page-pad) 80px; }
.sf-measure { max-width: var(--sf-measure); }

/* Small caps label — used everywhere above a value or section. */
.sf-label {
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.15em;
  text-transform: uppercase;
  color: var(--sf-muted2);
}

/* ---------------------------------------------------------------- header -- */

.sf-header {
  position: sticky;
  top: 0;
  z-index: 40;
  background: var(--sf-glass);
  backdrop-filter: blur(10px);
  border-bottom: 1px solid var(--sf-rule);
}

.sf-header__inner {
  max-width: var(--sf-page);
  margin: 0 auto;
  padding: 12px 26px;
  display: flex;
  align-items: center;
  gap: 18px;
}

.sf-wordmark {
  display: flex;
  /* Baseline, not centre. The cup is the letter O of SODA, and its artwork is
     drawn so the ring's outer edge touches the bottom of the viewBox — which
     for a replaced element is exactly what a flex baseline aligns on. Centring
     instead hung it ~2px below the S and the D, because the straw and cherry
     occupy the top of the box and nothing occupies the bottom. */
  align-items: baseline;
  gap: 1px;
  font-family: var(--sf-display);
  font-size: 23px;
  line-height: 1;
  color: var(--sf-text);
  flex-shrink: 0;
}

/* The wordmark holds its width; the search field beside it is what flexes. */
.sf-masthead { flex-shrink: 0; }

.sf-nav { display: flex; align-items: center; gap: 4px; margin-left: auto; }

/* The theme switch is not a nav item and sits outside the <nav>, so the
   header's own 18px gap already separates it from the last section — it needs
   no margin of its own. It had 10px when it lived inside the nav, where the
   gap between items was only 4px. */

.sf-nav__link {
  height: 38px;
  padding: 0 13px;
  display: inline-flex;
  align-items: center;
  border-radius: var(--sf-radius-pill);
  font-size: var(--sf-size-9);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--sf-muted);
}
.sf-nav__link:hover { color: var(--sf-head); background: var(--sf-panel); }
.sf-nav__link[aria-current="page"] { color: var(--sf-head); background: var(--sf-panel2); }

/* ---------------------------------------------------------- header, small -- */

/* On a phone the three parts of the header cannot share a line. Left as one
   flex row they wrapped, and the nav — which is `margin-left: auto` and does
   not shrink — ran 67px past the viewport: "By the numbers" was cut through
   and the theme switch was off the screen entirely, unreachable.
 *
 * So the header stacks into three rows: the wordmark and the theme switch,
 * then the search at full width, then the sections.
 *
 * The nav scrolls sideways rather than wrapping. Wrapping five uppercase
 * section names onto two or three lines makes the header taller than the first
 * screenful of content, and the control bar sticks to the bottom of it — so
 * every extra header row is a row stolen from the list on every page. A strip
 * that scrolls keeps the header one row tall and still reaches everything. */
@media (max-width: 860px) {
  .sf-header__inner {
    flex-wrap: wrap;
    gap: 10px 12px;
    padding: 10px 16px;
  }

  .sf-search { order: 3; flex: 1 0 100%; max-width: none; }
  .sf-nav { order: 4; flex: 1 0 100%; margin-left: 0; }

  .sf-nav {
    /* Bleed to the screen edges so the strip reads as scrollable — a row that
       stops short of the edge looks like a row that fits. */
    margin-inline: -16px;
    padding-inline: 16px;
    overflow-x: auto;
    flex-wrap: nowrap;
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
  }
  .sf-nav::-webkit-scrollbar { display: none; }
  .sf-nav__link { flex-shrink: 0; }

  /* Out of the scrolling strip and up beside the wordmark, where it is always
     reachable without scrolling to the end of the sections. */
  .sf-nav__mode {
    order: 2;
    margin-left: auto;
    height: 32px;
    padding: 0 12px 0 10px;
  }
}

/* The phone treatment for the filter bar lives with the bar itself, further
   down — a media query only beats the rule it is overriding if it comes after
   it, and `.sf-filters { display: none }` is declared with the pills. */

/* ----------------------------------------------------------------- pills -- */

.sf-pill {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  height: 38px;
  padding: 0 14px 0 11px;
  border-radius: var(--sf-radius-pill);
  border: 1px solid var(--sf-rule2);
  background: transparent;
  color: var(--sf-muted);
  font-size: var(--sf-size-10);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.12em;
  text-transform: uppercase;
}
/* Labels that are not words keep the casing they were written in. Every other
   pill is a word and reads fine shouted, but "100s" came out "100S", which is
   a different thing — the s is a plural, not an initial. */
.sf-pill--exact { text-transform: none; letter-spacing: 0.06em; }

.sf-pill:hover { color: var(--sf-head); border-color: var(--sf-accent-ink); }
.sf-pill--on { background: var(--sf-pop-fill); border-color: var(--sf-pop-fill); color: #fff; }
.sf-pill--on:hover { color: #fff; }

/* Brass chip carrying a count. */
.sf-chip-count {
  font-size: var(--sf-size-10);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--sf-on-accent);
  background: var(--sf-accent);
  border-radius: var(--sf-radius-pill);
  padding: 2px 9px;
}

/* ------------------------------------------------------------ album art -- */

/* THE SHADOW. It belongs on the element carrying the background image, so it
   follows the 8px radius. Do not wrap this in an overflow:hidden container —
   that clips the shadow, which is how it goes missing. */
.sf-cover {
  display: block;
  border-radius: var(--sf-radius);
  background-size: cover;
  background-position: center;
  box-shadow: var(--sf-shadow-cover);
}

.sf-cover--shelf { width: 100%; aspect-ratio: 1 / 1; }
.sf-cover--drawer { width: 230px; margin: 0 auto; aspect-ratio: 1 / 1; }
.sf-cover--row { width: 60px; height: 60px; flex-shrink: 0; }

/* Fallback when no scan exists and the album has no number: a gradient tile. */
.sf-cover--tile {
  display: grid;
  place-items: center;
  font-family: var(--sf-display);
  color: rgba(255, 255, 255, 0.92);
}

/* Fallback for a NUMBERED album with no scan yet — album 83 and whatever
   follows it. The sleeves share one frame, so the placeholder rebuilds it:
   a hairline, a gold band, the blue field, a gold inner line, and the number
   set in the middle in black with a gold outline.

   Colours sampled off images/albums-2026/77.png rather than guessed. The band
   widths there, on a 600px sleeve, are 3 / 5 / 15 / 5 px — near enough to
   0.5 / 1.5 / 4 / 5 multiples of a ~2px unit at the sizes we render, which is
   what --sf-plate-ring is. */
.sf-cover--plate {
  --sf-plate-gold: #F9B748;
  --sf-plate-blue: #4A73C2;
  --sf-plate-edge: #111111;
  --sf-plate-ring: 2px;

  display: grid;
  place-items: center;
  background: var(--sf-plate-blue);
  font-family: var(--sf-display);
  color: #000;
  line-height: 1;

  /* Concentric bands, drawn from the edge inward. The list runs smallest
     spread first because an earlier inset shadow paints over a later one:
     reverse the order and the hairline swallows the whole frame.

     Held in a variable because the frame IS a box-shadow, and every other
     box-shadow rule that reaches a cover replaces the whole list rather than
     adding to it — the shelf's hover did exactly that, and the placeholder's
     border vanished the moment you pointed at it. Anything that wants to add a
     shadow to this element has to re-state the rings, so they live somewhere
     they can be re-stated from. */
  --sf-plate-rings:
    inset 0 0 0 calc(var(--sf-plate-ring) * 0.5) var(--sf-plate-edge),
    inset 0 0 0 calc(var(--sf-plate-ring) * 1.5) var(--sf-plate-gold),
    inset 0 0 0 calc(var(--sf-plate-ring) * 4)   var(--sf-plate-blue),
    inset 0 0 0 calc(var(--sf-plate-ring) * 5)   var(--sf-plate-gold);

  box-shadow: var(--sf-plate-rings), var(--sf-shadow-cover);

  /* Gold outline behind a black fill, as on the sleeves. paint-order keeps the
     stroke under the fill so the numerals stay their true weight instead of
     being eaten from the inside. */
  -webkit-text-stroke: 0.055em var(--sf-plate-gold);
  paint-order: stroke fill;
}

/* The number wants to fill the plate, so its size is set per variant rather
   than inherited from the tile's smaller label type. */
.sf-cover--plate.sf-cover--shelf  { font-size: 70px; }
.sf-cover--plate.sf-cover--drawer { font-size: 86px; }

/* At 46px the four bands would collapse into a smudge; one gold edge reads
   better and still says "same family as the sleeves". */
.sf-cover--plate.sf-cover--row {
  --sf-plate-ring: 1px;
  font-size: var(--sf-row-title-lg);
  box-shadow:
    inset 0 0 0 1px var(--sf-plate-gold),
    var(--sf-shadow-cover);
  -webkit-text-stroke: 0;
  color: var(--sf-plate-gold);
}

/* The album page's centred masthead: cover, number, title, then the meta row.
   Named rather than left as an inline style so the drawer can zero its top
   padding without reaching for it by position. */
.sf-album__header { text-align: center; padding: 10px 0 30px; }

/* The shelf mark above the title, with room between it and the cover. */
.sf-album__num { margin-top: 18px; }

/* "Previously released as …" — a footnote to the title, not a subtitle, so it
   is small and italic rather than another line of heading. */
.sf-album__former {
  font-size: var(--sf-size-8);
  font-weight: var(--sf-weight-label);
  font-style: italic;
  color: var(--sf-muted2);
  margin-top: var(--sf-space-3xs);
}

/* Release date, formats, running time: one wrapping row of short facts. */
.sf-album__facts {
  display: flex;
  gap: 14px;
  justify-content: center;
  flex-wrap: wrap;
  margin-top: var(--sf-space-sm);
  font-size: var(--sf-size-9);
  font-weight: var(--sf-weight-title);
  color: var(--sf-muted2);
}

/* --------------------------------------------------------------- shelves -- */

.sf-shelf {
  display: grid;
  /* The minimum is a custom property so the cover-size slider can move it;
     190px stays the default when nothing is stored. */
  grid-template-columns: repeat(auto-fill, minmax(var(--sf-cover-size, 190px), 1fr));
  gap: 26px 20px;
}

.sf-shelf__item { display: flex; flex-direction: column; gap: 10px; }
.sf-shelf__num {
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--sf-accent-ink);
}
.sf-shelf__title { font-family: var(--sf-display); font-size: var(--sf-size-6); line-height: 1.2; color: var(--sf-head); }

/* ------------------------------------------------------- episode grid -- */

/* The wall of artwork at /episodes/grid.
 *
 * Built like the album shelf rather than like the episode rows, because it is
 * the same idea — a field of pictures you scan rather than read — and giving it
 * a second idiom would mean two answers to one question. The size slider drives
 * the column minimum, so the control that resizes a row tile resizes this too
 * and the reader does not have to learn a second thing.
 *
 * `content-visibility` for the same reason the rows have it: a filtered view of
 * a thousand episodes is a great many tiles, and the ones below the fold should
 * not cost layout or a network request until they are near.
 */
.sf-egrid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 26px 18px;
}
:root[data-art="2"] .sf-egrid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
:root[data-art="3"] .sf-egrid { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }

/* Inside an expanded group the tiles are already inset by the group's padding,
   so they need a little air above them and none of the page-level margin. */
.sf-egrid--grouped { padding: 16px 14px 20px; }

.sf-etile {
  display: flex;
  flex-direction: column;
  gap: 8px;
  color: var(--sf-text);
  content-visibility: auto;
  contain-intrinsic-size: auto 230px;
}
.sf-etile:hover { color: var(--sf-text); }

/* The picture, and the chip that sits on its corner. Square by aspect-ratio
   rather than by a height, so the tile keeps its shape at every column width
   the slider produces. */
.sf-etile__art { position: relative; display: block; }

/* Two selectors deep on purpose. The fallback picture is rendered by
   partial/cover and so arrives wearing `.sf-cover--drawer`, which is a fixed
   230px — a single-class rule here would be settled by source order, and the
   order is not something this block should have to know about. */
.sf-etile__art > .sf-etile__pic {
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
  height: auto;
  border-radius: var(--sf-radius);
  background-size: cover;
  background-position: center;
  box-shadow: var(--sf-shadow-cover);
  background-image: image-set(var(--e-md) 1x, var(--e-lg) 2x);
  transition: box-shadow 140ms ease-out;
}
.sf-etile:hover .sf-etile__art > .sf-etile__pic { box-shadow: 0 0 0 2px var(--sf-accent-ink), var(--sf-shadow-cover); }

/* An episode on no release and with no artwork yet. A panel rather than a gap:
   an empty cell would make the grid look broken rather than incomplete. */
.sf-etile__art > .sf-etile__pic--none {
  background: linear-gradient(150deg, var(--sf-panel2), var(--sf-panel));
  box-shadow: inset 0 0 0 1px var(--sf-rule2);
}

/* Bigger than the row's chip because the tile is bigger, and pinned to the
   corner the same way so the two listings read as one family. */
.sf-etile__chip {
  position: absolute;
  left: -6px;
  bottom: -6px;
  width: 38px;
  height: 38px;
  border-radius: 5px;
  box-shadow: 0 0 0 2px var(--sf-bg), 0 2px 6px rgba(0, 0, 0, 0.55);
}
:root[data-art="3"] .sf-etile__chip { width: 46px; height: 46px; border-radius: 6px; }

.sf-etile__num {
  font-family: var(--sf-display);
  font-weight: var(--sf-weight-display);
  font-size: var(--sf-size-10);
  letter-spacing: 0.05em;
  color: var(--sf-pop-ink);
  margin-top: 2px;
}
.sf-etile__title {
  font-weight: var(--sf-weight-title);
  font-size: var(--sf-size-7);
  line-height: 1.25;
  color: var(--sf-head);
  transition: color 140ms ease-out;
}
.sf-etile:hover .sf-etile__title { color: var(--sf-accent-ink); }
:root[data-art="3"] .sf-etile__title { font-size: var(--sf-row-title-md); }

@media (prefers-reduced-motion: reduce) {
  .sf-etile__pic, .sf-etile__title { transition: none; }
}

@media (max-width: 720px) {
  .sf-egrid { grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); gap: 20px 12px; }
  .sf-etile__chip { width: 30px; height: 30px; left: -5px; bottom: -5px; }
}

/* ---------------------------------------------------------- episode rows -- */

.sf-eprow {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 14px;
  padding: 11px 14px;
  border-radius: var(--sf-radius);
  color: var(--sf-text);
}

/* With the album sleeve between the number and the title.
 *
 * A modifier rather than an always-present empty cell: grouped BY album the
 * cover is suppressed, and a 46px column of nothing down that whole view would
 * be a gutter with no reason to exist.
 *
 * The gap drops to 12px on the left of the tile — 16px between a small number
 * and a picture reads as a gap in the row rather than as two things belonging
 * to one line — and the tile carries its own 16px to the title. */
.sf-eprow--art {
  grid-template-columns: 60px minmax(0, 1fr);
  gap: 14px;
}

/* The episode's own picture, which is the thing worth looking at on this row.
 *
 * `image-set()` rather than one URL: the tile is 60, 96 or 132px depending on
 * the size step, and 160w covers the first two at 2x while 132 needs the 240.
 * The rungs arrive as custom properties from the row (see partial/episode-row
 * for why they are not inline background-images). */
.sf-cover--epart { background-image: image-set(var(--e-sm) 1x, var(--e-md) 2x); }

/* The album sleeve, small, on the corner of the episode's picture.
 *
 * Absolutely placed rather than given a column of its own: a third track would
 * cost 30px of every row to show something the reader mostly already knows,
 * and overlapping the corner reads as "this belongs to that" without asking
 * for the space. The ring is what stops it dissolving into whatever it lands
 * on — some of the artwork is dark in exactly that corner. */
.sf-eprow__art { position: relative; display: block; align-self: center; }
.sf-eprow__chip {
  position: absolute;
  left: -5px;
  bottom: -5px;
  width: 24px;
  height: 24px;
  border-radius: 4px;
  box-shadow: 0 0 0 2px var(--sf-bg), 0 1px 4px rgba(0, 0, 0, 0.5);
}
.sf-eprow:nth-child(odd) .sf-eprow__chip { box-shadow: 0 0 0 2px var(--sf-panel2), 0 1px 4px rgba(0, 0, 0, 0.5); }
.sf-eprow:hover .sf-eprow__chip { box-shadow: 0 0 0 2px var(--sf-panel), 0 1px 4px rgba(0, 0, 0, 0.5); }

/* Squared off, not rounded like the shelf tiles.
 *
 * At 46px the 8px radius the shelf uses eats a visible bite out of each corner
 * of the art; 3px keeps the softness without cropping the picture. The shadow
 * goes too — forty of them down a page is a grey haze — and a hairline does the
 * separating instead. */
.sf-eprow__tile {
  border-radius: 3px;
  box-shadow: 0 0 0 1px var(--sf-rule2);
  align-self: center;
}

/* The sleeve answers the pointer with the title, so the row still reads as one
   object rather than as a picture next to a link. */
.sf-eprow:hover .sf-eprow__tile { box-shadow: 0 0 0 1px var(--sf-accent-ink); }

/* Smaller with the loglines hidden.
 *
 * That toggle exists to compress the list, and a 46px tile sets a floor the
 * text no longer reaches: measured, it added 11px to every row of a view whose
 * whole purpose is to fit more of them on screen. 36px costs nothing — the row
 * is 57px on text alone — so the artwork survives the dense mode instead of
 * quietly working against it. */
.sf-no-loglines .sf-eprow--art { grid-template-columns: 40px minmax(0, 1fr); }
/* The chip goes with the dense mode: at 40px it would cover a third of the
   picture, and the whole point of that toggle is to fit more rows in. */
.sf-no-loglines .sf-eprow__chip { display: none; }
.sf-no-loglines .sf-eprow__tile { width: 40px; height: 40px; }
/* A hairline between rows as well as the stripe. With the faces gone the rows
   are shorter and closer together, and the stripe alone stopped being enough
   to say where one ends — two adjacent light rows read as a single block. The
   rule sits inside the padding so it stops short of the row's rounded corners
   rather than cutting across them. */
.sf-eprow + .sf-eprow { box-shadow: inset 0 1px 0 var(--sf-rule); }
.sf-eprow:hover { background: var(--sf-panel); color: var(--sf-text); }
.sf-eprow:nth-child(odd) { background: var(--sf-stripe); }
.sf-eprow:nth-child(odd):hover { background: var(--sf-panel); }

/* The number and the title on one baseline.
 *
 * The number kept its red and lost its column. First in reading order at 16px
 * it was the loudest thing on a row whose list is sorted by airdate, which is
 * to say the field a reader is least likely to be scanning was the field
 * shouting. Beside the title it is a tag on the thing it names. */
.sf-eprow__head {
  display: flex;
  align-items: baseline;
  gap: 8px;
  flex-wrap: wrap;
}
.sf-eprow__num {
  font-family: var(--sf-display);
  font-weight: var(--sf-weight-display);
  font-size: var(--sf-size-9);
  letter-spacing: 0.05em;
  color: var(--sf-pop-ink);
  flex: none;
}
.sf-eprow__title { display: block; font-weight: var(--sf-weight-title); font-size: var(--sf-size-6); line-height: 1.25; color: var(--sf-head); }
/* The title takes the accent on hover, the same as the compact line does on an
   album's contents and a character's appearances. Both are lists of episodes
   you click through to, so they should answer a pointer the same way. */
.sf-eprow:hover .sf-eprow__title { color: var(--sf-accent-ink); }
.sf-eprow__sub { display: block; font-size: var(--sf-size-9); font-weight: var(--sf-weight-label); color: var(--sf-muted); }

/* Loglines off: a denser list for someone scanning titles rather than reading.
   The class sits on <html>, not inside the results, so it survives a sort or a
   section expanding without having to be carried through every link. */
.sf-no-loglines .sf-eprow__sub { display: none; }

/* A switch, not a choice. The pills beside it pick one of a set and fill in
   when active; this one is on or off, so it keeps a steady outline and says so
   in words instead. Never takes `sf-pill--on` — a filled state here would read
   as "Loglines is the selected grouping". */
.sf-toggle {
  padding: 0 10px;
  color: var(--sf-head);
}
.sf-toggle__icon { width: 17px; height: 17px; display: block; }

/* Off: the brackets stay, the dots go — the icon says what the list is doing.
   Dimmed as well, so state reads at a glance and not only on inspection. */
.sf-toggle[aria-pressed="false"] { color: var(--sf-muted3); }
.sf-toggle[aria-pressed="false"] .sf-toggle__icon circle { opacity: 0; }
.sf-toggle:hover { color: var(--sf-accent-ink); }

/* The rule that keeps this switch apart from the pills after it is
   .sf-controls__divider, in the controls block. */

.sf-no-loglines .sf-eprow { contain-intrinsic-size: auto 60px; }

/* The description of an episode that has not aired yet, blurred until asked
   for. `user-select: none` so it cannot be dragged out or copied while hidden,
   which would defeat the whole thing. The row's own hover already lifts the
   background, so revealing on hover reads as part of the same gesture; the
   focus-within case is what makes it reachable from the keyboard. */
.sf-spoiler {
  filter: blur(4px);
  user-select: none;
  transition: filter 140ms ease-out;
}
.sf-eprow:hover .sf-spoiler,
.sf-eprow:focus-visible .sf-spoiler,
.sf-eprow:focus-within .sf-spoiler {
  filter: none;
  user-select: auto;
}

@media (prefers-reduced-motion: reduce) {
  .sf-spoiler { transition: none; }
}

/* The airdate, leading the row.
 *
 * The display face, uppercased and tracked — the same treatment every label on
 * the site gets — in teal. Teal is the one hue in the palette not already
 * carrying a meaning here: red is the episode number, brass is hover, violet
 * is the skit dot. Tabular figures so the dates line up down the column even
 * though nothing pins them to a grid. */
.sf-eprow__date {
  font-family: var(--sf-display);
  font-weight: var(--sf-weight-display);
  font-size: var(--sf-size-10);
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--sf-teal-ink);
}

/* Between the facts. An element rather than a `::before` on each sibling,
   because the runtime is a bordered pill and a pseudo-element would draw its
   dot inside the border. */
.sf-eprow__sep {
  width: 3px;
  height: 3px;
  border-radius: 50%;
  background: var(--sf-rule3);
  flex: none;
}

/* Where the runtime goes on an episode that has not been broadcast yet. A pill
   rather than a dash, because "—" is what an unknown runtime looks like and
   this is a known fact about the episode, not a missing one. */
.sf-soon {
  display: inline-block;
  padding: 2px 8px;
  border-radius: var(--sf-radius-pill);
  border: 1px solid var(--sf-accent-ink);
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--sf-accent-ink);
  white-space: nowrap;
}

/* -------------------------------------------------------------- sections -- */

.sf-section { margin-bottom: 34px; }

/* A section that also needs air above it — one block ending and another
   starting, rather than a run of sections down a page. Nine templates each
   wrote their own `style="margin-top: …"` here, at 26px, 30px, 34px and 38px,
   which is four values for one idea and no way to change the rhythm without
   finding all nine. */
.sf-section--spaced { margin-top: var(--sf-section-gap); }

.sf-section__head {
  display: flex;
  align-items: baseline;
  gap: 12px;
  flex-wrap: wrap;
  border-bottom: 2px solid var(--sf-rule);
  padding-bottom: 9px;
  margin-bottom: 14px;
}
.sf-section__head h2 { font-size: var(--sf-size-5); margin: 0; }
.sf-section__note { font-size: var(--sf-size-9); font-weight: var(--sf-weight-label); color: var(--sf-muted2); }

/* A note under a heading rather than beside it — the ranked charts, where the
   sentence is too long to sit on the heading's line. */
.sf-section__note--under { display: block; margin: -8px 0 var(--sf-space-sm); }

/* The same note explaining a form field, or trailing a list. */
.sf-section__note--field { display: block; margin: 0 0 var(--sf-space-xs); }
.sf-section__note--after { display: block; margin-top: 14px; }

/* A small-caps label acting as a heading for the thing directly below it,
   rather than as a caption beside a value. */
.sf-label--over { display: block; margin-bottom: 10px; }

/* The mirror image: a caption under the figure it names. */
.sf-label--under { display: block; margin-top: var(--sf-space-2xs); }

/* Figure card — one headline count with a caption and a note.
 *
 * Was two rules four hundred lines apart, the later one turning the card's fill
 * off and giving it a border instead while leaving the earlier rule's grid and
 * padding in force. Merged here to exactly what the cascade was already
 * producing, so the card renders as it did but can now be read in one place. */
.sf-figure {
  display: grid;
  grid-template-rows: auto 1fr auto;
  row-gap: 6px;
  padding: 14px 16px;
  background: transparent;
  border: 1px solid var(--sf-panel2);
  border-radius: var(--sf-radius-lg);
}
.sf-figure__value { font-family: var(--sf-display); font-size: var(--sf-size-figure); line-height: 1.05; color: var(--sf-head); }
.sf-figure__note { font-size: var(--sf-size-10); font-weight: var(--sf-weight-label); color: var(--sf-muted3); line-height: 1.35; }

/* ------------------------------------------------------------- speaking -- */
/* Speaking time on a character page. The note is part of the component, not
   decoration: the figure is a floor, and a floor shown without saying so reads
   as a total. */

.sf-speaking {
  margin-top: 20px;
  padding: 14px 16px 13px;
  background: var(--sf-panel);
  border-left: 3px solid var(--sf-accent-ink);
}
.sf-speaking__figure {
  display: flex;
  align-items: baseline;
  gap: 10px;
  flex-wrap: wrap;
}
.sf-speaking__label {
  font-size: var(--sf-size-10);
  font-weight: var(--sf-weight-title);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--sf-muted2);
}
.sf-speaking__value {
  font-family: var(--sf-display);
  font-size: var(--sf-size-figure);
  line-height: 1.05;
  color: var(--sf-head);
}
.sf-speaking__meta { font-size: var(--sf-size-9); font-weight: var(--sf-weight-label); color: var(--sf-muted2); }
/* ------------------------------------------------------- character head -- */
/* Portrait first, then the name, the counts and the way on. Reading order and
   visual order agree, so the face is not an afterthought pinned to the far
   edge of a line it has nothing to do with. `align-items: flex-start` because
   the block beside it is three lines tall and a centred portrait beside it
   floats. */

.sf-charhead {
  display: flex;
  align-items: flex-start;
  gap: 22px;
  padding-top: 4px;
}
.sf-charhead__text { min-width: 0; flex: 1 1 auto; }

/* The appearance count leads the meta line, so it carries the accent and the
   rest of the line stays quiet. */
.sf-charhead__count { color: var(--sf-accent-ink); }

/* Used when a character has no art: the same frame, carrying the name. */
.sf-charhead__face--none {
  display: grid;
  place-items: center;
  color: rgba(255, 255, 255, 0.94);
  box-shadow: none;
}

.sf-charhead__face {
  flex: 0 0 auto;
  width: 104px;
  height: 104px;
  border-radius: var(--sf-radius-portrait);
  background-size: cover;
  background-position: center top;
  background-color: var(--sf-panel);
  box-shadow: inset 0 0 0 3px var(--sf-accent-ink);
}

/* The drawer is 560px wide inside a window that is not, so this has to answer
   to its container and not the viewport — a media query cannot tell the drawer
   and the full page apart. 104px of portrait beside a wrapped name is most of
   the crowding in there. */
@container page (max-width: 760px) {
  .sf-charhead { gap: 14px; }
  .sf-charhead__face { width: 72px; height: 72px; box-shadow: inset 0 0 0 2px var(--sf-accent-ink); }
}

@media (max-width: 560px) {
  .sf-charhead__face { width: 72px; height: 72px; }
}

/* ------------------------------------------------ character reader rail -- */

/* The portrait in the rail, where the episode reader puts the album cover.
   Full column width and square, so the two pages have the same silhouette. */
.sf-charrail__face {
  display: grid;
  place-items: center;
  width: 100%;
  aspect-ratio: 1 / 1;
  border-radius: var(--sf-radius-portrait);
  background-size: cover;
  background-position: center top;
  background-color: var(--sf-panel);
  box-shadow: inset 0 0 0 3px var(--sf-accent-ink);
}

/* The monogram stands in for the 3,700 characters with no art, so the rail
   keeps its shape rather than collapsing to just a list of actors. */
.sf-charrail__face--none {
  color: rgba(255, 255, 255, 0.94);
  box-shadow: none;
}

/* The name set inside a large monogram, one column part per line.
   `word-break` rather than a smaller size for everyone: the long names are a
   handful — Cunningham-Schultz, Whittaker-Dowd — and shrinking the type for
   all 3,700 to spare those few would make the common short name look lost in
   its own box. */
.sf-mononame {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  padding: 0 10px;
  font-family: var(--sf-display);
  line-height: 1.05;
  text-align: center;
  text-wrap: balance;
  word-break: break-word;
  hyphens: auto;
}

.sf-charrail__face--none .sf-mononame { font-size: var(--sf-size-figure); }
.sf-charhead__face--none .sf-mononame { font-size: var(--sf-size-8); padding: 0 6px; gap: 1px; }

/* The actor list, which is a control rather than a credit: pressing a row
   filters the appearances. Built on the rail's billing rhythm — same rule,
   same type — so it reads as part of the same column and not as a widget
   dropped into it. */
.sf-voicefilter { display: grid; }

.sf-voicefilter__row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  padding: 7px 0;
  border-bottom: 1px solid var(--sf-rule);
  font-size: var(--sf-size-8);
  font-weight: var(--sf-weight-title);
  color: var(--sf-text2);
}
.sf-voicefilter__row:hover { color: var(--sf-accent-ink); }

/* Name and marker share the left cell, so the count stays hard right.
   min-width: 0 lets a long name truncate rather than push the count off. */
.sf-voicefilter__label {
  display: inline-flex;
  align-items: baseline;
  gap: 5px;
  min-width: 0;
}

/* Which self this actor played.
 *
 * The billing line's badge at 9px with a border is too heavy here: that sits
 * against a 14px name on a wide row, while this is a 13px name in a rail with a
 * count on the other side, and a bordered pill reads as a second control rather
 * than as a note on the first. So the same word, smaller, and set apart by
 * weight and colour instead of a box.
 *
 * Not shortened further. "young" is already the short form — the long one is in
 * the title attribute — and an abbreviation here would need a legend. */
.sf-voicefilter__note {
  flex: 0 0 auto;
  font-size: var(--sf-size-12);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--sf-muted3);
  /* Nudged off the baseline: uppercase at this size sits optically low beside
     mixed-case at 13px. */
  position: relative;
  top: -1px;
}
.sf-voicefilter__row:hover .sf-voicefilter__note,
.sf-voicefilter__row--on .sf-voicefilter__note { color: inherit; opacity: 0.75; }

/* The active row is marked on the left edge rather than by a fill: a filled
   row at this width reads as a heading for the rows under it. */
.sf-voicefilter__row--on {
  color: var(--sf-head);
  box-shadow: inset 3px 0 0 var(--sf-accent);
  padding-left: 9px;
}

.sf-voicefilter__n {
  font-size: var(--sf-size-9);
  font-weight: var(--sf-weight-label);
  color: var(--sf-muted3);
  flex-shrink: 0;
}
.sf-voicefilter__row--on .sf-voicefilter__n { color: var(--sf-accent-ink); }

/* Who a character turns up with. The face partial at `sm` brings its own
   avatar; this only has to lay out face, name and count. */
.sf-costars { display: grid; gap: 2px; }

.sf-costar {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: 9px;
  padding: 5px 0;
  border-bottom: 1px solid var(--sf-rule);
  color: var(--sf-text2);
}
.sf-costar:hover { color: var(--sf-accent-ink); }
.sf-costar .sf-face { pointer-events: none; }

.sf-costar__name { font-size: var(--sf-size-8); font-weight: var(--sf-weight-title); }
.sf-costar__n { font-size: var(--sf-size-9); font-weight: var(--sf-weight-label); color: var(--sf-muted3); }
.sf-costar:hover .sf-costar__n { color: var(--sf-accent-ink); }

/* Selected, on a cast-and-crew page where these rows filter rather than
   navigate. Marked exactly as the Role filter above it is — accent down the
   left edge — so the two controls in one rail read as one idea. A fill would
   make the row look like a heading for the rows beneath it. */
.sf-costar--on {
  color: var(--sf-head);
  box-shadow: inset 3px 0 0 var(--sf-accent);
  padding-left: 9px;
}
.sf-costar--on .sf-costar__n { color: var(--sf-accent-ink); }

/* "Everyone" has no face, so it needs the column the faces occupy closed up
   or its name would start hard against the rail's edge. */
.sf-costar--all { grid-template-columns: minmax(0, 1fr) auto; }

/* A heading inside a rail section — "Featured", "Bit parts" — lighter than the
   section label above it so the nesting is legible. */
.sf-label--sub {
  margin: 14px 0 4px;
  font-size: var(--sf-size-12);
  color: var(--sf-muted3);
}

/* ------------------------------------------------------------- sparkline -- */

/* A year per bar, sitting between the bio and the appearance list. Short and
   dense on purpose: it is a caption to the list under it, not the subject of
   its own section, and must not push that list off the first screen. */
.sf-spark {
  display: flex;
  align-items: flex-end;
  gap: 2px;
  height: 44px;
}

.sf-spark__bar {
  flex: 1 1 0;
  min-width: 2px;
  background: var(--sf-chart-ink);
  border-radius: 2px 2px 0 0;
}

/* A year in the run with no episodes. Drawn as a stub rather than left blank
   so the gap is legible as a gap and the bars stay on their year. */
.sf-spark__bar--none { background: var(--sf-chart-empty); }

.sf-spark__axis {
  display: flex;
  justify-content: space-between;
  margin-top: 5px;
  font-size: var(--sf-size-10);
  font-weight: var(--sf-weight-title);
  letter-spacing: 0.08em;
  color: var(--sf-muted3);
}

/* Heading and note with no underline: the pills or rows below already draw the
   eye down, and a second rule stacked on the section's own is noise. */
.sf-section__head--plain { border-bottom: 0; padding-bottom: 0; margin-bottom: 12px; }

/* ----------------------------------------------------------- voiced by -- */
/* A band under the appearances rather than a column beside them. The bar is
   the content: who played this part is nearly always a question about
   proportion — one actor for twenty years and three who covered a handful —
   and a bare list of counts leaves that arithmetic to the reader. */

.sf-voices {
  margin-top: 30px;
  padding: 20px 0 4px;
  border-top: 2px solid var(--sf-rule);
}

.sf-voices__row {
  display: grid;
  grid-template-columns: minmax(0, 13em) minmax(60px, 1fr) auto;
  align-items: center;
  gap: 14px;
  padding: 5px 0;
}

.sf-voices__track {
  height: 7px;
  border-radius: var(--sf-radius-xs);
  background: var(--sf-chart-empty);
  overflow: hidden;
}
.sf-voices__fill {
  display: block;
  height: 100%;
  border-radius: var(--sf-radius-xs);
  background: var(--sf-chart-ink);
}

.sf-voices__count {
  font-size: var(--sf-size-9);
  font-weight: var(--sf-weight-title);
  color: var(--sf-muted2);
  min-width: 3ch;
  text-align: right;
}

@media (max-width: 560px) {
  /* The bar is the first thing to go: a 60px track is decoration, and the
     name and the number still carry the whole answer. */
  .sf-voices__row { grid-template-columns: minmax(0, 1fr) auto; gap: 10px; }
  .sf-voices__track { display: none; }
}

/* --------------------------------------------------------------- avatars -- */

.sf-avatar {
  display: grid;
  place-items: center;
  border-radius: var(--sf-radius-portrait);
  background-size: cover;
  background-position: center top;
  font-weight: var(--sf-weight-display);
  color: rgba(255, 255, 255, 0.9);
  flex-shrink: 0;
}
/* The art inside an avatar. Fills the frame exactly as the background did —
   `cover` from the top, so a face is never cropped through the eyes — while
   still being an element the browser can defer. */
.sf-avatar__img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center top;
  border-radius: inherit;
  display: block;
}

.sf-avatar--sm { width: 26px; height: 26px; font-size: var(--sf-size-11); }
.sf-avatar--md { width: 34px; height: 34px; font-size: var(--sf-size-9); }
.sf-avatar--lg { width: 56px; height: 56px; font-size: var(--sf-album-title-sm); }

/* ---------------------------------------------------------------- drawer -- */

.sf-scrim { position: fixed; inset: 0; background: rgba(8, 5, 12, 0.72); z-index: 50; }

.sf-drawer {
  position: fixed;
  top: 0; right: 0; bottom: 0;
  width: min(560px, 100%);
  background: var(--sf-bg);
  border-left: 1px solid var(--sf-rule2);
  z-index: 51;
  display: flex;
  flex-direction: column;
}

.sf-drawer__bar {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 12px 16px;
  border-bottom: 1px solid var(--sf-rule);
  flex-wrap: wrap;
}

.sf-drawer__body { flex: 1; overflow-y: auto; padding: 22px 22px 40px; }

/* Square nav control — drawer back, prev/next episode. */
.sf-step {
  display: grid;
  place-items: center;
  width: 30px;
  height: 26px;
  border-radius: var(--sf-radius-sm);
  border: 1px solid var(--sf-rule);
  background: var(--sf-panel);
  font-size: var(--sf-size-8);
  font-weight: var(--sf-weight-title);
  color: var(--sf-text);
}
.sf-step:hover { border-color: var(--sf-accent-ink); color: var(--sf-head); }
.sf-step[aria-disabled="true"] {
  background: transparent;
  color: var(--sf-rule2);
  cursor: default;
  pointer-events: none;
}

/* ---------------------------------------------------------------- reader -- */

/* Prose and a rail. 260px, not the 224px this originally shipped with: the rail
   carries a 230px cover. The widening used to be a second `.sf-reader` rule
   four hundred lines further down, so the number here was never the one that
   applied. */
.sf-reader {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 260px;
  gap: var(--sf-space-2xl);
  align-items: start;
}

.sf-reader__header { border-top: 3px solid var(--sf-red-rule); padding-top: 18px; }
.sf-reader__kicker { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }

/* The episode number, which is how most people actually refer to an episode —
   set at 13px it read as a tag on the airdate rather than as the identifier. */
.sf-reader__epnum {
  font-family: var(--sf-display);
  font-size: var(--sf-row-title-lg);
  color: var(--sf-on-accent);
  background: var(--sf-accent);
  border-radius: var(--sf-radius-sm);
  padding: 2px 11px;
}
.sf-reader__airdate {
  font-size: var(--sf-size-9);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--sf-teal-ink);
}
/* A reader heading for a subject rather than a work — a character or a person,
   where the name is shorter and the page below it denser. */
.sf-reader__title--sm { font-size: var(--sf-size-3); }

/* The title inside a .sf-charhead, where the line under it is a count rather
   than a logline and needs to sit closer. */
.sf-reader__title--head { margin-bottom: var(--sf-space-xs); }

.sf-reader__title { font-size: var(--sf-size-2); line-height: 1.05; margin: 0 0 12px; max-width: 26ch; }

/* Logline card. Deliberately NOT max-width capped — it matches the summary
   column beneath it. */
/* The standfirst card sitting under a title rather than opening a page. */
.sf-logline--under { margin-top: var(--sf-space-md); }

/* A short fact riding in the reader's kicker beside the number and the date —
   a runtime, a reading time. Three templates each wrote their own three
   declarations for this, differing only in the ink. */
.sf-reader__fact { font-size: var(--sf-size-9); font-weight: var(--sf-weight-title); color: var(--sf-muted2); }
.sf-reader__fact--quiet { color: var(--sf-faint); }

/* The listener advisory, under an episode's title.
 *
 * A rule down the left rather than a full border or a filled panel: it sits
 * directly above the standfirst box, and two boxed blocks stacked would make
 * the top of the page read as two competing summaries. The rule marks it
 * without building a second container.
 *
 * The 12% wash is the same construction .sf-badge uses for its kind chips, so
 * the tint comes from --sf-pop-ink and both themes answer for it in one place.
 * Text is --sf-text2 rather than the red: red-on-red-wash is the kind of thing
 * that measures fine and reads badly, and the colour is already carried by the
 * rule and the dot. */
.sf-advisory {
  display: flex;
  align-items: baseline;
  gap: 9px;
  margin: 0 0 var(--sf-space-md);
  padding: 10px 14px;
  border-left: 3px solid var(--sf-pop-ink);
  border-radius: 0 var(--sf-radius-sm) var(--sf-radius-sm) 0;
  background: color-mix(in srgb, var(--sf-pop-ink) 12%, transparent);
  font-size: var(--sf-size-8);
  font-weight: var(--sf-weight-label);
  line-height: 1.45;
  color: var(--sf-text2);
  text-wrap: pretty;
}

/* "Listener advisory", set apart from the sentence that follows it.
   Uppercase and tracked rather than bold: the note itself is already 700, so a
   heavier label would not separate from it, and this is the same treatment the
   kickers elsewhere on the page use for a standing label. The red goes here
   rather than on the note, because a whole sentence in --sf-pop-ink beside a
   red rule and a red dot is three of the same signal. */
.sf-advisory__label {
  color: var(--sf-pop-ink);
  font-size: var(--sf-size-10);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  margin-right: 2px;
}

/* The same 5px dot the listing row uses, so the mark a reader met in the list
   is the mark they meet again on the page. `flex-shrink` because a baseline
   flex item with no content will otherwise collapse to nothing. */
.sf-advisory__dot {
  flex-shrink: 0;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--sf-pop-ink);
}

.sf-logline {
  background: var(--sf-panel);
  border: 1px solid var(--sf-panel2);
  border-radius: var(--sf-radius-lg);
  padding: 13px 16px;
}
.sf-logline p {
  font-size: var(--sf-size-6);
  line-height: 1.5;
  color: var(--sf-muted);
  margin: 0;
  font-style: italic;
  text-wrap: pretty;
}

.sf-prose { display: flex; flex-direction: column; gap: 18px; }

.sf-prose p {
  font-size: var(--sf-row-title-lg);
  line-height: 1.7;
  color: var(--sf-text);
  margin: 0;
  text-wrap: pretty;
}

/* The reader's synopsis, set smaller than body prose.

   Its own selector rather than a rule on `.sf-prose`, because the reader also
   renders the transcript through that class and the two are different kinds of
   text.

   15px, up from 14. The synopsis is the body of the episode page and runs to
   5,200 characters at the longest, which is a lot of reading to do a pixel
   under comfortable.

   The generous 1.75 stays. It was set that way because 14px at 1.7 tracked
   badly over that length, and while a larger size needs proportionally less
   leading, the measure here is 68ch — long lines want the extra air more than
   the bigger type wants it back.

   `.sf-prose--note` used to share this rule, for the album blurb. That blurb
   moved to `.sf-logline` with the rest of the album reader and the class is now
   in no template, so it has gone rather than being carried as a second selector
   nothing can match. */
.sf-prose--synopsis p {
  font-size: var(--sf-size-6);
  line-height: 1.75;
}

/* Character/person mentions linked inside prose. */
.sf-prose a {
  color: var(--sf-text);
  border-bottom: 2px solid rgba(255, 77, 94, 0.55);
  font-weight: var(--sf-weight-title);
}
.sf-prose a:hover { color: var(--sf-head); border-bottom-color: var(--sf-accent-ink); }

/* The `.sf-quote` block is gone with the episode page's "Worth quoting"
   section. Nothing supplied it: no controller passed `$quotes` and the
   `episode_quote` table it was written against is not in this database, so
   neither the markup nor these rules could render on any input. */

/* ----------------------------------------------------------------- admin -- */

.sf-field { width: 100%; margin-bottom: var(--sf-space-lg); }
.sf-field__head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.sf-field__input { width: 100%; }

.sf-textarea {
  width: 100%;
  font-family: inherit;
  font-size: var(--sf-row-title-md);
  line-height: 1.7;
  color: var(--sf-text);
  background: var(--sf-panel);
  border: 1px solid var(--sf-pop-ink);
  border-radius: var(--sf-radius-lg);
  padding: 14px 16px;
  resize: vertical;
}

.sf-badge-edited {
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--sf-on-accent);
  background: var(--sf-pop-ink);
  border-radius: var(--sf-radius-xs);
  padding: 2px 6px;
}

/* Internal-only content — the synopsis. The violet is the site's fourth ink,
   reserved for editorial material that never reaches a reader. */
.sf-badge-edited--internal { background: var(--sf-violet); color: #fff; }

/* ------------------------------------------------------------- utilities -- */

.sf-truncate { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sf-tnum { font-variant-numeric: tabular-nums; }

.sf-visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

/* ------------------------------------------------------------ responsive -- */

@media (max-width: 900px) {
  /* Narrower gutter down here. Set on :root rather than on .sf-page, because
     .sf-controlbar reads the same token to cancel it — declared on the page it
     would not be in scope for the bar's negative margin and the background
     would overshoot by 10px on each side. */
  :root { --sf-page-pad: 16px; }

  .sf-reader { grid-template-columns: minmax(0, 1fr); gap: 26px; }
  .sf-reader__title { font-size: var(--sf-size-3); }
  .sf-page { padding: 22px var(--sf-page-pad) 70px; }
  .sf-shelf { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 20px 14px; }

  /* No room for a third column, so the date drops under the title. It becomes
     one line rather than two: the airdate and the runtime were stacked because
     they were sharing a narrow right-hand column, and down here they have the
     whole width. Two lines of small print per row is a third of the row's
     height spent on the least interesting thing in it. */
  .sf-eprow {
    grid-template-columns: minmax(0, 1fr);
    gap: 12px;
    padding: 9px 10px;
  }

  /* The sleeve stays on a phone, smaller.
   *
   * It is worth more here than on a desktop, not less: the rows are denser and
   * the date has moved under the title, so the artwork is the only thing giving
   * one row a different shape from the next. 38px against a 44px number leaves
   * roughly 250px for the title at 390px wide, which is where it was before
   * once the old 52px number column is accounted for.
   *
   * The date has to be told which column it is in — the rule above puts it in
   * the second, which is now the picture. */
  .sf-eprow--art {
    grid-template-columns: 52px minmax(0, 1fr);
    gap: 10px;
  }
  .sf-eprow__tile { width: 52px; height: 52px; }
  .sf-eprow__chip { width: 20px; height: 20px; left: -4px; bottom: -4px; }

}

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

/* ==========================================================================
   SUPPLEMENT — components the delivered extract does not cover
   --------------------------------------------------------------------------
   app.css covers the pieces that repeat. These are the remaining parts of the
   prototype the PHP build needs: the marquee stripe, the search panel, the
   filter bars, the tally block, content flags, footer and pager.

   Values come only from the tokens above — no new hex, no second palette.
   ========================================================================== */

/* -------------------------------------------------------------- skip link -- */

.sf-skip {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 100;
  padding: 10px 16px;
  background: var(--sf-panel);
  color: var(--sf-head);
  font-weight: var(--sf-weight-display);
  border-radius: 0 0 var(--sf-radius) 0;
}
.sf-skip:focus { left: 0; }

/* ------------------------------------------------------------ marquee bar -- */

/* The candy stripe along the top of the header, and its quieter echo in the
   footer. Red/cream is fixed in both themes: it is the diner sign, not a
   themed surface. */
.sf-header__stripe {
  height: 5px;
  background: repeating-linear-gradient(90deg, #FF4D5E 0 22px, #F6F1EA 22px 44px);
}

.sf-header__inner { flex-wrap: wrap; }

/* The cup is standing in for the letter O, so it is sized off the font's own O
   rather than a round pixel value. Alfa Slab One is 1000 units to the em and
   draws both O and S from y -14 to y 792: 806 units tall, overshooting D's cap
   line and the baseline by 14 apiece. The ring's outer edge spans 38 of the
   SVG's 40 viewBox units, so the box has to be 40/38 of that height for the
   ring itself to come out at 806.

   The negative bottom margin is that overshoot. Flex baseline alignment sets
   the bottom margin edge on the baseline, so pulling the margin up by 14/1000em
   drops the ring the same fraction below it that the S already goes. */
.sf-wordmark__cherry {
  width: calc(0.806em * 40 / 38);
  height: calc(0.806em * 40 / 38);
  margin: 0 -1px -0.014em;
}
.sf-wordmark__fountain { color: var(--sf-red-rule); letter-spacing: 0.09em; margin-left: 8px; }

.sf-wordmark__tag {
  display: block;
  font-size: var(--sf-size-12);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.28em;
  text-transform: uppercase;
  color: var(--sf-accent-ink);
  margin-top: 5px;
}

/* ----------------------------------------------------------------- search -- */

/* A small flex-basis with grow, not a large one: flexbox decides where to break
   from the flex-basis, before it shrinks anything, so the original 320px basis
   forced the nav onto a second row even though the field would sit happily at
   260px. 200px never causes the break, and flex-grow still lets the field take
   whatever the wordmark and the nav leave behind. */
.sf-search {
  flex: 1 1 200px;
  min-width: 170px;
  max-width: 460px;
  position: relative;
}

.sf-search__field {
  width: 100%;
  height: 46px;
  padding: 0 18px 0 44px;
  border: 2px solid var(--sf-rule2);
  border-radius: var(--sf-radius-pill);
  background: var(--sf-panel);
  color: var(--sf-text);
  font-size: var(--sf-size-6);
  font-weight: 600;
  outline: none;
}
.sf-search__field:focus { border-color: var(--sf-accent-ink); }

/* Squared-off variant for the in-page filter bars. */
.sf-search__field--flat {
  height: 38px;
  padding: 0 14px;
  border-radius: var(--sf-radius);
  font-size: var(--sf-size-7);
}

.sf-search__icon { position: absolute; left: 16px; top: 14px; width: 18px; height: 18px; pointer-events: none; }

.sf-search__panel {
  position: absolute;
  top: 54px;
  left: 0;
  right: 0;
  z-index: 45;
  background: var(--sf-panel);
  border: 2px solid var(--sf-rule2);
  border-radius: var(--sf-radius-lg);
  box-shadow: var(--sf-shadow-pop);
  overflow-y: auto;
  max-height: 460px;
}
.sf-search__panel[hidden] { display: none; }

.sf-search__heading { padding: 10px 16px 6px; }

.sf-search__hit { display: flex; align-items: center; gap: 12px; padding: 9px 16px; color: var(--sf-text); }
.sf-search__hit:hover { background: var(--sf-panel2); color: var(--sf-text); }

.sf-search__title { display: block; font-weight: var(--sf-weight-title); font-size: var(--sf-size-6); line-height: 1.2; }
.sf-search__sub { display: block; font-size: var(--sf-size-9); color: var(--sf-muted); margin-top: 1px; }

.sf-search__empty { padding: 22px 16px; text-align: center; color: var(--sf-muted); font-size: var(--sf-size-7); font-weight: var(--sf-weight-label); }

.sf-search__more {
  display: block;
  width: 100%;
  padding: 12px;
  border: 0;
  border-top: 2px solid var(--sf-panel2);
  background: var(--sf-panel);
  color: var(--sf-accent-ink);
  font-weight: var(--sf-weight-display);
  font-size: var(--sf-size-8);
}

/* Square badge marking what kind of thing a search hit is. */
.sf-badge {
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  border-radius: var(--sf-radius-sm);
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.04em;
  flex-shrink: 0;
}
.sf-badge--episode   { background: color-mix(in srgb, var(--sf-pop-ink) 16%, transparent);  color: var(--sf-pop-ink); }
.sf-badge--album     { background: color-mix(in srgb, var(--sf-accent) 16%, transparent);   color: var(--sf-accent-ink); }
.sf-badge--character { background: color-mix(in srgb, var(--sf-teal-ink) 16%, transparent); color: var(--sf-teal-ink); }
.sf-badge--person    { background: var(--sf-panel2); color: var(--sf-text2); }

/* An era name and an age marker on a billing line.
 *
 * Both are quiet on purpose. A cast list is read by scanning the left column
 * for a name, and anything loud enough to stop that scan costs more than the
 * annotation is worth — these are footnotes, not corrections.
 *
 * The era name gets a dotted underline, the convention for "there is more to
 * this word", and keeps the ordinary link colour: the name shown IS the right
 * name for that episode, so styling it as an exception would be a lie. */
.sf-billing__char--era {
  border-bottom: 1px dotted var(--sf-rule2);
}

/* The name and its badge, sharing one cell of .sf-billing__row's fixed grid.
   Without this wrapper the badge becomes an implicit second grid row and sits
   under the name rather than beside it. */
.sf-billing__name {
  display: inline-flex;
  align-items: baseline;
  gap: 6px;
  min-width: 0;
}

/* The age badge, on the character's name rather than the actor's: the credit
   and the character are both correct, and what it qualifies is WHICH self of
   that character this episode shows. */
.sf-billing__age {
  padding: 1px 6px;
  border: 1px solid var(--sf-rule2);
  border-radius: var(--sf-radius-pill);
  color: var(--sf-muted3);
  font-size: var(--sf-size-12);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  white-space: nowrap;
  vertical-align: 1px;
}

/* A character's name inside the synopsis, linked to their page.
 *
 * Keeps the body colour and marks itself with a thick cherry underline rather
 * than becoming a coloured word: a synopsis names a dozen people, and a dozen
 * coloured words is a page that reads as a link farm instead of as prose. The
 * weight does most of the work; the rule underneath confirms it is a link.
 *
 * border-bottom, not text-decoration, so it sits a couple of pixels lower and
 * clears the descenders on names like "Jason" and "Wooton". Both colours are
 * custom properties that flip with the theme, so Daylight needs no rule of its
 * own. */
.sf-prose__char {
  color: var(--sf-text);
  border-bottom: 2px solid rgba(255, 77, 94, 0.55);
  font-weight: var(--sf-weight-title);
  text-decoration: none;
}
.sf-prose__char:hover {
  color: var(--sf-head);
  border-bottom-color: var(--sf-accent-ink);
}

/* Names a character also went by, on the character page. */
.sf-alsoknown {
  margin-top: 10px;
  color: var(--sf-muted3);
  font-size: var(--sf-size-8);
}
.sf-alsoknown__name { color: var(--sf-text); }

/* --------------------------------------------------------------- controls -- */

/* The filter/sort bar that sits above a list. */
.sf-controls {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  padding: 12px 0 16px;
  border-top: 1px solid var(--sf-rule);
  border-bottom: 1px solid var(--sf-rule);
  margin-bottom: 18px;
}
.sf-controls--open { border-bottom: 0; margin-bottom: 8px; }

/* A second or third row of the same bar: it inherits the rule from the row
   above rather than drawing its own, which would double the hairline.
   Was `style="border-top: 0"` on six separate rows. */
.sf-controls--stacked { border-top: 0; }

.sf-controls__spacer { flex: 1 1 20px; }

/* The search field that opens a control bar. One width for all five lists —
   they were 220px, 240px and 260px, which nobody chose and nobody can see. */
.sf-controls__search { flex: 1; min-width: 240px; }

/* A rule between two groups in one row — the loglines switch and the pills
   after it, or Cast and the crew jobs. Set apart from .sf-controls__spacer,
   which pushes things to opposite ends rather than separating neighbours. */
.sf-controls__divider {
  width: 1px;
  align-self: stretch;
  margin: 2px var(--sf-space-2xs);
  background: var(--sf-rule2);
}

/* Both control rows pinned under the site header.
 *
 * Sticky, not fixed: it stays in the flow, so the list below it starts where it
 * looks like it starts and nothing has to be padded down by a magic number.
 *
 * `top` is the header's own height. The fallback is close to right, and app.js
 * measures the real thing — the header wraps to two rows on a narrow window,
 * where a fixed 76px would leave the bar overlapping it.
 *
 * An opaque background is not optional here: without it the episode rows scroll
 * through the pills. `--sf-bg` rather than the header's translucent glass,
 * because two blurred layers over each other read as a smear. */
.sf-controlbar {
  position: sticky;
  top: var(--sf-header-h, 76px);
  z-index: 30;              /* under the header's 40, over the rows */
  background: var(--sf-bg);
  margin-bottom: 18px;

  /* Out to the page's edge and back again.
     The bar is inside `.sf-page`, so its opaque background used to stop at the
     gutter and the covers scrolled up through the 26px either side of it — two
     hard vertical edges, obvious in Daylight where the artwork is darker than
     the paper. Cancelling the gutter with a negative margin and putting it back
     as padding moves the background out without moving the controls. */
  margin-left: calc(var(--sf-page-pad) * -1);
  margin-right: calc(var(--sf-page-pad) * -1);
  padding-left: var(--sf-page-pad);
  padding-right: var(--sf-page-pad);
}

/* What used to be a shadow.
   `0 6px 12px -10px rgba(0,0,0,.55)` is a hairline in the dark, where it only
   has to separate two dark surfaces. On cream it reads as a drawn grey rule
   with a hard top edge. A short gradient of the page's own background does the
   same job by making the thing underneath fade out rather than stop, and it
   costs nothing in either theme because it is the background colour either
   way. */
.sf-controlbar::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  top: 100%;
  height: 18px;
  pointer-events: none;
  background: linear-gradient(to bottom, var(--sf-bg), transparent);
}

.sf-controlbar .sf-controls { margin-bottom: 0; }

/* The rows carry their own rules. The bar's own edge is the gradient above,
   not a shadow — see .sf-controlbar::after. */

/* A smaller pill, for the dense filter rows. */
.sf-pill--sm { height: 30px; padding: 0 12px; font-size: var(--sf-size-11); letter-spacing: 0.1em; gap: 6px; }

/* ------------------------------------------------------- folding filters -- */

/* Above the breakpoint the wrapper is not there: `display: contents` drops its
   box and its children become flex items of the row they were written into, so
   a desktop bar lays out exactly as it did before it could fold. */
.sf-controlbar__fold { display: contents; }

/* The button that opens the fold. Off the page entirely on a desktop, where
   there is nothing folded to open. */
.sf-filters { display: none; }

/* The search submit shows its word by default and hides the magnifier; the two
   swap below the breakpoint. Declared here rather than only in the media query
   so the icon is hidden on a wide screen even if the stylesheet is served to
   something that ignores the query. */
.sf-controls__submiticon { display: none; }

/* How many axes are narrowing the list. A bar that can hide its own filters
   must not be able to hide the fact that it has some — otherwise "why is this
   list so short" has no answer on screen. */
.sf-filters__n {
  display: inline-grid;
  place-items: center;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  border-radius: var(--sf-radius-pill);
  /* The fill, not the ink — 9px of white on the bright red was 3.24:1. See the
     --sf-pop-fill token. This one only paints below 860px, which is why a
     desktop sweep never caught it. */
  background: var(--sf-pop-fill);
  color: #fff;
  font-size: var(--sf-size-12);
}
.sf-filters[aria-expanded="true"] { border-color: var(--sf-accent-ink); color: var(--sf-head); }

/* Below the breakpoint the fold becomes a real box — one full-width row of the
   bar — and the button that opens it appears. */
@media (max-width: 860px) {
  .sf-controlbar__fold { display: block; flex: 1 0 100%; }
  .sf-filters { display: inline-flex; }

  /* Collapsed. Keyed off a class only the head script sets, so with no
     JavaScript the fold never closes and every filter stays reachable — the
     button is the enhancement, not the mechanism. */
  .sf-narrow .sf-controlbar:not([data-filters="open"]) .sf-controlbar__fold { display: none; }

  /* The search field and the two buttons share one line; the field takes what
     is left. A 240px floor would force a wrap on a 390px screen. */
  .sf-controls__search { min-width: 120px; }

  /* Submit becomes a magnifier here — see partial/filter-form. The word and the
     icon swap rather than both existing, so the button is one thing wide at any
     width and never briefly both. */
  .sf-controls__submitword { display: none; }
  .sf-controls__submiticon { display: block; }

  /* Rows inside the fold sit closer together than they do spread across a
     desktop bar — opened, they are a stack, not a row of rows. */
  .sf-controlbar__fold .sf-controls { padding: 8px 0; gap: 6px; }
  .sf-controlbar__fold .sf-controls:last-child { padding-bottom: 12px; }

  /* The fold is a block here rather than `display: contents`, so the pills
     inside it sit in an inline formatting context instead of a flex row. A
     divider written for that flex row paints nothing: `align-self: stretch` has
     no flex line to stretch against, and an inline box ignores `width`, so the
     rule between Collection and Only vanished at exactly the width where the
     two axes wrap into each other and need it most. An explicit inline-block
     box gives it back.

     Direct children only. The stacked rows are wrapped in a fold of their own,
     but each row is still `display: flex`, so the dividers down there — the
     loglines switch, the Cast & Crew job rule — already stretch correctly and
     must not be re-boxed. Only the search row's fold puts a divider straight
     into an inline context. */
  .sf-controlbar__fold > .sf-controls__divider {
    display: inline-block;
    width: 1px;
    height: 18px;
    vertical-align: middle;
    margin: 0 var(--sf-space-2xs);
  }

  /* The cover-size slider sets the shelf grid's minimum track width. At two or
     three columns on a phone there is nothing for it to do, and it was taking a
     row of the bar to do it. */
  .sf-range,
  .sf-label:has(+ .sf-range) { display: none; }
}

/* ------------------------------------------------------------------ intro -- */

.sf-intro {
  display: grid;
  /* `min(320px, 100%)`, not a bare 320px.
   *
   * A bare minimum in minmax() is exactly that — a floor the track will not go
   * below even when its container is narrower, so at a 320px viewport this grid
   * kept asking for a 320px column inside a 273px content box and pushed the
   * page 16px wider than the screen. That is WCAG 1.4.10 Reflow: content must
   * not need horizontal scrolling at 320px.
   *
   * Wrapping the floor in min() with 100% says "320px, unless that is more than
   * I have, in which case all of it". Nothing changes at any width where the
   * column already fitted. Same fix, same reason, on .sf-charts and .sf-haul. */
  grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
  gap: 30px;
  /* Start, not end: the tallies hang off the headline rather than sitting on
     the deck's last line, which left them floating at whatever height the
     randomised deck happened to wrap to.

     No nudge needed to make the type line up. The headline's cap sits 4.34px
     below its box top (52px Alfa Slab One at line-height 1.02: -9.08px of half
     leading, then 0.258em from ascent to cap line) and a tally's number sits
     3.86px below its own (2px of padding, then the same sum at 26px). Within
     half a pixel, so the numbers and the headline share a cap line and the
     coloured rules hang a touch above it, which is what a hanging rule does. */
  align-items: start;
  margin-bottom: 30px;
}

/* The heading every list page opens with. Was `style="font-size: 34px"`
   repeated on nine templates, which is nine places to forget. */
.sf-page__title {
  font-family: var(--sf-display);
  font-size: var(--sf-size-3);
  line-height: 1.1;
  color: var(--sf-head);
  margin: 0 0 18px;
}
.sf-page__title--tight { margin-bottom: var(--sf-space-2xs); }

/* Standing alone above a page of charts rather than above a control bar, where
   the default 18px reads as cramped. Was `style="margin-bottom: 26px"`. */
.sf-page__title--loose { margin-bottom: 26px; }

/* Inside a centred album header, where the shelf mark above supplies the gap. */
.sf-page__title--flush { margin: var(--sf-space-2xs) 0 0; }

.sf-intro__title { font-size: var(--sf-size-1); line-height: 1.02; margin: 0 0 14px; }
.sf-intro__title em { font-style: normal; color: var(--sf-accent-ink); }

/* The 14px is the gap to the deck. With no deck under it, it is 14px of nothing
   hanging off the bottom of the row, dragging the headline up away from the
   tallies it is supposed to share a cap line with. Keyed off the deck's
   presence rather than a modifier class, so it comes back on its own. */
.sf-intro__title:not(:has(~ .sf-intro__lede)) { margin-bottom: 0; }

.sf-intro__lede {
  font-size: var(--sf-row-title-md);
  line-height: 1.55;
  color: var(--sf-muted);
  margin: 0;
  max-width: 48ch;
  text-wrap: pretty;
}

/* The same lede reporting a search result above a list, where it needs air
   under it before the control bar. Was `style="margin-bottom: 14px"` on four
   pages, one of which had drifted to 24px. */
.sf-intro__lede--result { margin-bottom: var(--sf-space-md); }

/* Tally block: a red rule, a slab number, a caption. */
.sf-tallies { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; }
.sf-tally { border-left: 3px solid var(--sf-pop-ink); padding: 2px 0 2px 12px; }
.sf-tally__value { font-family: var(--sf-display); font-size: var(--sf-size-figure); line-height: 1; color: var(--sf-head); }

/* ---------------------------------------------------------------- figures -- */

/* The strip of figure cards above a page of charts.
 *
 * Also formerly two rules: the first drew a hairline grid — 1px gaps over a
 * ruled background — and the second widened the gap to 12px and gave the cards
 * their own borders, which made the hairline construction moot while leaving
 * its background and overflow behind. Merged to the cascade's actual result. */
.sf-figures {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: var(--sf-space-sm);
  background: var(--sf-rule);
  border: 1px solid var(--sf-rule);
  border-radius: var(--sf-radius-lg);
  overflow: hidden;
  margin-bottom: 34px;
}

/* ------------------------------------------------------------------- rows -- */

.sf-rows { display: flex; flex-direction: column; }

/* ---------------------------------------------------------------- avatars -- */

.sf-face { display: inline-flex; align-items: center; gap: 7px; min-width: 0; }
.sf-face__name { font-size: var(--sf-size-9); font-weight: var(--sf-weight-title); color: var(--sf-text2); }

/* An actor name is teal so it reads as a different kind of thing from a
   character name, in both themes. */
.sf-actor { color: var(--sf-teal-ink); font-weight: var(--sf-weight-title); }
.sf-actor:hover { color: var(--sf-teal-ink-hi); }

/* ----------------------------------------------------------------- crumbs -- */

.sf-crumbs {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  padding: 14px 0;
  font-size: var(--sf-size-9);
  font-weight: var(--sf-weight-title);
  color: var(--sf-muted2);
}
.sf-crumbs__sep { color: var(--sf-rule2); }
.sf-crumbs__steps { display: flex; align-items: center; gap: 6px; margin-left: auto; }

/* The page you are on, at the end of the trail.
   Five templates rendered it as a bare <span>, which inherited the bar's muted
   colour, while album-read rendered it as a link and came out bright — so the
   same crumb was two different colours depending which page you were reading.
   `--sf-head` rather than a literal white: it is #FFFFFF in the dark theme and
   near-black in the light one, so this reads correctly in both. */
.sf-crumbs__here { color: var(--sf-head); }

/* The position readout between the arrows.
   Was `--sf-tick` — the faintest token, since renamed `--sf-faint` and lifted
   to 4.8:1 for exactly the reason this rule exists. Against the crumb bar it
   was under 2:1, well below the 4.5:1 that WCAG 1.4.3 asks of text this size.
   It now takes the same colour as the arrows it sits between, which is what it
   always looked like it should have been, so it stays where it is. */
.sf-crumbs__position { font-size: var(--sf-size-10); font-weight: var(--sf-weight-title); color: var(--sf-text); padding: 0 2px; }

/* ------------------------------------------------------------------- grid -- */

/* Generic card grid: characters, cast, roles, co-stars. */
.sf-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--sf-grid-min, 230px), 1fr)); gap: 8px 18px; }

/* Narrower tracks, for rows that are a name and a number rather than a card. */
.sf-grid--tight { --sf-grid-min: 220px; }

/* One row of that grid: a name on the left, a count on the right, a hairline
   under. Baseline-aligned rather than centred — the two are different sizes,
   and centring them makes the count sit visibly high beside the name. */
.sf-grid__item {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  padding: var(--sf-space-xs);
  border-bottom: 1px solid var(--sf-rule);
  color: var(--sf-text);
}
.sf-grid__item:hover { background: var(--sf-panel); color: var(--sf-text); }

.sf-grid__name { font-size: var(--sf-size-8); font-weight: var(--sf-weight-title); line-height: 1.25; }
.sf-grid__count {
  flex-shrink: 0;
  font-size: var(--sf-size-10);
  font-weight: var(--sf-weight-title);
  color: var(--sf-muted2);
}

/* ----------------------------------------------------------------- footer -- */

.sf-footer {
  border-top: 1px solid var(--sf-rule);
  background: repeating-linear-gradient(90deg, var(--sf-stripe) 0 1px, transparent 1px 8px), var(--sf-bg);
}
.sf-footer__stripe { height: 4px; background: repeating-linear-gradient(90deg, var(--sf-rule2) 0 22px, var(--sf-bg) 22px 44px); }
.sf-footer__inner {
  max-width: var(--sf-page);
  margin: 0 auto;
  padding: 26px;
  display: flex;
  gap: 24px;
  /* Centred. This was space-between for two children — the colophon on the
     left and a "By the numbers" link on the right — and once that link went,
     space-between had one item to distribute and simply left it hard against
     the left edge of a 1400px page. */
  justify-content: center;
  text-align: center;
  flex-wrap: wrap;
  font-size: var(--sf-size-9);
  color: var(--sf-muted3);
  line-height: 1.6;
}

/* ------------------------------------------------------------------- misc -- */

.sf-empty { padding: 60px 20px; text-align: center; color: var(--sf-muted); font-weight: var(--sf-weight-label); }

.sf-pager { display: flex; gap: 8px; align-items: center; justify-content: center; margin-top: 30px; flex-wrap: wrap; }

/* ----------------------------------------------------------------- drawer -- */

.sf-drawer[hidden], .sf-scrim[hidden] { display: none; }

/* No JavaScript means no drawer. Every link is a real page, so there is
   nothing to fall back to; it just stays out of the way. */
.no-js .sf-drawer, .no-js .sf-scrim { display: none !important; }

@media (max-width: 900px) {
  .sf-intro__title { font-size: var(--sf-size-2); }
}

/* ---------------------------------------------------------- content flags -- */

/* --------------------------------------------------------- content flags -- */

/* Flags are emitted only when the content exists, so they need no "off" state.
   The colour lives in the dot; the label stays muted in both themes. */

.sf-flags { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }

.sf-flag {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--sf-muted2);
  white-space: nowrap;
}

/* One dot per kind of content. Grey when unset, which is the whole of the admin
   index's answer: an editor is looking for what is still blank. The colours
   used to be written inline by the two templates that emit these, one of them
   as a bare #B48BFF that no theme could reach. */
.sf-flag__dot {
  width: 5px;
  height: 5px;
  border-radius: 50%;
  flex-shrink: 0;
  background: var(--sf-rule2);
}
/* The listener advisory. Red because it is the one flag that is a warning
   rather than an inventory of what the episode contains.

   It shares --sf-pop-ink with the transcript dot, which is the only doubling in
   the set. They are told apart by the word beside them, and the two never
   compete for meaning: fifteen episodes carry an advisory against a thousand
   with a transcript, so a red dot in a list is almost always Transcript and the
   rare one that is not has "Advisory" written next to it. Giving the warning a
   sixth hue would have cost more than it bought. */
.sf-flag__dot--advisory   { background: var(--sf-pop-ink); }
.sf-flag__dot--quotes     { background: var(--sf-accent); }
.sf-flag__dot--skit       { background: var(--sf-violet); }
.sf-flag__dot--transcript { background: var(--sf-pop-ink); }
.sf-flag__dot--logline    { background: var(--sf-teal-ink); }
.sf-flag__dot--summary    { background: var(--sf-accent); }
.sf-flag__dot--synopsis   { background: var(--sf-violet); }

/* ------------------------------------------------------------ row meta -- */

/* The middle column: title, logline and the meta line, all of which have to be
   allowed to shrink below their content width or a long title blows the grid
   out. */
/* The text column: eyebrow, title, logline. Flex rather than three margins,
   so the rhythm is one number and the row cannot drift as parts appear and
   disappear. `min-width: 0` because a long title would otherwise blow the
   grid out. */
.sf-eprow__body {
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 4px;
}

/* Runtime, beside the airdate rather than under it.
 *
 * Outlined rather than plain, and violet rather than grey: the eyebrow is a
 * strip of three kinds of fact, and an outline is what separates the measured
 * one from the date beside it without needing a third bright colour. */
.sf-eprow__runtime {
  display: inline-block;
  padding: 1px 8px;
  border: 1px solid color-mix(in srgb, var(--sf-violet) 45%, transparent);
  border-radius: var(--sf-radius-pill);
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.06em;
  color: var(--sf-violet);
  white-space: nowrap;
}

/* A runtime nobody has measured — nine episodes. Html::duration() answers with
   an em dash, which inside a pill reads as a value rather than an absence, so
   the row says it in words and drops the colour with the claim. */
.sf-eprow__runtime--none {
  border-color: var(--sf-rule2);
  color: var(--sf-faint);
  font-weight: var(--sf-weight-label);
  font-style: italic;
  letter-spacing: 0;
}

/* The eyebrow: airdate, runtime and flags, above the title.
 *
 * It used to sit under the logline holding faces and an album attribution,
 * both of which are gone. What is left is the two facts that were pinned to
 * the row's right-hand edge, brought back into the text column where there is
 * no gap for them to strand themselves in. */
.sf-eprow__meta {
  display: flex;
  align-items: center;
  gap: 9px;
  flex-wrap: wrap;
}
/* Matched to the eyebrow's own gap — the flags arrive as one flex child with a
   12px gap of their own, which read as a wider space than the separators. */
.sf-eprow__meta .sf-flags { gap: 9px; }


/* -------------------------------------------------------------- groups -- */

/* A gold rule above each section, running the full width of the list — so the
   line grows with the content rather than stopping at the text. Above rather
   than below, so an expanded section's rows sit inside the band its heading
   opened rather than under a line that belongs to the next one. */
.sf-group { border-top: 1px solid var(--sf-accent); }
.sf-group:last-child { border-bottom: 1px solid var(--sf-accent); }

.sf-group > .sf-rows { padding-bottom: 14px; }

.sf-group__head {
  display: flex;
  align-items: center;
  gap: 14px;
  width: 100%;
  padding: 12px 8px;
  cursor: pointer;
  color: var(--sf-text);
  list-style: none;          /* Firefox */
}
.sf-group__head::-webkit-details-marker { display: none; }
.sf-group__head:hover { color: var(--sf-head); }
.sf-group__head:hover .sf-group__title { color: var(--sf-accent-ink); }

.sf-group__text { flex: 1; min-width: 0; text-align: left; }

.sf-group__title {
  display: block;
  font-family: var(--sf-display);
  font-size: var(--sf-size-4);
  line-height: 1.15;
  color: var(--sf-head);
}

/* Small caps under the title — the shelf mark or the date span. Set as a label
   rather than as prose: it identifies the section, it is not read. */
.sf-group__sub {
  display: block;
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: var(--sf-muted2);
  margin-top: 4px;
}

/* Was a second copy of .sf-chip-count with a different padding and weight.
   One badge, one definition — see .sf-chip-count. */
.sf-group__count { white-space: nowrap; }

.sf-group__chevron {
  width: 16px;
  text-align: center;
  font-size: var(--sf-size-7);
  color: var(--sf-accent-ink);
  transition: transform 120ms ease;
}
/* The header is a link with aria-expanded, not a <details> toggle — <details>
   renders every row whether or not the section is open, so the grouped views
   were shipping all 1,080 rows to show a hundred headings. The open set lives
   in the URL instead, and the chevron follows the attribute. */
.sf-group__head[aria-expanded="true"] .sf-group__chevron { transform: rotate(90deg); }

@media (prefers-reduced-motion: reduce) {
  .sf-group__chevron { transition: none; }
}

/* Rows inside a group are already inset by the group padding. */
.sf-group .sf-eprow { align-items: start; }

/* ------------------------------------------------------- listing lines -- */

/* The compact form: an episode listed inside something else. Number, title,
   dotted leader, date — a track listing, not a browsing row. */

.sf-lines { display: flex; flex-direction: column; }

.sf-line {
  display: grid;
  grid-template-columns: 46px auto 1fr auto;
  align-items: baseline;
  gap: 10px;
  padding: 9px 4px;
  border-bottom: 1px solid var(--sf-rule);
  color: var(--sf-text);
}
.sf-line:hover { color: var(--sf-head); }
.sf-line:hover .sf-line__title { color: var(--sf-accent-ink); }
.sf-line:last-child { border-bottom: 0; }
/* An album's track listing drops the dividers; the rows are the album's own
   contents rather than a list of separate things. */
.sf-lines--plain .sf-line { border-bottom: 0; }

.sf-line__num {
  font-family: var(--sf-display);
  font-size: var(--sf-size-9);
  color: var(--sf-pop-ink);
  font-variant-numeric: tabular-nums;
}

/* The admin's edit log borrows this row for something that is not an episode
   listing: a field name, a date and a sentence. Wider first column, and the
   first cell holds a word rather than an episode number, so it drops the slab
   face that makes numbers read as numbers. */
.sf-line--audit { grid-template-columns: 90px 110px 1fr; }
.sf-line__num--word { font-family: var(--sf-body); font-size: var(--sf-size-10); }
.sf-line__title--plain { font-weight: var(--sf-weight-label); }
.sf-line__aside { color: var(--sf-muted3); }

.sf-line__title { font-size: var(--sf-size-6); font-weight: var(--sf-weight-label); }

/* The dotted rule between title and date, as on a printed contents page. */
.sf-line__leader {
  border-bottom: 1px dotted var(--sf-rule2);
  transform: translateY(-3px);
  min-width: 12px;
}

.sf-line__date { font-size: var(--sf-size-9); font-weight: var(--sf-weight-label); color: var(--sf-muted2); white-space: nowrap; }

/* The optional trailing column — who played the part in this episode. Quieter
   than the title, so scanning the list still follows the titles. */
.sf-line--noted { grid-template-columns: 46px auto 1fr minmax(0, 13em) auto; }
.sf-line--noted.sf-line--dateless { grid-template-columns: 46px auto 1fr auto; }
.sf-line--dateless { grid-template-columns: 46px auto 1fr; }
/* Same voice as the actor names in an episode's billing — `--sf-teal-ink` is a
   theme token, so it follows light and dark rather than being pinned to one.
   Not an `<a>`: the whole row is already a link to the episode, and an anchor
   inside an anchor is not a thing. Splitting the row into two links so this
   could be one was tried and reverted — see partial/episode-line.php. */
.sf-line__note {
  font-size: var(--sf-size-8);
  font-weight: var(--sf-weight-title);
  color: var(--sf-teal-ink);
  text-align: right;
}

@media (max-width: 720px) {
  /* The actor is the first thing to drop: the row still answers which episode
     it is, and the filter pills above already say who is in the list. */
  .sf-line--noted { grid-template-columns: 46px auto 1fr auto; }
  .sf-line--noted.sf-line--dateless { grid-template-columns: 46px auto 1fr; }
  .sf-line__note { display: none; }
}

/* The leader goes, and the title takes the room it was filling.
 *
 * Every variant has to be restated, not just `.sf-line`: the variants above
 * carry two classes and so outrank a single-class rule here however far down
 * the file it sits. Left to them, the dotted leader kept its `1fr` on a phone
 * and the title kept its `auto` — which on a 390px screen came out as 205px of
 * dots beside 78px of title, and nineteen of Whit's 684 appearances truncated
 * mid-word to make room for a rule of full stops. */
@media (max-width: 640px) {
  .sf-line,
  .sf-line--noted,
  .sf-line--dateless,
  .sf-line--noted.sf-line--dateless { grid-template-columns: 40px minmax(0, 1fr) auto; }
  .sf-line__leader { display: none; }
}

/* ------------------------------------------------------------ callsheet -- */

/* Two columns of rows on a wide screen: the point of the callsheet is
   scanning a lot of names at once, and one column wastes half the width. */
/* The appearance list's one sort control: which end of the run to start at.
   An arrow beside "No.", not a pill — there is a single column to order by, so
   naming it in a pill would be a label for a choice that has no alternatives. */
.sf-sortdir {
  display: inline-flex;
  align-items: baseline;
  gap: 5px;
  margin-left: auto;
  font-size: var(--sf-size-10);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--sf-muted2);
}
.sf-sortdir:hover { color: var(--sf-accent-ink); }
.sf-sortdir__num { font-size: var(--sf-size-11); }

/* The way to the filters when the rail has stacked underneath the list.
   Hidden wherever the rail is beside the list, which is everywhere it fits —
   a link to something already on screen is a link to nowhere. */
.sf-jump { display: none; }

@container page (max-width: 760px) {
  .sf-jump {
    display: inline-flex;
    align-items: baseline;
    gap: 5px;
    margin-left: auto;
    font-size: var(--sf-size-10);
    font-weight: var(--sf-weight-display);
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--sf-accent-ink);
  }
  /* With a Filter link in the row, the sort arrow no longer needs to claim the
     free space — they share the right-hand end. */
  .sf-jump ~ .sf-sortdir,
  .sf-sortdir + .sf-jump { margin-left: 0; }
}

/* --------------------------------------------------------- year range -- */

/* A two-handle slider, which HTML does not have: two range inputs stacked on
   one track, each transparent except for its thumb. Without JavaScript they are
   two independent sliders and the Find button applies them — the server swaps
   the pair if the handles crossed, so a crossed range still means something. */
/* Stacked, not inline: the readout sits above the track where the eye already
   is while dragging, and the scale ends sit under it. Inline, the numbers were
   off to one side and the control read as a decorative bar. */
.sf-years {
  display: flex;
  flex-direction: column;
  gap: 2px;
  flex: 1 1 340px;
  min-width: 260px;
  padding: 2px 0;
}

.sf-years__head {
  display: flex;
  align-items: baseline;
  gap: 10px;
}
.sf-years--set .sf-years__head .sf-label { color: var(--sf-accent-ink); }

/* The pair of years, and the largest thing in the control: it is what the
   control is for, and what tells the reader the drag did something. */
.sf-years__value {
  font-family: var(--sf-display);
  font-size: var(--sf-row-title-md);
  color: var(--sf-muted);
  white-space: nowrap;
}
.sf-years--set .sf-years__value { color: var(--sf-head); }

.sf-years__clear {
  margin-left: auto;
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--sf-accent-ink);
}

.sf-years__scale {
  display: flex;
  justify-content: space-between;
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-title);
  color: var(--sf-muted3);
}

.sf-years__slider {
  position: relative;
  height: 24px;
  display: flex;
  align-items: center;
}

.sf-years__track,
.sf-years__fill {
  position: absolute;
  height: 6px;
  border-radius: 3px;
  pointer-events: none;
}
.sf-years__track { left: 0; right: 0; background: var(--sf-rule2); }
.sf-years__fill { background: var(--sf-chart-ink); }

/* Both inputs occupy the whole track so either thumb can be grabbed anywhere.
   The track itself is transparent — `.sf-years__track` above draws it — and
   the input only takes pointer events on the thumb, so the one underneath is
   still reachable. */
.sf-years__input {
  position: absolute;
  left: 0;
  width: 100%;
  margin: 0;
  height: 24px;
  background: none;
  appearance: none;
  -webkit-appearance: none;
  pointer-events: none;
}

/* 20px thumbs: big enough to grab confidently, and — more to the point — big
   enough to still be two separate handles when the range is narrow and they sit
   almost on top of each other. */
.sf-years__input::-webkit-slider-thumb {
  -webkit-appearance: none;
  pointer-events: auto;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--sf-chart-ink);
  border: 3px solid var(--sf-bg);
  box-shadow: 0 0 0 1px var(--sf-chart-ink);
  cursor: ew-resize;
}
.sf-years__input::-moz-range-thumb {
  pointer-events: auto;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--sf-chart-ink);
  border: 3px solid var(--sf-bg);
  box-shadow: 0 0 0 1px var(--sf-chart-ink);
  cursor: ew-resize;
}
.sf-years__input::-moz-range-track { background: none; }

.sf-years__input:focus-visible::-webkit-slider-thumb { outline: 2px solid var(--sf-accent-ink); outline-offset: 2px; }
.sf-years__input:focus-visible::-moz-range-thumb { outline: 2px solid var(--sf-accent-ink); outline-offset: 2px; }

/* ------------------------------------------------------- character list -- */

/* Two columns, stated rather than auto-fit: at 1400px an auto-fit track wide
   enough for a forty-year histogram fits three, and three columns of 40 bars
   each is a pitch nobody can read. */
.sf-namelist {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  column-gap: 40px;
}

.sf-namerow {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) 168px 44px;
  align-items: center;
  gap: 12px;
  padding: 5px 6px;
  border-bottom: 1px solid var(--sf-rule);
  color: var(--sf-text);
}
.sf-namerow:hover { background: var(--sf-panel); }
.sf-namerow:hover .sf-namerow__name { color: var(--sf-accent-ink); }

.sf-namerow__name { font-size: var(--sf-size-8); font-weight: var(--sf-weight-title); }
/* Recurring names carry the accent, as they do in the card grid this replaced. */
.sf-namerow__name--major { color: var(--sf-head); }

.sf-namerow__count {
  font-size: var(--sf-size-8);
  font-weight: var(--sf-weight-title);
  color: var(--sf-head);
  text-align: right;
}

/* The histogram: one column per year of the archive's run. Bottom-aligned in a
   fixed-height track so every row's baseline agrees down the column.

   A grid rather than a flex row, because only the years the subject actually
   worked are emitted — each bar names its own column, so the quiet years need
   no markup to hold their space. 91% of the bars here were empty ones.

   The column count is written out literally rather than taken from
   var(--sf-chart-years): a var() inside repeat() is valid per spec but has a
   patchy history in shipped browsers, and the failure mode is the whole
   histogram collapsing into one column. In calc(), where var() is
   uncontroversial, the variable is used. Move either end of the archive's run
   (Sheet::FIRST_YEAR..LAST_YEAR) and BOTH numbers have to move with it. */
.sf-namerow__bars {
  --sf-chart-years: 40;
  display: grid;
  grid-template-columns: repeat(40, 1fr);
  align-items: end;
  gap: 1px;
  height: 20px;
}

.sf-namerow__bar {
  background: var(--sf-chart-ink);
  border-radius: 1px 1px 0 0;
  min-height: 2px;
}

@container page (max-width: 900px) {
  .sf-namelist { grid-template-columns: minmax(0, 1fr); }
}

/* One column is not enough on its own: the row's own tracks were written for a
   680px half-page, and at 390px the fixed 168px histogram and 44px count left
   the name 64 pixels — "John Whittaker" truncated on the widest row on the
   site. The chart gives most of it back. Forty years across 92px is 2.3px a
   year, which still reads as a shape, and the shape is all it was ever for. */
@container page (max-width: 620px) {
  .sf-namerow {
    grid-template-columns: auto minmax(0, 1fr) 92px 34px;
    gap: 8px;
    padding: 5px 2px;
  }
  .sf-namerow__count { font-size: var(--sf-size-9); }
}

/* ------------------------------------------------ container-aware layout -- */

/* The episode page and the episode drawer render the SAME template. On a
   1080px page the two-column reader is right; in a 560px drawer it squeezes
   the prose into a ~250px gutter and the logline breaks every three words.

   A viewport media query cannot tell those apart — the window is wide in both
   cases. The layout has to respond to its container, so: */

.sf-page { container-type: inline-size; container-name: page; }

@container page (max-width: 760px) {
  .sf-reader {
    grid-template-columns: minmax(0, 1fr);
    gap: 26px;
  }

  /* In one column the aside reads as an appendix, so give it a rule to sit
     under and let the cover stop being a 230px block in a narrow column. */
  .sf-reader > aside {
    border-top: 1px solid var(--sf-rule);
    padding-top: 22px;
  }

  .sf-reader > aside .sf-cover--drawer { width: 160px; }

  .sf-reader__title { font-size: var(--sf-size-3); }

  /* ---- the rail, stacked ----
   *
   * The portrait is `width: 100%` with a 1:1 ratio, which beside the prose is
   * a 260px square. Stacked it became a 520px one — a whole screen of face,
   * and upscaled, because the largest derivative is 256px wide. It gets a
   * fixed size here instead, and sits beside the speaking figure rather than
   * above it, so the two together are one band and not two screens. */
  .sf-rail--controls .sf-speaking { margin-top: 0; }

  .sf-rail__id {
    display: flex;
    align-items: center;
    gap: var(--sf-space-md);
  }
  .sf-rail__id .sf-charrail__face { width: 116px; flex-shrink: 0; }
  .sf-rail__id .sf-speaking { flex: 1 1 auto; min-width: 0; }
  .sf-rail--controls .sf-charrail__face--none .sf-mononame { font-size: var(--sf-size-6); }

  /* Long filter lists get a window of their own rather than the whole page.
     Hal Smith has 64 parts: left to run, the Characters block alone is 2,500px
     of rail under a list it is supposed to be filtering. Capped in viewport
     units so it is always a readable fraction of the screen it is on. */
  .sf-rail--controls .sf-costars,
  .sf-rail--controls .sf-voicefilter {
    max-height: 44vh;
    overflow-y: auto;
    /* The scrollbar is drawn inside the box, over the count column at the right
       edge — "244" came out as "2". The gutter reserves its width instead, and
       is stable so the rows do not shift as a list crosses the cap. */
    scrollbar-gutter: stable;
    padding-right: var(--sf-space-2xs);
    /* A flick that reaches the end of this list should stop there rather than
       carrying on down the page it is sitting in. */
    overscroll-behavior: contain;
  }
}

/* ------------------------------------------------------ cover-size slider -- */

/* The shelf's cover size is a genuine preference — some people want to see the
   art, some want to see the whole run at once. Stored per browser, applied as
   one custom property, no server round trip. */

.sf-range {
  -webkit-appearance: none;
  appearance: none;
  /* Sized to its seven stops rather than to the space available. At 120px the
     handle had to travel 20px to reach the next size the grid actually
     reflows at, which read as a slider that mostly does nothing. */
  width: 84px;
  height: 20px;
  background: transparent;
  cursor: pointer;
}

.sf-range::-webkit-slider-runnable-track {
  height: 4px;
  border-radius: var(--sf-radius-xs);
  background: var(--sf-rule2);
}
.sf-range::-moz-range-track {
  height: 4px;
  border-radius: var(--sf-radius-xs);
  background: var(--sf-rule2);
}

.sf-range::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 14px;
  height: 14px;
  margin-top: -5px;
  border-radius: 50%;
  background: var(--sf-chart-ink);
  border: 0;
}
.sf-range::-moz-range-thumb {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--sf-chart-ink);
  border: 0;
}

.sf-range:focus-visible::-webkit-slider-thumb { outline: 2px solid var(--sf-accent-ink); outline-offset: 2px; }
.sf-range:focus-visible::-moz-range-thumb { outline: 2px solid var(--sf-accent-ink); outline-offset: 2px; }

/* ==========================================================================
   Episode drawer — append to public/assets/app.css

   The drawer is a summary card: logline, a way through to the prose, where the
   episode was released, who was in it, who made it. The article itself lives
   at /episode/<slug>/read.
   ========================================================================== */

/* Single column, always. This replaces the two-column .sf-reader grid for the
   episode view — the sidebar's contents now sit in the main flow, which is
   what lets the same markup work at 560px and at 1080px without a container
   query deciding anything. */
.sf-episode { display: block; max-width: 68ch; }

/* The card's body starts below the header rather than hard against it. */
.sf-episode__body { padding-top: var(--sf-space-lg); }

/* Plain prose, not the bordered standfirst card. In the drawer the logline is
   the first thing you read, so it should look like reading matter; on the
   reader page it sits under a 40px title and needs the card to separate it. */
.sf-episode__logline {
  font-size: var(--sf-row-title-md);
  line-height: 1.6;
  color: var(--sf-text2);
  margin: 14px 0 0;
  text-wrap: pretty;
}

.sf-noprose {
  font-size: var(--sf-size-7);
  font-weight: var(--sf-weight-label);
  color: var(--sf-muted);
  border: 1px dashed var(--sf-rule2);
  border-radius: var(--sf-radius-lg);
  padding: 16px 18px;
  margin: 0 0 24px;
}

/* Not an absence of prose but a warning about it — a failed sign-in, an
   unsaved edit. Same box, the alarm colour instead of the muted one. */
.sf-noprose--alert { border-color: var(--sf-pop-ink); color: var(--sf-pop-ink); }
.sf-noprose--note { border-color: var(--sf-teal-ink2); color: var(--sf-teal-ink); }

/* ------------------------------------------------------------- note card -- */

/* A bordered aside carrying one short passage: the host segments that opened
   and closed a broadcast, and anything else of that shape. Was fourteen
   declarations of inline style repeated for the opening and the closing.
   The accent on the label is the only thing that differs between the two, so
   that is the only thing the modifier sets. */
.sf-notecard {
  border: 1px solid var(--sf-panel2);
  border-radius: var(--sf-radius);
  padding: 14px 16px;
}
.sf-notecard__body {
  font-size: var(--sf-size-7);
  line-height: 1.6;
  color: var(--sf-text2);
  margin: 0;
  text-wrap: pretty;
}
.sf-notecard__label { display: block; margin-bottom: var(--sf-space-xs); }
.sf-notecard--open .sf-notecard__label { color: var(--sf-teal-ink); }
.sf-notecard--close .sf-notecard__label { color: var(--sf-red-rule); }

/* ------------------------------------------------------------ card decks -- */

/* A row of equal cards that wraps. `--sf-deck-min` is the track floor, so a
   deck of longer cards can widen it without a second grid declaration. */
.sf-deck {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(var(--sf-deck-min, 280px), 1fr));
  gap: var(--sf-space-sm);
  align-items: start;
}

/* ------------------------------------------------------------- found on -- */

/* An episode can be on more than one album, so this is a list of cards rather
   than a single card. Grid instead of margins on the card itself: the rail
   variant strips the card's border and padding, and a stacking margin would
   have to be unset there too. */
.sf-found-list {
  display: grid;
  gap: 10px;
}

.sf-found {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 12px 14px;
  /* --sf-rule, not --sf-panel2, and the same border the crew chips beneath it
     carry. In the light theme panel2 is #EFE8DA against a cream page: the card
     had a box all along and nothing showed until you hovered it, so a link
     looked like a picture with some text next to it. Hover then lifts to the
     brass, which is what .sf-madeby__chip already does. */
  border: 1px solid var(--sf-rule);
  border-radius: var(--sf-radius-lg);
  color: var(--sf-text);
}
.sf-found:hover { border-color: var(--sf-accent-ink); color: var(--sf-text); }
.sf-found:hover .sf-found__title { color: var(--sf-accent-ink); }

/* The album cover in a "Found on" card. Doubled from 54px: at that size the
   sleeve was an icon rather than a picture, and the card has the width. The
   drawer variant is used for the file (480w) at every placement, so this only
   sets how big it draws — see partial/episode-credits.

   Scoped away from the rail deliberately. Unscoped, the `height` reached the
   reader's rail as well, where the sibling rule sets width: 100% and no
   height — so the sleeve rendered 260x108 and `background-size: cover` cropped
   a square cover into a letterbox strip. The rail sizes its own. */
.sf-credits:not(.sf-credits--rail) .sf-found .sf-cover--drawer {
  width: 108px;
  height: 108px;
  margin: 0;
  flex-shrink: 0;
}

.sf-found__title {
  display: block;
  font-family: var(--sf-display);
  font-size: var(--sf-row-title-lg);
  line-height: 1.2;
  color: var(--sf-head);
  margin-top: 3px;
}

.sf-found__meta {
  display: block;
  font-size: var(--sf-size-9);
  font-weight: var(--sf-weight-label);
  color: var(--sf-muted2);
  margin-top: 3px;
}

/* -------------------------------------------------------------- billing -- */

/* Character on the left, actor on the right, dotted leader between — a cast
   list as it appears on a call sheet. The leader is what lets the eye track
   across a gap that varies by twenty characters from row to row. */

.sf-billing { display: flex; flex-direction: column; }

.sf-billing__row {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: baseline;
  gap: 8px;
  padding: 5px 0;
}

/* A featured row carries the character's face, so it gains a column and stops
   sitting on the baseline — a 26px avatar aligned to the text baseline hangs
   below the row. */
.sf-billing__row--faced {
  grid-template-columns: auto auto 1fr auto;
  align-items: center;
  padding: 3px 0;
}
.sf-billing__row--faced .sf-face { pointer-events: none; }

.sf-billing__char {
  font-size: var(--sf-size-8);
  font-weight: var(--sf-weight-label);
  color: var(--sf-text2);
}
.sf-billing--featured .sf-billing__char {
  font-size: var(--sf-size-7);
  font-weight: var(--sf-weight-title);
  color: var(--sf-head);
}
a.sf-billing__char:hover { color: var(--sf-accent-ink); }

.sf-billing__leader {
  border-bottom: 1px dotted var(--sf-rule2);
  transform: translateY(-3px);
  min-width: 12px;
}

.sf-billing .sf-actor { font-size: var(--sf-size-8); }
.sf-billing--featured .sf-actor { font-size: var(--sf-size-8); }

.sf-billing__unknown {
  font-size: var(--sf-size-9);
  font-weight: var(--sf-weight-label);
  color: var(--sf-muted3);
  font-style: italic;
}

/* -------------------------------------------------------------- made by -- */

/* One chip per person carrying every role they had. The crew query groups by
   job, which lists Steve Harris under Director, Producer and Writer as three
   separate people; the chip says it once. */

.sf-madeby {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 8px;
}

.sf-madeby__chip {
  display: block;
  padding: 10px 13px;
  border: 1px solid var(--sf-rule);
  border-radius: var(--sf-radius);
  color: var(--sf-text);
}
.sf-madeby__chip:hover { border-color: var(--sf-accent-ink); color: var(--sf-text); }

.sf-madeby__name {
  display: block;
  font-size: var(--sf-size-7);
  font-weight: var(--sf-weight-title);
  color: var(--sf-head);
}

.sf-madeby__roles {
  display: block;
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--sf-accent-ink);
  margin-top: 4px;
}
/* --------------------------------------------------- reader measure caps -- */

/* Cap the measure on the prose column, not on the page. Capping the page is
   what left half the screen empty with no rail to fill it. */
.sf-reader > div > .sf-prose,
.sf-reader > div > .sf-section .sf-prose { max-width: 68ch; }

.sf-reader > div > .sf-reader__header { max-width: 68ch; }

/* -------------------------------------------------------------- credits -- */

.sf-credits__section { margin-top: 24px; }
.sf-credits__section .sf-label { margin-bottom: 10px; }

/* In the rail everything is narrower, so the billing rows stack character over
   actor instead of running them across a dotted leader that would have no room
   to be a leader. */
.sf-credits--rail .sf-found {
  display: block;
  border: 0;
  padding: 0;
  text-align: left;
}

/* Borderless in the rail, so a second album needs real air to read as a
   separate entry rather than as more of the first one. */
.sf-credits--rail .sf-found-list { gap: 22px; }

.sf-credits--rail .sf-found .sf-cover--drawer {
  width: 100%;
  margin: 0 0 14px;
}

/* The episode's own artwork, at the head of the reader's rail.
   Exactly the square the album sleeve drew, so the page's shape is unchanged
   for the two thirds of the archive that has no episode art. */
.sf-epart {
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
  border-radius: var(--sf-radius);
  background-size: cover;
  background-position: center;
  box-shadow: var(--sf-shadow-cover);
  margin: 0 0 18px;
}

/* The rail's artwork as a control.
 *
 * A <button> rather than a tile, so the picture can be opened at size in the
 * page's existing lightbox. Everything visual is inherited from .sf-epart; this
 * only removes the button chrome and adds the affordance, because a thing that
 * can be clicked has to look like it. */
.sf-epart--zoom {
  position: relative;
  padding: 0;
  border: 0;
  cursor: zoom-in;
  font: inherit;
  color: inherit;
  -webkit-appearance: none;
  appearance: none;
}

/* The magnifier, bottom right. Always faintly there so the affordance does not
   depend on hovering — a touch screen never hovers — and brightening on hover
   and focus so a pointer gets the usual answer. */
.sf-epart__glass {
  position: absolute;
  right: 8px;
  bottom: 8px;
  width: 30px;
  height: 30px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  color: var(--sf-head);
  background: rgba(0, 0, 0, 0.42);
  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.28);
  opacity: 0.72;
  transition: opacity 140ms ease-out, background 140ms ease-out;
}
.sf-epart--zoom:hover .sf-epart__glass,
.sf-epart--zoom:focus-visible .sf-epart__glass {
  opacity: 1;
  background: rgba(0, 0, 0, 0.62);
}
.sf-epart--zoom:focus-visible { outline: 2px solid var(--sf-accent-ink); outline-offset: 3px; }

@media (prefers-reduced-motion: reduce) {
  .sf-epart__glass { transition: none; }
}

/* The card and the drawer, where the picture pairs with the standfirst under
   the title rather than heading a rail.

   230px is the square .sf-cover--drawer occupies, so an episode card and an
   album card carry art the same size. Against the drawer's 516px of content
   that leaves 268px for the logline, which averages 170 characters — five or
   six lines, near enough the height of the picture beside it.

   After .sf-epart deliberately: both are single-class selectors, so the width
   above would otherwise win on source order. */
.sf-epart--mast { flex: 0 0 auto; width: 230px; max-width: 100%; margin: 0; }

.sf-epmast {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 18px;
  margin: 16px 0 24px;
}

/* Standfirst and action, stacked beside the picture. The column takes whatever
   the picture leaves and drops beneath it once there is less than 220px of it —
   a phone-width drawer rather than the 516px one. `min-width: 0` because a flex
   item will not wrap below its longest unbreakable word without it, and episode
   titles are not always short. */
.sf-epmast__text { flex: 1 1 220px; min-width: 0; }
.sf-epmast__text > .sf-episode__logline { margin: 0; }
.sf-epmast__text > .sf-actions { margin: 14px 0 0; }

/* An episode with no logline: the button is then the top of the column and
   should start level with the picture. */
.sf-epmast__text > .sf-actions:first-child { margin-top: 0; }

/* The button carries 24px of tail for the body it used to sit in. Here the
   row's own bottom margin does that, and the tail would only stretch the
   column past the picture. */
.sf-epmast__text .sf-read { margin-bottom: 0; }

/* "Found on" as a card rather than a full-width sleeve.
   With the episode's artwork above it the album has to read as a reference
   rather than as a second masthead, so it takes the boxed form the episode
   card uses — cover on the left, label and title beside it. See the $boxed
   note in partial/episode-credits. */
.sf-credits--rail .sf-found-list--boxed { gap: 10px; }
.sf-credits--rail .sf-found-list--boxed .sf-found {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 12px;
  border: 1px solid var(--sf-rule);
  border-radius: var(--sf-radius-lg);
}
.sf-credits--rail .sf-found-list--boxed .sf-found .sf-cover--drawer {
  width: 64px;
  height: 64px;
  margin: 0;
  flex-shrink: 0;
}
.sf-credits--rail .sf-found-list--boxed .sf-found__title { font-size: var(--sf-size-6); }

.sf-credits--rail .sf-billing__row {
  grid-template-columns: minmax(0, 1fr);
  gap: 1px;
  padding: 7px 0;
  border-bottom: 1px solid var(--sf-rule);
}

/* The face beside the pair, not above it.

   These rows stack the character over the actor, so a face left to flow simply
   took the line above them — which is why it used to be hidden here. Placing
   it explicitly in a first column that spans both rows puts it where it
   belongs: level with the middle of the two lines it belongs to.

   Only the `--faced` rows, which are the featured ones that have a character
   to show. The rest keep the single column they had. */
.sf-credits--rail .sf-billing__row--faced {
  grid-template-columns: auto minmax(0, 1fr);
  column-gap: 10px;
  align-items: center;
}
.sf-credits--rail .sf-billing__row--faced .sf-face {
  display: block;
  grid-column: 1;
  grid-row: 1 / span 2;
  align-self: center;
}
.sf-credits--rail .sf-billing__row--faced .sf-billing__char {
  grid-column: 2;
  grid-row: 1;
}
.sf-credits--rail .sf-billing__row--faced .sf-actor,
.sf-credits--rail .sf-billing__row--faced .sf-billing__unknown {
  grid-column: 2;
  grid-row: 2;
}

/* Everything else in the rail still has no face to place. */
.sf-credits--rail .sf-billing__row:not(.sf-billing__row--faced) .sf-face { display: none; }

.sf-credits--rail .sf-billing__leader { display: none; }

.sf-credits--rail .sf-madeby {
  grid-template-columns: minmax(0, 1fr);
  gap: 0;
}

.sf-credits--rail .sf-madeby__chip {
  border: 0;
  border-bottom: 1px solid var(--sf-rule);
  border-radius: 0;
  padding: 9px 0;
}
.sf-credits--rail .sf-madeby__chip:hover { border-color: var(--sf-accent-ink); }

/* The rail is a reference column, not a reading column — everything in it is
   a size down from the same element in the card. */
.sf-credits--rail .sf-found__title { font-size: var(--sf-row-title-md); }
.sf-credits--rail .sf-billing--featured .sf-billing__char { font-size: var(--sf-size-8); }
.sf-credits--rail .sf-madeby__name { font-size: var(--sf-size-8); }

@container page (max-width: 760px) {
  .sf-reader { grid-template-columns: minmax(0, 1fr); }
  .sf-credits--rail .sf-found .sf-cover--drawer { width: 160px; }
}

/* ----------------------------------------------------- cover editions -- */

/* The shelf takes its tile shape from the edition it is showing.
   A streaming cover is square because that is what a streaming service wants,
   a CD front is about 7:6, and a cassette J-card is about 1:1.83. Squaring
   them all would either squash the artwork or cut the top and bottom off the
   part that makes an edition recognisable, so the grid changes shape instead
   and every cover fills its tile.

   Set on the tile rather than on the image because `.sf-cover` draws its art
   as a background: the element IS the picture, so the element has to be the
   right shape. */
.sf-shelf[data-art="cd"] .sf-cover--shelf { aspect-ratio: 7 / 6; }
.sf-shelf[data-art="tape"] .sf-cover--shelf { aspect-ratio: 1 / 1.83; }

/* ------------------------------------------------- the shelf's size ladder --

   The Size slider runs 110 to 230, and `1fr` stretches the top of that past
   250px on a wide window. A single 480w file served all of it: four times what
   the smallest tick renders, and less than the largest tick wants on a 2x
   screen. So the tile publishes three rungs as custom properties (see
   partial/cover.php) and these two rules choose between them.

   Two buckets, not seven. Only three distinct widths actually reach the screen
   across the seven stops — the grid quantises the rest — and a browser that has
   already cached the 480 for one bucket should not be made to fetch a 400 for
   the next stop along.

   `sf-cover--set` is the guard: only a tile that emitted all three properties
   carries it. Without that, a placeholder plate — which has no art and sets no
   properties — would hit an undefined var() inside image-set(), which is
   invalid at computed-value time and would blank the declaration.

   A ladder tile carries no inline background-image at all — the rungs are the
   only thing in its style attribute — so these rules are where its picture
   comes from. The plain var() declaration is first and is a complete answer on
   its own: a browser with no image-set() drops the line below as invalid and
   keeps this one, which is the 480w file every tile used to get. */
.sf-cover--set.sf-cover--shelf {
  background-image: var(--c-md);
  background-image: image-set(var(--c-md) 1x, var(--c-lg) 2x);
}
:root[data-cover-art="sm"] .sf-cover--set.sf-cover--shelf {
  background-image: var(--c-sm);
  background-image: image-set(var(--c-sm) 1x, var(--c-md) 2x);
}

/* Safari shipped image-set() behind -webkit- for years and still answers to it.
   Declared after the unprefixed pair so a browser that understands both keeps
   the standard one; one that only knows the prefix takes this and is correct
   too. Anything that knows neither has already been served a plain url() from
   the style attribute, which is the picture at 480w — the behaviour every
   browser had before this block existed. */
@supports (background-image: -webkit-image-set(url("") 1x)) and (not (background-image: image-set(url("") 1x))) {
  .sf-cover--set.sf-cover--shelf {
    background-image: -webkit-image-set(var(--c-md) 1x, var(--c-lg) 2x);
  }
  :root[data-cover-art="sm"] .sf-cover--set.sf-cover--shelf {
    background-image: -webkit-image-set(var(--c-sm) 1x, var(--c-md) 2x);
  }
}

/* An album with no scan of the chosen edition falls back to a later one, and
   is shaped like what it actually shows — otherwise a CD standing in for a
   missing cassette would be stretched into a J-card. These override the two
   rules above by being more specific, which is the whole reason the partial
   emits the edition it ENDED UP with. */
.sf-shelf[data-art] .sf-cover--ed-streaming.sf-cover--shelf { aspect-ratio: 1 / 1; }
.sf-shelf[data-art] .sf-cover--ed-cd.sf-cover--shelf { aspect-ratio: 7 / 6; }
/* An earlier CD is still a CD case, so it takes the CD's shape. Reached when a
   CD-mode tile falls back through the chain in Art::EDITION_FALLBACK. */
.sf-shelf[data-art] .sf-cover--ed-original.sf-cover--shelf { aspect-ratio: 7 / 6; }
.sf-shelf[data-art] .sf-cover--ed-tape.sf-cover--shelf { aspect-ratio: 1 / 1.83; }

/* No per-edition track width.

   The cassette grid used to multiply --sf-cover-size by 0.62, on the reasoning
   that tall narrow tiles fit more per row. The cost was that the size slider
   stopped meaning one thing: at the same setting a cover rendered 132px wide in
   streaming and 85px in cassette, so changing edition resized the artwork while
   the handle sat still.

   The slider sets a width, and a width is a width in every edition. The J-cards
   are taller than the square covers because `aspect-ratio` makes them taller —
   which is the honest way to show that they are a different shape. */

/* The three-way edition control, sitting with the size slider. */
.sf-arttoggle { display: inline-flex; align-items: center; gap: 6px; }
.sf-arttoggle__label { margin-right: 2px; }
/* Icon-only, so the pill is square rather than a lozenge sized for a word. */
.sf-arttoggle__pill { padding-left: 9px; padding-right: 9px; }
.sf-arttoggle__pill { display: inline-flex; align-items: center; gap: 6px; }
.sf-arttoggle__pill svg { display: block; flex: 0 0 auto; }


/* The other pressings, stacked down the album reader's rail.

   One per line at full rail width. Side by side they came out at 79px each,
   which is a thumbnail of a thumbnail — and the whole point of the block is
   that you can look at the artwork properly. Stacked, each sleeve keeps its
   own shape without the row having to reconcile three different ones. */
.sf-editions { display: grid; gap: 14px; }

/* A button that has to look like a figure: browsers give it a background, a
   border and centred inline layout, none of which belong here. */
.sf-editions__item {
  display: grid;
  gap: 6px;
  justify-items: center;
  width: 100%;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  cursor: zoom-in;
  color: var(--sf-muted2);
}
/* Format and release date on one line — "CASSETTE · 1991".

   Baseline-aligned rather than centred, so the uppercase word and the mixed-case
   date sit on the same line rather than the date riding high against the caps.

   It wraps: "Original CD · September 2001" is wider than the rail at some
   window sizes, and a caption that overflows its sleeve is worse than one on
   two lines. */
.sf-editions__cap {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: center;
  gap: 0 5px;
  text-align: center;
}
.sf-editions__word {
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.1em;
  text-transform: uppercase;
}
/* Not uppercased and not letter-spaced: "JUNE 2003" set like the format word
   reads as another label rather than as a date, and the two run together. */
.sf-editions__date {
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-label);
  letter-spacing: 0.02em;
  opacity: 0.72;
}
/* The separator belongs to the date, so it disappears with it — the many
   sleeves that carry no date must not be left with a trailing dot. */
.sf-editions__date::before {
  content: "·";
  margin-right: 5px;
  opacity: 0.7;
}
.sf-editions__art {
  width: 100%;
  height: auto;
  border-radius: 4px;
  display: block;
  box-shadow: var(--sf-shadow-cover, 0 2px 8px rgba(0, 0, 0, 0.35));
}
.sf-editions__item:hover,
.sf-editions__item:focus-visible { color: var(--sf-accent-ink); }
.sf-editions__item:hover .sf-editions__art { filter: brightness(1.06); }

/* ---------------------------------------------------------- lightbox -- */

/* The full-size cover viewer. The dialog fills the viewport rather than
   shrinking to its contents, so that a click anywhere off the picture is a
   click on the dialog itself — which is what app.js tests to close it. */
.sf-lightbox {
  width: 100vw;
  max-width: 100vw;
  height: 100dvh;
  max-height: 100dvh;
  padding: 0;
  border: 0;
  background: none;
  overflow: hidden;
}
.sf-lightbox::backdrop { background: rgba(0, 0, 0, 0.82); }

.sf-lightbox__figure {
  margin: 0;
  height: 100%;
  display: grid;
  align-content: center;
  justify-items: center;
  gap: 14px;
  padding: 5vmin;
  pointer-events: none;      /* clicks fall through to the dialog, which closes */
}
.sf-lightbox__img {
  max-width: min(92vw, 1100px);
  /* Room for the caption under it, so a tall J-card cannot push it off. */
  max-height: 82dvh;
  width: auto;
  height: auto;
  object-fit: contain;
  border-radius: 6px;
  box-shadow: 0 18px 60px rgba(0, 0, 0, 0.6);
  pointer-events: auto;
  cursor: default;
}
.sf-lightbox__cap {
  font-size: var(--sf-size-9);
  font-weight: var(--sf-weight-title);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: #fff;
  text-align: center;
}
.sf-lightbox__close {
  position: fixed;
  top: 16px;
  right: 16px;
  z-index: 1;
}

/* ------------------------------------------------------- album reader -- */

/* Sleeve then facts, stacked, exactly as the character reader stacks the
   portrait over the speaking figure. The sleeve fills the rail — a character
   portrait is the full 260px there, and an album cover set smaller beside a
   short column of facts read as a thumbnail of itself rather than as the
   artwork. */
/* The sleeve is a button now, so it has to stop looking like one. The cover
   inside keeps its own shadow and radius; the button is only a hit area. */
.sf-albumrail__cover {
  display: block;
  width: 100%;
  padding: 0;
  border: 0;
  background: none;
  cursor: zoom-in;
}
.sf-albumrail__cover:hover .sf-cover { filter: brightness(1.06); }

.sf-albumrail__id .sf-cover--drawer { width: 100%; margin: 0 0 16px; }

/* The album's name under its sleeve, and what it used to be called. */
.sf-albumrail__name { display: grid; gap: 4px; margin-bottom: 16px; }
.sf-albumrail__title {
  font-family: var(--sf-display);
  font-size: var(--sf-row-title-lg);
  font-weight: var(--sf-weight-display);
  line-height: 1.15;
  color: var(--sf-head);
  text-wrap: balance;
}
.sf-albumrail__was { font-size: var(--sf-size-9); font-style: italic; color: var(--sf-muted2); }

/* On a search result, the old name that the query actually matched. */
.sf-shelf__was {
  display: block;
  font-size: var(--sf-size-10);
  font-style: italic;
  color: var(--sf-muted2);
  margin-top: 2px;
}

/* Term-and-value pairs. A <dl> because that is what they are; the grid puts the
   term and the value on one row each so the values line up down the column
   however long the terms are. */
.sf-facts { margin: 0; display: grid; gap: 6px; }
.sf-facts__row {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: 8px;
  align-items: baseline;
}
.sf-facts dt {
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--sf-muted2);
}
.sf-facts dd {
  margin: 0;
  font-size: var(--sf-size-8);
  font-weight: var(--sf-weight-title);
  color: var(--sf-text);
  text-align: right;
}

/* The formats an album was pressed on. Chips rather than a dotted list: there
   are never more than three, and they are facts about the object rather than
   links to anywhere. */
.sf-formats { display: flex; flex-wrap: wrap; gap: 6px; }
.sf-format {
  font-size: var(--sf-size-10);
  font-weight: var(--sf-weight-title);
  padding: 4px 9px;
  border: 1px solid var(--sf-panel2);
  border-radius: 999px;
  background: var(--sf-panel);
  color: var(--sf-muted);
}

/* The blurb runs to a real paragraph or two, where a character bio is a
   sentence — so it sets upright and at reading size rather than in the italic
   the shorter standfirsts use. */
.sf-page--reader .sf-logline p + p { margin-top: 10px; }

@container page (max-width: 760px) {
  /* Stacked, the rail runs the full page width and a 100% sleeve would be a
     screen of cover art. Back to the 160px the other readers give theirs,
     centred, with the facts under it. */
  .sf-albumrail__id .sf-cover--drawer { width: 160px; margin: 0 auto 16px; }

  /* Same problem, same answer, for the pressings underneath. At full rail
     width they came out 610px on a phone — three covers of that size is most
     of a screen each, and they are meant to be compared rather than pored
     over. Centred so the column still reads as a column. */
  .sf-editions { justify-items: center; }
  .sf-editions__item { max-width: 200px; }
}

/* ---------------------------------------------------- artwork sizing -- */

/* Three steps of artwork on the episode, character and cast lists, set by the
   slider in partial/size-slider and applied from `data-art` on <html>.
   
   The ladder tops out at 110px because that is the shelf slider's `min` — the
   albums page runs 110 to 210, so "large" here lands exactly on the smallest
   tile there and the two pages meet rather than overlapping.
   
   Step 1 is absent from this block on purpose: it is the size everything
   already was, so the default page carries no overrides at all and a reader who
   never touches the slider pays nothing.
   
   The type grows a pixel a step with the pictures. A 110px cover beside 12px
   text reads as an illustration that has escaped its caption; the row has to
   grow as a whole or it just looks broken. */

.sf-sizer { display: inline-flex; align-items: center; gap: var(--sf-space-xs); }

/* The group's own rule. `align-self: stretch` on the base class measures against
   .sf-sizer, which is only as tall as the slider, so this gives it the same
   18px the fold's dividers use and keeps it off the row's edges. */
.sf-sizer__rule { align-self: center; height: 18px; margin-left: 0; }

/* Shorter than the shelf's 84px: three stops need less travel than six, and at
   84px the handle moved a long way to do very little. */
.sf-range--art { width: 52px; }

/* Nothing to drag without a script, so it is not offered. `.js` lands on <html>
   before first paint, so this never flashes into view and back out. */
.sf-sizer { display: none; }
.js .sf-sizer { display: inline-flex; }

/* The cover's GRID TRACK has to move with the cover. `.sf-eprow--art` pins the
   second column at 46px, so resizing only the tile left it overflowing its own
   track and the title printed on top of the artwork. The `.sf-no-loglines`
   rule further up gets this right — it changes the track and the tile in the
   same breath — and this follows it. */

/* ---- step 2 ---- */
:root[data-art="2"] .sf-eprow--art { grid-template-columns: 96px minmax(0, 1fr); }
:root[data-art="2"] .sf-eprow__tile { width: 96px; height: 96px; }
:root[data-art="2"] .sf-eprow__chip { width: 28px; height: 28px; }
:root[data-art="2"] .sf-eprow__title { font-size: var(--sf-row-title-md); }
:root[data-art="2"] .sf-eprow__sub { font-size: var(--sf-size-8); }
:root[data-art="2"] .sf-namerow .sf-avatar { width: 72px; height: 72px; font-size: 24px; }
:root[data-art="2"] .sf-namerow__name { font-size: var(--sf-size-7); }

/* ---- step 3 ---- */
:root[data-art="3"] .sf-eprow--art { grid-template-columns: 132px minmax(0, 1fr); }
:root[data-art="3"] .sf-eprow__tile { width: 132px; height: 132px; }
:root[data-art="3"] .sf-eprow__chip { width: 34px; height: 34px; border-radius: 5px; }
/* At the large step the tile is 132px. 240 covers it on an ordinary screen;
   a 2x screen wants 264 device pixels and gets the 480, which is the only place
   in the listing that rung is served. */
:root[data-art="3"] .sf-cover--epart { background-image: image-set(var(--e-md) 1x, var(--e-lg) 2x); }
:root[data-art="3"] .sf-eprow__title { font-size: var(--sf-row-title-lg); }
:root[data-art="3"] .sf-eprow__sub { font-size: var(--sf-size-7); }
:root[data-art="3"] .sf-namerow .sf-avatar { width: 110px; height: 110px; font-size: 34px; }
:root[data-art="3"] .sf-namerow__name { font-size: 15.5px; }

/* The year histogram grows with everything else. Height, because that is what
   a bar chart has to spend on being readable — forty bars in 20px is a texture,
   and in 40px it is a shape you can read a career off.
   
   The track gets NARROWER as it grows taller, and that is not a contradiction.
   Two columns stay two columns at every size, so the width a bigger portrait
   takes has to come from somewhere, and it comes from here: at 110px of
   portrait beside the full-width 168px chart the names truncated to
   "John W...". Height is what a bar chart actually needs — forty bars in 20px
   is a texture, in 40px it is a shape you can read a career off — and width is
   what it can afford to give up.
   
   The column gap closes with it, which buys every row another 8px of name
   without touching the chart at all. */
:root[data-art="2"] .sf-namerow__bars { height: 28px; }
:root[data-art="3"] .sf-namerow__bars { height: 40px; }

/* Only the chart's width changes. The row keeps the base structure —
   `auto minmax(0, 1fr) <chart> 44px` — with the NAME as the flexible column.
   
   Two other arrangements were tried and both were worse. A `1fr` chart reached
   335px on a 1400px screen and came apart: forty bars spread that far read as a
   row of separate marks rather than one shape. Sizing the name to `max-content`
   to feed the chart instead moved the slack into the `auto` avatar track, which
   left the portrait sitting alone at the left of a wide empty column with the
   name drifting off towards the chart — the name belongs beside the face.
   
   So the slack stays in the name column where it started, and the chart simply
   gets a bigger fixed lane at each step. The gap between name and chart is the
   layout the list has always had.
   
   The 2px gap is the drawer's, not the list's 1px: the same forty years drawn
   two ways looked like two different charts side by side. */
/* Both flexible tracks are bounded, and both bounds are load-bearing.
   
   A FIXED chart width overflows the column on a narrower window: 110px of
   portrait plus a 280px chart plus the count and three gaps came to more than
   the 456px a two-column list gets at 1000px, the name track collapsed to
   nothing, and the name printed over the picture. `minmax(0, 280px)` lets the
   chart give width back instead of pushing the row apart.
   
   The name then needs a floor of its own, or the chart takes its 280px first
   and leaves the name at zero — the same overlap arriving from the other
   direction. */
:root[data-art="2"] .sf-namerow { grid-template-columns: auto minmax(140px, 1fr) minmax(0, 210px) 44px; }
:root[data-art="3"] .sf-namerow { grid-template-columns: auto minmax(150px, 1fr) minmax(0, 280px) 44px; }

:root[data-art="2"] .sf-namerow__bars,
:root[data-art="3"] .sf-namerow__bars { gap: 2px; }

:root[data-art="2"] .sf-namelist { column-gap: 32px; }
:root[data-art="3"] .sf-namelist { column-gap: 24px; }

/* The skipped-row height estimate has to move with the rows, or the scrollbar
   lurches as they are realised and an anchor lands in the wrong place — the
   same reason `contain-intrinsic-size` was given a real number in the first
   place rather than left to guess. */
:root[data-art="2"] .sf-eprow   { contain-intrinsic-size: auto 118px; }
:root[data-art="3"] .sf-eprow   { contain-intrinsic-size: auto 154px; }
:root[data-art="2"] .sf-namerow { contain-intrinsic-size: auto 72px; }
:root[data-art="3"] .sf-namerow { contain-intrinsic-size: auto 110px; }

/* With the loglines switched off a row is only its title, so the cover would be
   taller than the line it sits against. It keeps a size below the ladder's, as
   it already does at step 1. */
:root[data-art="2"] .sf-no-loglines .sf-eprow--art { grid-template-columns: 52px 60px minmax(0, 1fr) 110px; }
:root[data-art="2"] .sf-no-loglines .sf-eprow__tile { width: 60px; height: 60px; }
:root[data-art="3"] .sf-no-loglines .sf-eprow--art { grid-template-columns: 52px 84px minmax(0, 1fr) 110px; }
:root[data-art="3"] .sf-no-loglines .sf-eprow__tile { width: 84px; height: 84px; }

/* -------------------------------------------------- check controls -- */

/* A group of boxes inside the .sf-controls flex row, carrying its own label
   the way partial/pill-row does. */
.sf-checkrow {
  display: inline-flex;
  align-items: center;
  gap: var(--sf-space-xs);
  margin: 0;
  /* The pair must never be split across two lines of a wrapping control bar:
     "Only ☐ Skits" on one and "☑ Advisory" on the next reads as two unrelated
     controls, which is what the first attempt did. */
  flex-wrap: nowrap;
}

/* One checkbox and its word, as a single clickable pill-height target. */
.sf-check {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  cursor: pointer;
  font-size: var(--sf-size-10);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--sf-muted);
  /* 24px tall so the label is a WCAG 2.5.8 target on its own, not just the
     14px box inside it. */
  min-height: 24px;
  padding: 0 2px;
}
.sf-check:hover { color: var(--sf-head); }

/* The input stays in the layout and keeps every native behaviour; only its
   painting is replaced. Not `display: none`, which would take it out of the
   tab order and off the accessibility tree along with its appearance. */
.sf-check__input {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

.sf-check__box {
  position: relative;
  flex-shrink: 0;
  width: 15px;
  height: 15px;
  border: 1.5px solid var(--sf-rule3);
  border-radius: var(--sf-radius-xs);
  background: var(--sf-panel);
  transition: background 0.12s ease, border-color 0.12s ease;
}

/* The tick, drawn as two borders on a rotated box rather than a glyph: a
   character would depend on the font shipping it and sit differently in each
   one. Hidden by scale rather than display so it has somewhere to animate
   from. */
.sf-check__box::after {
  content: '';
  position: absolute;
  left: 4px;
  top: 1px;
  width: 4px;
  height: 8px;
  border: solid var(--sf-on-accent);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg) scale(0);
  transition: transform 0.12s ease;
}

.sf-check__input:checked + .sf-check__box {
  background: var(--sf-chart-ink);
  border-color: var(--sf-chart-ink);
}
.sf-check__input:checked + .sf-check__box::after { transform: rotate(45deg) scale(1); }

/* Checked reads as on, so the word goes up to full ink with the box. */
.sf-check:has(.sf-check__input:checked) { color: var(--sf-head); }

/* The box carries the focus ring, since the input it belongs to is invisible.
   `:focus-visible` so a mouse click does not leave one behind. */
.sf-check__input:focus-visible + .sf-check__box {
  outline: 2px solid var(--sf-accent-ink);
  outline-offset: 2px;
}

/* The checkboxes need no submit button of their own: they are fields of the
   search form, and its Find button is the no-script path. app.js submits that
   form on change for everyone else. */

/* ------------------------------------------------------ sort direction -- */

/* The sort pills carry their own direction: the active one shows an arrow and
   clicking it reverses. That replaces the separate asc/desc button, which sat
   away from the thing it described and left you guessing which column it
   applied to. */
.sf-pill__dir {
  font-size: var(--sf-size-9);
  line-height: 1;
  margin-left: -2px;
  opacity: 0.85;
}

/* The arrow only ever appears on the active pill, and the active pill is
   filled, so its 0.85 was thinning white against a coloured ground rather than
   softening a glyph against the page. It is the smallest thing on the bar and
   the one carrying the direction, so it gets the full ink. */
.sf-pill--on .sf-pill__dir { opacity: 1; }

/* --------------------------------------------------- episode page width -- */

/* The reader's prose is capped at a readable measure while its grid column was
   still `1fr`, so the column stretched the full page and left a band of empty
   space between the text and the rail. Capping the prose moved the gap; it did
   not close it.
 
   Cap the whole thing instead. The reader is measure + gap + rail and nothing
   else, so sizing the page to exactly that puts the rail beside the text and
   keeps the crumbs, the stepper and both columns on the same two edges. */
.sf-page--reader { max-width: 1040px; }

/* With the page carrying the cap, the per-element caps would only re-introduce
   a smaller version of the same gap. */
.sf-page--reader .sf-reader > div > .sf-prose,
.sf-page--reader .sf-reader > div > .sf-section .sf-prose,
.sf-page--reader .sf-reader > div > .sf-reader__header { max-width: none; }

/* The episode card is a single column of index-card material — it does not
   need 1,080px of page to sit in. */
.sf-page--episode { max-width: 760px; }
.sf-page--episode .sf-episode { max-width: none; }

/* A centred header over a track listing, and a card the drawer also renders.
   These were `style="max-width: …"` on the page element itself, three
   different numbers on three templates with nothing naming what they were
   for. */
.sf-page--album { max-width: 1080px; }
.sf-page--card { max-width: 820px; }
.sf-page--form { max-width: 420px; }

/* ==========================================================================
   Patch 08 — cover lift + read-summary button
   ========================================================================== */

/* The lift. A shelf of covers is a wall of squares; the hover has to say
   "this one" loudly enough to be read peripherally, which a colour change
   alone does not do. 6px up, and the shadow deepens as if the card left the
   page — the shadow is half the effect, so do not ship the transform alone.

   The patch as delivered also set `display: block` here. Our markup puts the
   link ON the item (`<a class="sf-shelf__item">`), and `a.sf-shelf__item` beats
   the plain `.sf-shelf__item { display: flex; flex-direction: column }` on
   specificity — so that declaration would collapse every card into inline
   flow. It is dropped; only the motion belongs in this rule. */
.sf-shelf__item > a,
a.sf-shelf__item {
  color: var(--sf-text);
  transition: transform 140ms ease, box-shadow 140ms ease;
  will-change: transform;
}

.sf-shelf__item > a:hover,
a.sf-shelf__item:hover {
  transform: translateY(-6px);
  color: var(--sf-text);
}

/* The hover shadow lives on .sf-cover, so it is reached through the hover on
   the link rather than set on the link itself. See "cover shadows" below for
   the values and why they are what they are. */

/* Keyboard users get the same affordance. */
.sf-shelf__item > a:focus-visible,
a.sf-shelf__item:focus-visible { transform: translateY(-6px); }

@media (prefers-reduced-motion: reduce) {
  .sf-shelf__item > a,
  a.sf-shelf__item { transition: none; }
  .sf-shelf__item > a:hover,
  a.sf-shelf__item:hover { transform: none; }
}

/* ------------------------------------------------- read-summary button -- */

/* Not a .sf-pill: pills are 38px navigation chrome, and this is a single
   in-content call to action sitting under the logline. 9px/15px padding and a
   9px radius — squarer and tighter than a pill, so it reads as a button rather
   than another filter. */
.sf-read {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 9px 15px;
  border: 0;
  border-radius: var(--sf-radius);
  font-size: var(--sf-size-8);
  font-weight: var(--sf-weight-title);
  line-height: 1;
  margin-bottom: 24px;
  background: var(--sf-accent);
  color: var(--sf-on-accent);
}
.sf-read:hover { color: var(--sf-on-accent); filter: brightness(1.06); }

/* The way out of a form, beside the button that submits it. */
.sf-cancel { margin-left: var(--sf-space-xs); }

/* "See all 322 appearances →" — a continuation of the list it sits under, not
   a second call to action competing with the button in the header. Plain text
   so the header keeps the only filled control on the card. */
.sf-more {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font-size: var(--sf-size-8);
  font-weight: var(--sf-weight-title);
  color: var(--sf-accent-ink);
}
.sf-more:hover { color: var(--sf-head); }

/* The paragraph an in-content action sits in. .sf-read carries its own bottom
   margin, so the wrapper supplies none — it exists only to give the button a
   block of its own. Was `style="margin: 14px 0 0"`, `style="margin: 0"` and
   `style="margin: 16px 0 0"` on four templates for the same two cases. */
.sf-actions { margin: 14px 0 0; }
.sf-actions--after { margin: var(--sf-space-md) 0 0; }
.sf-actions--flush { margin: 0; }

/* ------------------------------------------------------- drawer insets -- */

/* A fragment is the same markup the full page renders, so it arrives wrapped in
   `.sf-page` — carrying that page's 30px/26px padding and its 80px tail. Inside
   the drawer that lands on top of the body's own 22px inset and the crumbs'
   14px, putting the first line ~66px below the close button with nothing in
   between.

   The drawer supplies the inset; the page inside it should not supply another.
   Scoped to `.sf-drawer__body`, so the standalone pages keep their padding. */
.sf-drawer__body .sf-page {
  max-width: none;
  margin: 0;
  padding: 0;
}

/* The crumbs' top padding is spacing from a page header that the drawer does
   not have — the bar is already directly above. */
.sf-drawer__body .sf-crumbs { padding-top: 0; }

/* Same reasoning for the album drawer's centred header, which opens with 10px
   of its own before the cover.

   Targeted by class, not by `.sf-page > header:first-of-type`. That selector
   described a position rather than a thing, and it also matched the person
   page's .sf-reader__header — the one page whose header is a direct child of
   .sf-page rather than nested inside .sf-reader > div. It zeroed the 18px that
   sits between the red rule and the name, so Cast & Crew names rendered hard
   against the rule while character names, two elements deeper, kept their
   spacing. */
.sf-drawer__body .sf-album__header { padding-top: 0; }

/* ------------------------------------------------------- cover shadows -- */

/* `.sf-cover` paints its shadow outside its own box — it spills ~26px past the
   bottom edge, under the album title. Any ancestor with `overflow: hidden` or
   paint containment crops it silently, which reads as "the shadow is too
   small" rather than as a clipping bug.
 
   Nothing between the shelf item and the cover clips today (checked: the only
   overflow:hidden rules in this file are .sf-visually-hidden and .sf-figures,
   neither an ancestor, and there is no aspect-ratio wrapper — .sf-cover--shelf
   carries its own 1:1). This is a guard so a future overflow:hidden reached for
   on a long title cannot take the shadow with it.
 
   `container-type: inline-size` on .sf-page is safe: it computes to
   `contain: layout style inline-size`, which does not include paint. */
.sf-shelf__item,
.sf-shelf__item > a,
a.sf-shelf__item,
.sf-shelf__item > a > *,
.sf-cover {
  overflow: visible;
  contain: none;
}

/* Hover has to stay clearly above the resting state, which is already a rim
   plus a deep ambient. Both values are tokens, so the two themes are handled by
   the token block and this rule is written once rather than four times. */
.sf-shelf__item > a:hover .sf-cover,
a.sf-shelf__item:hover .sf-cover {
  box-shadow: var(--sf-shadow-cover-hover);
}

/* The numbered placeholder keeps its frame while it lifts. Same specificity as
   the rule above, so this has to sit after it to win. */
.sf-shelf__item > a:hover .sf-cover--plate,
a.sf-shelf__item:hover .sf-cover--plate {
  box-shadow: var(--sf-plate-rings), var(--sf-shadow-cover-hover);
}

/* ------------------------------------------------------- long list rows -- */
/* The callsheet is 450 KB of markup and the episodes list runs to a thousand
   rows. `content-visibility: auto` lets the browser skip layout and paint for
   the ones below the fold until they are scrolled to.

   `contain-intrinsic-size` is the load-bearing half: without it a skipped row
   reports zero height, the scrollbar lurches as rows are realised, and jumping
   to an anchor lands in the wrong place. The values are the rendered heights
   of these rows, so the estimate is right before it is measured. */
/* Shorter now the faces are gone; the estimate has to follow or the
   scrollbar lurches as rows are realised. */
.sf-eprow   { content-visibility: auto; contain-intrinsic-size: auto 82px; }
.sf-line    { content-visibility: auto; contain-intrinsic-size: auto 38px; }
.sf-namerow { content-visibility: auto; contain-intrinsic-size: auto 34px; }

/* Not applied to .sf-grid__item: the A-Z cards are a grid, and a grid item
   that reports the wrong height reflows its whole row rather than just itself. */

/* --------------------------------------------------- swapping the results -- */

/* The results region while a control is being applied. Just enough to say
   something is happening on a slow connection — the swap is usually faster
   than this is visible. */
[data-content][aria-busy="true"] { opacity: 0.55; transition: opacity 120ms ease-out; }

/* A section fetching its own rows dims only its heading — the rest of the list
   is untouched and should not look like it is reloading. */
.sf-group__head[aria-busy="true"] { opacity: 0.6; transition: opacity 120ms ease-out; }

@media (prefers-reduced-motion: reduce) {
  [data-content][aria-busy="true"],
  .sf-group__head[aria-busy="true"] { transition: none; }
}

/* ------------------------------------------------------------ header fit -- */

/* Reclaim a little more at the tight end before anything wraps. */
@media (max-width: 1280px) {
  .sf-nav__link { padding: 0 10px; letter-spacing: 0.06em; }
}

/* ------------------------------------------------------------ episode nav -- */

/* The foot-of-page pager. Deliberately not the crumb steps: those are two
   muted arrows in the header for someone mid-navigation, and this is the end
   of the page, where the only question left is what aired either side. So it
   answers with the number and the title, and takes the accent rule to say it
   is a destination rather than a breadcrumb. */
.sf-epnav {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 10px;
  margin-top: 40px;
  padding-top: 18px;
  border-top: 3px solid var(--sf-accent-ink);
}

.sf-epnav__side {
  display: grid;
  gap: 5px;
  align-content: start;
  min-width: 0;
  padding: 11px 14px;
  border: 1px solid var(--sf-panel2);
  border-radius: var(--sf-radius);
}
.sf-epnav__side--next { text-align: right; }

a.sf-epnav__side:hover {
  border-color: var(--sf-accent-ink);
  background: var(--sf-panel);
}

/* The dead end is a label, not a link: same box so the live side stays on its
   own half, but nothing to click and nothing to hover. */
.sf-epnav__side--end { border-style: dashed; opacity: 0.55; }

.sf-epnav__dir {
  font-size: var(--sf-size-10);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--sf-accent-ink);
}

/* One line, clipped. A pager that wraps to three lines on a long title is a
   pager that moves the button the reader was aiming at. */
.sf-epnav__title {
  font-size: var(--sf-size-7);
  font-weight: var(--sf-weight-title);
  color: var(--sf-head);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.sf-epnav__num { color: var(--sf-muted2); margin-right: 7px; }

@media (max-width: 560px) {
  .sf-epnav { grid-template-columns: minmax(0, 1fr); }
  .sf-epnav__side--next { text-align: left; }
}

/* ==========================================================================
   Patch 11 — By the numbers
   Append to public/assets/app.css
   ========================================================================== */

/* ------------------------------------------------------ new faces chart -- */

.sf-debuts {
  display: flex;
  align-items: flex-end;
  gap: 3px;
  height: 120px;
  padding-bottom: 8px;
  border-bottom: 2px solid var(--sf-rule2);
}

/* Teal rather than --sf-chart-ink: the teal already darkens for the light
   theme (#4FD8D8 → #166767), so it never had the legibility problem the brass
   charts had and there is nothing here for the chart ink to fix. */
.sf-debuts__bar {
  flex: 1;
  border-radius: 3px 3px 0 0;
  background: var(--sf-teal-ink);
  opacity: 0.85;
}

.sf-debuts__axis { display: flex; gap: 3px; padding-top: 8px; }
.sf-debuts__axis span {
  flex: 1;
  font-size: var(--sf-size-12);
  font-weight: var(--sf-weight-title);
  color: var(--sf-muted3);
  text-align: center;
}

/* ----------------------------------------------------- airdate calendar -- */

/* The month labels sit over the track only — the year gutter on the left and
   the count column on the right are excluded by the padding, so JAN lines up
   with 1 January rather than with the year number. */
.sf-cal__months {
  display: flex;
  gap: 10px;
  padding-left: 46px;
  padding-right: 40px;
  margin-bottom: 6px;
}
.sf-cal__months span {
  flex: 1;
  font-size: var(--sf-size-12);
  font-weight: var(--sf-weight-display);
  letter-spacing: 0.08em;
  color: var(--sf-muted3);
}

.sf-cal { border-top: 1px solid var(--sf-rule); }

.sf-cal__row {
  display: flex;
  align-items: center;
  gap: 10px;
  border-bottom: 1px solid var(--sf-panel);
}

/* The row's only keyboard stop, and a link rather than a label.
 *
 * It was briefly 24px tall, to meet SC 2.5.8's 24x24 target. That made the
 * calendar 40 rows of 24px instead of 40 of 15, and the density is the point of
 * this chart — forty years should read as one block you can scan, not a list
 * you scroll. Reverted deliberately, with the trade-off recorded rather than
 * quietly dropped: at this height the year link is 36x15, so 2.5.8 is not met
 * on size, and the 15px gap to the year above and below means the spacing
 * exception does not rescue it either.
 *
 * What that revert does NOT cost is the reason the link exists. The ticks stay
 * out of the tab order and out of the accessibility tree, so this row is one
 * keyboard stop instead of the twenty-odd it used to be, and the calendar as a
 * whole is 40 instead of 1,080. That was the real problem; the target size was
 * a bonus that came with the taller row. */
.sf-cal__year {
  width: 36px;
  flex-shrink: 0;
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-display);
  color: var(--sf-muted2);
  text-align: right;
}
.sf-cal__year:hover { color: var(--sf-accent-ink); }

.sf-cal__track { position: relative; flex: 1; height: 13px; }

/* 3px wide with a -1.5px offset so the tick is centred on its date rather
   than starting at it — at this density the half-pixel is visible as drift
   across a year. */
.sf-cal__tick {
  position: absolute;
  top: 2px;
  bottom: 2px;
  width: 3px;
  margin-left: -1.5px;
  border-radius: var(--sf-radius-xs);
  background: var(--sf-decade, var(--sf-chart-ink));
}

/* One hue per decade, so the calendar reads as eras at a glance rather than as
   forty identical rows. The hues are tokens — see the token block, which is
   where both themes answer for them. These were hex literals returned by a
   closure inside the template, which meant the 1980s were brass in both themes,
   and brass on cream is very nearly the paper. */
.sf-cal__tick--1980 { --sf-decade: var(--sf-decade-80); }
.sf-cal__tick--1990 { --sf-decade: var(--sf-decade-90); }
.sf-cal__tick--2000 { --sf-decade: var(--sf-decade-00); }
.sf-cal__tick--2010 { --sf-decade: var(--sf-decade-10); }
.sf-cal__tick--2020 { --sf-decade: var(--sf-decade-20); }
.sf-cal__tick:hover {
  top: 0;
  bottom: 0;
  width: 5px;
  margin-left: -2.5px;
  z-index: 1;
}

.sf-cal__count {
  width: 30px;
  flex-shrink: 0;
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-title);
  color: var(--sf-faint);
  text-align: right;
}

/* ------------------------------------------------------- ranked charts -- */

.sf-charts {
  display: grid;
  /* See .sf-intro — a bare 330px floor overflowed a 320px screen. */
  grid-template-columns: repeat(auto-fit, minmax(min(330px, 100%), 1fr));
  gap: 34px;
}

.sf-rank {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 54px;
  gap: 12px;
  align-items: center;
  padding: 6px 8px;
  border-radius: var(--sf-radius);
  color: var(--sf-text);
}
.sf-rank:hover { background: var(--sf-panel); color: var(--sf-text); }

.sf-rank__body { min-width: 0; }
.sf-rank__label { display: block; font-size: var(--sf-size-8); font-weight: var(--sf-weight-label); margin-bottom: 5px; }
.sf-rank__track { display: block; height: 7px; border-radius: var(--sf-radius-xs); background: var(--sf-panel2); }
.sf-rank__bar { display: block; height: 100%; border-radius: var(--sf-radius-xs); background: var(--sf-chart-ink); }
.sf-rank__value { font-size: var(--sf-size-9); font-weight: var(--sf-weight-title); color: var(--sf-muted); text-align: right; }

/* One colour per chart, so four bar charts side by side are not four
   identical-looking lists. The repository names the chart, not the colour —
   see StatsRepository::charts(). Characters take the default chart ink, which
   is the brass that reads on the dark page and the red that reads on cream. */
.sf-rank__bar--characters { background: var(--sf-accent-ink); }
.sf-rank__bar--actors     { background: var(--sf-teal-ink); }
.sf-rank__bar--writers    { background: var(--sf-pop-ink); }
.sf-rank__bar--directors  { background: var(--sf-muted); }

/* ----------------------------------------------------------- long haul -- */

.sf-haul {
  display: grid;
  /* See .sf-intro — a bare 300px floor overflowed a 320px screen. */
  grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
  gap: 8px;
}

.sf-haul__row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 9px 12px;
  border: 1px solid var(--sf-panel2);
  border-radius: var(--sf-radius);
  color: var(--sf-text);
}
.sf-haul__row:hover { background: var(--sf-panel); border-color: var(--sf-rule2); color: var(--sf-text); }

.sf-haul__body { flex: 1; min-width: 0; }
.sf-haul__name { display: block; font-size: var(--sf-size-7); font-weight: var(--sf-weight-label); }
.sf-haul__span { display: block; font-size: var(--sf-size-10); font-weight: var(--sf-weight-label); color: var(--sf-muted2); margin-top: 2px; }
.sf-haul__years { font-family: var(--sf-display); font-size: var(--sf-row-title-lg); color: var(--sf-accent-ink); }

@media (max-width: 720px) {
  /* The calendar is the one thing here that cannot usefully shrink — 52 ticks
     across a phone is a smear. It scrolls sideways instead. */
  .sf-cal, .sf-cal__months { min-width: 620px; }
  .sf-section:has(.sf-cal) { overflow-x: auto; }
}


/* ======================================================= album contents == */
/* The album's listing, as picture rows.
 *
 * It was `partial/episode-line` - number, title, leader, date. That form is
 * right where an episode is listed inside something that has already said what
 * the group is; it is wrong on the album's own page, where the listing IS the
 * page and two thirds of these episodes now have a picture drawn for them.
 *
 * So this reuses `partial/episode-row`, the shelf's row. What changes is the
 * typography, and the reason it changes is that the shelf's row and this one
 * answer different questions. The shelf is 1,081 rows a reader is hunting
 * through, so it is set for scanning: everything at one size, weight carrying
 * the hierarchy. An album is twelve rows a reader is reading, so it is set for
 * reading: size carries the hierarchy, and weight stops doing a job it was
 * only doing because size was not.
 *
 * SPECIFICITY, which is not incidental here. The size steps are written
 * `:root[data-art="2"] .sf-eprow__title` - a pseudo-class, an attribute and a
 * class, so 0,3,0. A plain `.sf-album__listing .sf-eprow__title` is 0,2,0 and
 * loses to it however late in the file it sits, which is not a tie broken by
 * order but a straight defeat. Every rule below that touches a property those
 * steps also set is therefore written against the same three steps. */
.sf-album__listing { display: flex; flex-direction: column; }

/* 1. The title leads, the facts sit under it.
 *
 * On the shelf the eyebrow comes first because that list opens sorted by
 * airdate, so the date is the axis the whole view is built on and putting it
 * first matches the order the rows are in. An album is in its own track order
 * and every row shares a release window, which makes the date the least
 * distinguishing thing on the line - the title is what is being read for.
 *
 * `order` on the existing flex column rather than new markup: the partial is
 * one file used by four pages, and a second copy of it to move two lines would
 * be the start of them drifting apart. */
.sf-album__listing .sf-eprow__head { order: 1; }
.sf-album__listing .sf-eprow__meta { order: 2; }
.sf-album__listing .sf-eprow__sub  { order: 3; }

/* 2. The title becomes display type, which is the fix the rest follows from.
 *
 * `.sf-eprow__title` sets no font-family, so it inherits Archivo - the body
 * face - and reaches for 800 to look like a title. That is heavy body text
 * pretending, and above a 700-weight logline it gave four lines of nearly
 * identical colour: the row had no typographic hierarchy, only slightly
 * different amounts of bold.
 *
 * No font-weight is declared, deliberately: exactly one Nunito face is loaded
 * (900), so every weight request resolves to it, and every other display rule
 * on the site leans on that rather than restating it - see `.sf-shelf__title`.
 *
 * One step above the shelf's title at each setting rather than a fixed size,
 * so the reader's SIZE control still does what it says on this page. The shelf
 * runs 15 / 16 / 17; this runs 18 / 19 / 21, which keeps roughly the scale's
 * 1.25 ratio against the logline underneath at every step. */
.sf-album__listing .sf-eprow__title {
  font-family: var(--sf-display);
  line-height: 1.15;
  letter-spacing: -0.01em;
}
:root .sf-album__listing .sf-eprow__title { font-size: var(--sf-album-title-sm); }
:root[data-art="2"] .sf-album__listing .sf-eprow__title { font-size: var(--sf-size-5); }
:root[data-art="3"] .sf-album__listing .sf-eprow__title { font-size: var(--sf-album-title-lg); }

/* 3. The number goes back to the body face.
 *
 * It was the display face at 900 and red, which is right on the shelf, where
 * the number is how a reader picks one show out of the whole archive. Inside
 * an album it is a track number on a list of twelve, and the display face has
 * only its heavy cut - so as long as it stays Nunito it cannot be quiet, only
 * smaller and greyer, which is a heavy thing whispering rather than a light
 * one speaking.
 *
 * Archivo at 700 can actually be quiet. Tabular figures because this is a
 * column of numbers and they should not wobble; the tracking survives from the
 * old rule because it is what still reads as a catalogue mark rather than as
 * the first word of the title. */
.sf-album__listing .sf-eprow__num {
  font-family: var(--sf-body);
  font-size: var(--sf-size-9);
  font-weight: var(--sf-weight-label);
  letter-spacing: 0.08em;
  font-variant-numeric: tabular-nums;
  color: var(--sf-muted3);
}

/* 4. The eyebrow becomes a caption.
 *
 * Under the title it is no longer a label introducing the row, so it takes the
 * form small type uses when it is a caption: uppercase, open tracking, quiet.
 * 700 rather than 800 - at 10.5px uppercase, 800 is a second bold voice on a
 * row that already has one, and the tracking is doing the work of separating
 * it from the prose beneath. */
.sf-album__listing .sf-eprow__meta {
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-label);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--sf-muted3);
}
.sf-album__listing .sf-eprow__date { color: var(--sf-muted3); }

/* The runtime drops its pill. The outline exists to separate the measured fact
 * from the date beside it on a row that also carries flags; here there are no
 * flags - the album query returns no `advisory` or `has_skit` - so the eyebrow
 * is two facts and a separator, and a bordered violet capsule beside a plain
 * date is louder than the difference it marks. */
.sf-album__listing .sf-eprow__runtime {
  padding: 0;
  border: 0;
  border-radius: 0;
  font-size: inherit;
  font-weight: inherit;
  letter-spacing: inherit;
  color: var(--sf-muted3);
}

/* 5. The logline becomes prose.
 *
 * It was 12px at 700, which is a weight for a label and not for a sentence -
 * the other half of the row having no hierarchy. 400 is the weight this is
 * meant to be read at, and dropping it to the second step of the quiet ramp
 * puts it a clear stage behind a white title instead of competing with it.
 *
 * 1.6 rather than 1.55: at three lines the leading is what makes it read as a
 * paragraph rather than as three stacked labels. */
.sf-album__listing .sf-eprow__sub {
  font-weight: var(--sf-weight-body);
  line-height: 1.6;
  color: var(--sf-muted2);
}
:root .sf-album__listing .sf-eprow__sub { font-size: var(--sf-size-8); }
:root[data-art="2"] .sf-album__listing .sf-eprow__sub { font-size: var(--sf-size-7); }
:root[data-art="3"] .sf-album__listing .sf-eprow__sub { font-size: var(--sf-size-6); }

/* Room for the taller block. The list is twelve rows, so it can afford it. */
.sf-album__listing .sf-eprow { padding: 14px; gap: 16px; }
.sf-album__listing .sf-eprow__body { gap: 6px; }

/* The heading over it, matched to the rows rather than to the page.
 *
 * `.sf-section__head h2` is --sf-size-5, 19px, which is what the row titles now
 * take at the middle setting - two identical sizes eight pixels apart made the
 * heading read as the first row rather than as the label on the group. It goes
 * the other way instead: small, tracked and quiet, which is the form a heading
 * takes when the thing it labels is the loud one.
 *
 * Two selectors because the listing appears on two pages and their heads come
 * from different places: the album card writes its own `.sf-section__head`,
 * while the reader gets one from `partial/list-head` - the shared partial that
 * also serves the character and cast readers, which is exactly why this cannot
 * key off the partial's own classes. `.sf-section--albumlist` is a hook on the
 * reader's section for that reason and does nothing else. */
.sf-page--album .sf-section__head h2,
.sf-section--albumlist .sf-section__head h2 {
  font-family: var(--sf-body);
  font-size: var(--sf-size-10);
  font-weight: var(--sf-weight-title);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--sf-muted2);
}

/* Narrow screens: the title steps down, the logline holds. A display face at 19
   or 21px wraps to three lines beside a tile at phone widths, and a title that
   wraps more than twice stops being a title. All three size steps are named
   because each one outranks a bare `.sf-album__listing` selector. */
@media (max-width: 560px) {
  :root .sf-album__listing .sf-eprow__title,
  :root[data-art="2"] .sf-album__listing .sf-eprow__title,
  :root[data-art="3"] .sf-album__listing .sf-eprow__title { font-size: var(--sf-row-title-lg); }

  :root .sf-album__listing .sf-eprow__sub,
  :root[data-art="2"] .sf-album__listing .sf-eprow__sub,
  :root[data-art="3"] .sf-album__listing .sf-eprow__sub { font-size: var(--sf-size-8); }
}


/* ============================================================== drawer == */
/* The drawer is a surface over the page, so it is painted like one.
 *
 * It was `--sf-bg` - the page's own ground, the darkest token in the palette
 * and barely purple at all (#0F0B14). Behind it sit cards on --sf-panel, so an
 * overlay in the ground colour reads as a black slab cut out of the page rather
 * than as a panel lifted above it. That is why the drawers look black while the
 * page around them reads purple: the same palette, the wrong step of it.
 *
 * The palette already encodes the layering - bg < panel < panel2 - so this
 * takes the step that means "a surface" instead of inventing a colour, and it
 * holds in the cream theme for free: there the ground is #F6F1EA and a panel is
 * white, which is the same relationship the right way up.
 *
 * WHAT THIS COSTS, and why the rest of the block exists. Everything that paints
 * itself --sf-panel to sit ON the page becomes invisible on a --sf-panel
 * drawer. Inside a drawer that is one opaque fill - `.sf-step`, the pager
 * buttons in the bar - and the hover state of every row type a drawer can
 * contain. Each moves up to --sf-panel2, which is the step the palette provides
 * for exactly this: a surface on a surface.
 *
 * The listing rows themselves need nothing. `.sf-eprow:nth-child(odd)` is
 * --sf-stripe, a 3% white overlay rather than a fill, so it lifts off whatever
 * ground it is given. */
.sf-drawer { background: var(--sf-panel); }

.sf-drawer .sf-step { background: var(--sf-panel2); }

/* The hovers. `.sf-eprow:nth-child(odd):hover` is 0,3,0, so the plain
   `.sf-drawer .sf-eprow:hover` at 0,3,0 would only tie with it - it wins on
   order, being later, but the odd-row selector is restated here anyway so the
   rule does not depend on that. */
.sf-drawer .sf-eprow:hover,
.sf-drawer .sf-eprow:nth-child(odd):hover,
.sf-drawer .sf-namerow:hover,
.sf-drawer .sf-grid__item:hover,
.sf-drawer .sf-rank:hover,
.sf-drawer .sf-haul__row:hover { background: var(--sf-panel2); }

/* The chip's cut-out ring is drawn in the ground it sits on, so it follows the
   drawer up too - left behind it draws a --sf-panel halo on a --sf-panel
   drawer, which is a ring around nothing. */
.sf-drawer .sf-eprow:hover .sf-eprow__chip,
.sf-drawer .sf-eprow:nth-child(odd) .sf-eprow__chip {
  box-shadow: 0 0 0 2px var(--sf-panel2), 0 1px 4px rgba(0, 0, 0, 0.5);
}


/* ========================================== episode grid: the poster wall == */
/* The grid page rebuilt around the artwork, Aug 2026.
 *
 * It was a picture in a box with a number and a title printed underneath -
 * which is a catalogue card, and a catalogue card is what the LIST view is
 * already better at. With a drawn picture for most of the archive the grid's
 * job is different: it is the wall you browse by looking, and the only thing
 * on it that carries information at a glance is the image.
 *
 * So the picture goes edge to edge and the words move on top of it, over a
 * scrim, the way a poster or a streaming shelf sets them. Nothing here is new
 * markup - the tile already nests the art, the number and the title inside one
 * anchor, so this is entirely a question of where they sit.
 *
 * The scrim is built from --sf-bg-rgb, the token that exists precisely so a
 * gradient can fade to the page colour, which means the cream theme gets a
 * cream scrim under dark text without a second rule. */

/* Denser. The old 26px row gap was sized for a caption sitting under each
   tile; with the caption on the picture the wall wants to read as one field. */
.sf-egrid { gap: 16px; }

.sf-etile {
  position: relative;
  display: block;
  gap: 0;
  contain-intrinsic-size: auto 320px;
}

/* The frame. The radius and the clipping move here from the picture so the
   image can be scaled inside it on hover without escaping the corners. */
.sf-etile__art {
  position: relative;
  overflow: hidden;
  border-radius: var(--sf-radius-lg);
  box-shadow: var(--sf-shadow-cover);
  transition: box-shadow 180ms ease-out;
}
.sf-etile:hover .sf-etile__art { box-shadow: 0 0 0 2px var(--sf-accent-ink), var(--sf-shadow-cover); }

/* 4:5 rather than square. The pictures are drawn as scenes with a subject
   centred and headroom above, so a portrait crop keeps the figure and drops
   the empty sides - and a column of portraits reads as a wall of posters,
   which is the thing this page is trying to be. */
.sf-etile__art > .sf-etile__pic {
  aspect-ratio: 4 / 5;
  border-radius: 0;
  box-shadow: none;
  transform-origin: center 40%;
  transition: transform 460ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
.sf-etile:hover .sf-etile__art > .sf-etile__pic {
  box-shadow: none;
  transform: scale(1.055);
}

/* The scrim the words sit on. Four stops rather than two: a straight linear
   fade over this distance leaves a visible band across the middle of every
   picture, and the eye finds that band on a wall of forty of them. */
.sf-etile__art::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(to top,
      rgb(var(--sf-bg-rgb) / 0.94) 0%,
      rgb(var(--sf-bg-rgb) / 0.78) 16%,
      rgb(var(--sf-bg-rgb) / 0.34) 36%,
      rgb(var(--sf-bg-rgb) / 0.06) 55%,
      transparent 70%);
  transition: opacity 200ms ease-out;
}

/* The title, on the picture. --sf-size-6 rather than the caption size it used
   to be: it is no longer a label under a thing, it is the thing's name. */
.sf-etile__title {
  position: absolute;
  left: 13px;
  right: 13px;
  bottom: 11px;
  z-index: 2;
  font-size: var(--sf-size-6);
  line-height: 1.2;
  text-wrap: balance;
  text-shadow: 0 1px 3px rgb(var(--sf-bg-rgb) / 0.7);
}

/* The number becomes a corner mark rather than a line of text.
 *
 * On the picture it needs its own ground to stay legible over whatever happens
 * to be behind it, so it takes a blurred pill - the one piece of chrome on the
 * tile, and it earns it by being the only way to find a specific episode on a
 * wall of pictures. */
.sf-etile__num {
  position: absolute;
  top: 9px;
  left: 9px;
  z-index: 2;
  margin: 0;
  padding: 3px 7px;
  border-radius: var(--sf-radius-pill);
  background: rgb(var(--sf-bg-rgb) / 0.58);
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
  font-size: var(--sf-size-11);
  color: var(--sf-pop-ink);
}

/* The album sleeve steps off the picture and waits for the pointer.
 *
 * It was pinned to the bottom-left corner of every tile, which on a page whose
 * whole argument is "look at the artwork" is a second, smaller, competing
 * picture on all 800 of them. It is still the fastest way to see which release
 * a show belongs to, so it is not deleted - just held back until the tile is
 * the one being looked at. */
.sf-etile__chip {
  left: auto;
  bottom: auto;
  top: 9px;
  right: 9px;
  z-index: 2;
  width: 34px;
  height: 34px;
  border-radius: 6px;
  opacity: 0;
  transform: translateY(-3px);
  box-shadow: 0 0 0 2px rgb(var(--sf-bg-rgb) / 0.55), 0 2px 8px rgba(0, 0, 0, 0.5);
  transition: opacity 180ms ease-out, transform 180ms ease-out;
}
.sf-etile:hover .sf-etile__chip,
.sf-etile:focus-visible .sf-etile__chip { opacity: 1; transform: none; }
:root[data-art="3"] .sf-etile__chip { width: 40px; height: 40px; }

/* The episodes with no picture yet. They keep the panel they had, but the
   words now sit on it rather than under it like everything else, so the wall
   does not go ragged where the coverage stops. */
.sf-etile__art > .sf-etile__pic--none {
  background: linear-gradient(150deg, var(--sf-panel2), var(--sf-panel));
  box-shadow: inset 0 0 0 1px var(--sf-rule2);
}
.sf-etile__art:has(.sf-etile__pic--none)::after { background: none; }

/* Touch and keyboard have no hover, so nothing may depend on it: the chip is
   revealed outright where the pointer is coarse. */
@media (hover: none) {
  .sf-etile__chip { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  .sf-etile__art > .sf-etile__pic { transition: none; }
  .sf-etile:hover .sf-etile__art > .sf-etile__pic { transform: none; }
}


/* ================================== the wall: overflow fix and treatments == */
/* THE FIX FIRST.
 *
 * The overlaid title had no ceiling. `What are we Going to do about Halloween?`
 * wrapped to four lines at the small size step, covered 37% of the picture and
 * spilled 116px out of the bottom of the tile; three lines and 45px at the
 * middle step. It only ever fitted at the largest one, which is where it was
 * looked at. Two things were wrong and both are here:
 *
 * 1. Nothing clamped the line count, so a long title grew the box.
 * 2. The size did not follow the tile. `.sf-etile__title` is 0,1,0 and
 *    `:root[data-art="3"] .sf-etile__title` is 0,3,0, so at the large step the
 *    original rule won and at the others the title stayed one fixed size while
 *    the picture doubled. Every rule below owns all three steps for that
 *    reason - it is the same specificity trap the album listing hit.
 *
 * Two lines is the clamp everywhere. A third line of title on a picture is the
 * point where the tile stops being a picture with a name on it. */
.sf-etile__title {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
}
:root .sf-etile__title                  { font-size: var(--sf-size-8); }
:root[data-art="2"] .sf-etile__title    { font-size: var(--sf-size-7); }
:root[data-art="3"] .sf-etile__title    { font-size: var(--sf-row-title-md); }

/* The logline the tile now emits. The poster wall does not use it: at 150-320px
   a sentence under a two-line title is more words than picture. */
.sf-etile__sub { display: none; }


/* ------------------------------------------------------------- cinematic -- */
/* Three to a row, and the size control is gone.
 *
 * Not hidden as a tidy-up: a fixed count is what lets this page be sharp. With
 * a slider the tile could be anything from 400 to 1100 CSS px and the artwork
 * had to be served for the worst case or be soft at the top of the range.
 * Pinned at three, the tile is about 435px on a normal desktop, which is one
 * known number that a rung can be cut for - 720 at 1x and 1440 at 2x.
 *
 * The other two treatments keep their slider. This one traded it for
 * resolution, which on a page whose whole argument is the artwork is the right
 * side of that trade. */
.sf-page--cinematic .sf-sizer { display: none; }

.sf-page--cinematic .sf-egrid {
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 22px;
}
/* Three is the intent, not a rule to break the layout over: below about 1100px
   three columns puts the tile under 340px and the logline stops fitting. */
@media (max-width: 1100px) {
  .sf-page--cinematic .sf-egrid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 700px) {
  .sf-page--cinematic .sf-egrid { grid-template-columns: minmax(0, 1fr); }
}

.sf-page--cinematic .sf-etile { aspect-ratio: 3 / 2; }
.sf-page--cinematic .sf-etile__art > .sf-etile__pic { aspect-ratio: 3 / 2; }

/* The rungs this page actually needs.
 *
 * The tile published the row's pair - 240 at 1x, 480 at 2x - so a 435px tile
 * was being painted from a 240px file. That is the blur, and it is the whole
 * reason this block exists.
 *
 * `:not(.sf-cover):not(...--none)` is not decoration. An undefined custom
 * property inside image-set() is invalid at computed-value time, which resolves
 * background-image to `unset` and leaves a blank square - the tile's own
 * comment records this happening once already. Both excluded elements are real
 * cases that carry no `--e-*` at all: an episode with no artwork falls back to
 * the album sleeve, which publishes `--c-*`, and one with neither gets a plain
 * panel. Without the guard this rule would blank both. */
.sf-page--cinematic .sf-etile__art > .sf-etile__pic:not(.sf-cover):not(.sf-etile__pic--none) {
  background-image: image-set(var(--e-xl) 1x, var(--e-xxl) 2x);
}

/* A deeper scrim, because there is prose on it now and prose needs a ground
   that does not depend on the picture being dark in that corner. */
.sf-page--cinematic .sf-etile__art::after {
  background: linear-gradient(to top,
      rgb(var(--sf-bg-rgb) / 0.96) 0%,
      rgb(var(--sf-bg-rgb) / 0.86) 22%,
      rgb(var(--sf-bg-rgb) / 0.46) 44%,
      rgb(var(--sf-bg-rgb) / 0.10) 62%,
      transparent 76%);
}

/* Two lines of --sf-size-8 at 1.5 is 39px, plus the 20px it sits off the
   floor. The title is anchored above that rather than to the tile's floor, so
   a one-line logline and a two-line one do not put the title at two different
   heights across a row. */
.sf-page--cinematic .sf-etile { --sf-etile-sub-h: 46px; }

.sf-page--cinematic .sf-etile__title {
  left: 22px;
  right: 22px;
  top: auto;
  bottom: calc(22px + var(--sf-etile-sub-h, 0px));
  font-family: var(--sf-display);
  font-size: var(--sf-size-4);
  line-height: 1.1;
  letter-spacing: -0.015em;
}
@media (max-width: 1100px) {
  .sf-page--cinematic .sf-etile__title { font-size: var(--sf-size-5); }
}

.sf-page--cinematic .sf-etile__sub {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  position: absolute;
  left: 22px;
  right: 22px;
  bottom: 20px;
  z-index: 2;
  font-size: var(--sf-size-8);
  font-weight: var(--sf-weight-body);
  line-height: 1.5;
  color: var(--sf-muted);
  text-shadow: 0 1px 3px rgb(var(--sf-bg-rgb) / 0.8);
}

.sf-page--cinematic .sf-etile__num { top: 16px; left: 16px; font-size: var(--sf-size-10); }
.sf-page--cinematic .sf-etile__chip { top: 16px; right: 16px; width: 44px; height: 44px; }


/* ------------------------------------------------------------- editorial -- */
/* The wall as a magazine spread.
 *
 * The other two put the words on the picture. This one takes them off it,
 * because that is the trade a magazine makes: the image is a plate, the caption
 * is type, and neither is asked to be legible through the other. It buys real
 * typography - a number, a name and a sentence that can all be read at their
 * own weight - at the cost of the picture no longer running to the tile edge.
 *
 * Every seventh tile is a feature at double width, which is what stops a page
 * of equal rectangles reading as a spreadsheet. `7n + 1` rather than `7n` so
 * the first tile on the page is the feature. */
.sf-page--editorial .sf-egrid {
  grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
  gap: 34px 24px;
  align-items: start;
}
:root[data-art="2"] .sf-page--editorial .sf-egrid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
:root[data-art="3"] .sf-page--editorial .sf-egrid { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); }

.sf-page--editorial .sf-etile {
  display: flex;
  flex-direction: column;
  gap: 9px;
  contain-intrinsic-size: auto 300px;
}
.sf-page--editorial .sf-etile__art { box-shadow: none; border-radius: var(--sf-radius); }
.sf-page--editorial .sf-etile:hover .sf-etile__art { box-shadow: none; }
.sf-page--editorial .sf-etile__art > .sf-etile__pic { aspect-ratio: 3 / 2; }
/* No scrim: nothing is written on the picture here. */
.sf-page--editorial .sf-etile__art::after { background: none; }

/* Back into the flow, under the plate. */
.sf-page--editorial .sf-etile__num {
  position: static;
  padding: 0;
  background: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  font-family: var(--sf-body);
  font-weight: var(--sf-weight-label);
  font-size: var(--sf-size-11);
  letter-spacing: 0.1em;
  font-variant-numeric: tabular-nums;
  color: var(--sf-muted3);
}
.sf-page--editorial .sf-etile__title {
  position: static;
  margin: -4px 0 0;
  font-family: var(--sf-display);
  line-height: 1.15;
  letter-spacing: -0.01em;
  text-shadow: none;
  color: var(--sf-head);
}
:root .sf-page--editorial .sf-etile__title               { font-size: var(--sf-size-6); }
:root[data-art="2"] .sf-page--editorial .sf-etile__title { font-size: var(--sf-size-5); }
:root[data-art="3"] .sf-page--editorial .sf-etile__title { font-size: var(--sf-album-title-sm); }

.sf-page--editorial .sf-etile__sub {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
  line-clamp: 3;
  overflow: hidden;
  font-size: var(--sf-size-8);
  font-weight: var(--sf-weight-body);
  line-height: 1.55;
  color: var(--sf-muted2);
}

/* The chip has a corner to sit in again now the picture carries nothing. */
.sf-page--editorial .sf-etile__chip {
  top: auto;
  bottom: 8px;
  right: 8px;
  width: 30px;
  height: 30px;
}

/* The feature. Double width, a wider plate, and the type steps up with it -
   otherwise it is just the same tile stretched, which reads as a mistake. */
.sf-page--editorial .sf-egrid > .sf-etile:nth-child(7n + 1) { grid-column: span 2; }
.sf-page--editorial .sf-egrid > .sf-etile:nth-child(7n + 1) .sf-etile__art > .sf-etile__pic { aspect-ratio: 21 / 9; }
:root .sf-page--editorial .sf-egrid > .sf-etile:nth-child(7n + 1) .sf-etile__title               { font-size: var(--sf-size-5); }
:root[data-art="2"] .sf-page--editorial .sf-egrid > .sf-etile:nth-child(7n + 1) .sf-etile__title { font-size: var(--sf-size-4); }
:root[data-art="3"] .sf-page--editorial .sf-egrid > .sf-etile:nth-child(7n + 1) .sf-etile__title { font-size: var(--sf-size-4); }
.sf-page--editorial .sf-egrid > .sf-etile:nth-child(7n + 1) .sf-etile__sub { font-size: var(--sf-size-7); -webkit-line-clamp: 2; line-clamp: 2; }

/* One column wide, a feature spanning two would be a second column that is not
   there. Grid clamps the span, but the 21:9 plate would still be a letterbox
   sliver, so the feature simply stops being one. */
@media (max-width: 620px) {
  .sf-page--editorial .sf-egrid > .sf-etile:nth-child(7n + 1) { grid-column: auto; }
  .sf-page--editorial .sf-egrid > .sf-etile:nth-child(7n + 1) .sf-etile__art > .sf-etile__pic { aspect-ratio: 3 / 2; }
}


/* --------------------------------------------- the tile must not stretch -- */
/* The overlaid title was landing below the picture at the small and middle
 * size steps - up to 116px below it - and the cause was not the title.
 *
 * `.sf-etile` carries `content-visibility: auto` with `contain-intrinsic-size:
 * auto 320px`, so a tile the browser has not rendered yet reports itself as
 * 320px tall. Grid items stretch to their row by default, so one unrendered
 * sibling made the whole row 320px, every tile in it 320px, and the title -
 * which is positioned against the TILE, not against the picture - hung in the
 * space underneath. At the large step the art is 406px, taller than 320, so
 * nothing stretched and nothing looked wrong. That is why it only showed up
 * at the smaller sizes.
 *
 * `align-items: start` is the fix: the tile is then as tall as its picture and
 * the title has the picture's floor to sit on. The estimate still does its real
 * job of keeping the scrollbar honest. */
.sf-egrid { align-items: start; }

/* `align-items: start` was not enough: the 320px is the tile's OWN box, handed
 * to it by `contain-intrinsic-size` while `content-visibility: auto` is in
 * play, not a row stretching it. The title is positioned against the tile, so
 * wherever the tile's floor is, that is where the title goes - and at the large
 * step the picture is 406px, taller than the estimate, which is precisely why
 * this only ever looked wrong at the smaller sizes.
 *
 * Giving the tile the same ratio as the picture inside it makes the two boxes
 * the same box. The estimate then has nothing left to be wrong about. */
.sf-etile { aspect-ratio: 4 / 5; }
.sf-page--cinematic .sf-etile { aspect-ratio: 3 / 2; }
/* Editorial sets its type below the plate, so its tile is as tall as picture
   plus caption and must keep its natural height. */
.sf-page--editorial .sf-etile { aspect-ratio: auto; }

/* And the estimate itself has to go.
 *
 * With `content-visibility: auto` the tile takes `contain-intrinsic-size` as
 * its height whenever that is the larger number, so 320px was a FLOOR: at the
 * large step the picture is 406px and the tile fitted it, at the middle step
 * 264px and the tile stayed 320. Hence a title 45px adrift at one size and
 * correct at the next, which is what "the highlighting doesn't work with the
 * current sizing" was.
 *
 * The estimate existed to keep the scrollbar honest over a long list. It is not
 * needed now: `aspect-ratio` above gives every tile a height that follows from
 * the column width, so the browser can lay the grid out without guessing, and
 * the listing paginates at 100 tiles rather than running to 800. Correctness
 * over a micro-optimisation that was buying nothing here. */
.sf-etile {
  content-visibility: visible;
  contain-intrinsic-size: auto;
}

/* Grouped, on the cinematic wall.
 *
 * The grouped view nests its grid inside a section that already insets it, and
 * `--grouped` was padded for tiles a third of this size. The section head is
 * the album, so it is worth more than the 11px label a poster wall wants. */
.sf-page--cinematic .sf-egrid--grouped { padding: 6px 0 22px; }
.sf-page--cinematic .sf-section__head h2 { font-size: var(--sf-size-5); }

/* The year slider, in a controls row that is otherwise pills. It is the only
   control here with a track, so it gets the room to be one. */
.sf-page--cinematic .sf-years { min-width: 260px; }


/* ============================================================ where it crops */
/* The artwork is square - every source is 2048x2048 - and every treatment here
 * is not. The poster wall's 4:5 is TALLER than square so it trims the sides and
 * keeps every pixel of height; the cinematic 3:2 and editorial 21:9 are wider,
 * so they trim the top and the bottom instead. That is where the faces go.
 *
 * `background-position: center` splits the loss evenly, which is the wrong
 * split for this art: the prompts put the subject centre-frame WITH HEADROOM,
 * so the top of the frame holds sky, ceiling and hair, the bottom holds floor -
 * and a scrim is painted over the bottom third anyway. Biasing the window
 * upward spends the crop on the half that carries least.
 *
 * At 3:2 the visible window is 66.7% of the height, and the top of it lands at
 * `focus x 0.333`. So 50% starts at 16.7% - into the top of a head - and 35%
 * starts at 11.7%, which clears it.
 *
 * One token, tuned in one place. Lower it to keep more of the top, raise it
 * toward 50% to go back to an even split. If individual episodes still crop
 * badly this is the hook a per-episode focal point would hang off, set as an
 * inline `--sf-epart-focus` on the tile - but try the global number first. */
:root { --sf-epart-focus: 35%; }

.sf-etile__art > .sf-etile__pic { background-position: center var(--sf-epart-focus); }



/* ============================================================ episode hero == */
/* Built on the square artwork, because the artwork IS square.
 *
 * Every source is 2048 square and every rung preserves it, so the picture
 * already exists in its own shape at 720 and 1440. The walls crop it - a grid
 * needs one shape and the tile has to tessellate - but a page has no such
 * constraint, so here it is shown whole. No crop, no focal point to get wrong,
 * and the composition it was drawn with.
 *
 * The plate sits on a wash made from the same file: blown up, blurred and
 * dimmed, bled to the full width of the window. It is the same URL the plate
 * uses, so it costs no second request, and it gives the page a ground made out
 * of this episode instead of a band of flat colour. */
.sf-ehero {
  position: relative;
  /* Full-bleed out of the page's 1040px column. 100vw rather than 100% because
     the element is inside that column and has to escape it; the negative
     margin is half the difference. */
  /* The window, less the scrollbar. Plain 100vw includes the gutter and so
     overflows a centred page by its width - see the note in app.js, which
     measures it into `--sf-scrollbar`. The fallback of 0 is right both before
     the script runs and on platforms with overlay scrollbars. */
  width: calc(100vw - var(--sf-scrollbar, 0px));
  margin-left: calc(50% - (100vw - var(--sf-scrollbar, 0px)) / 2);
  /* No bottom margin. It used to have 30, on top of its own 30 of padding, on
     top of the first section's 26 - three separate spacings stacking into 114px
     of nothing between the artwork and the portraits. The section below owns
     the separation now; the hero owns only the room its own wash needs to fade
     out in. */
  margin-bottom: 0;
  /* Less at the top: the crumbs sit inside now and bring their own room. */
  padding: 14px 0 22px;
  overflow: hidden;
}

.sf-ehero__wash {
  position: absolute;
  inset: -10%;
  z-index: 0;
  background-image: var(--e-xl);
  background-size: cover;
  background-position: center;
  filter: blur(64px) saturate(1.25);
  opacity: 0.55;
  transform: scale(1.2);
}
/* The wash has to end in the page, not at an edge: a hard cut across the window
   reads as a broken image. Fading to --sf-bg on all four sides is what makes it
   a ground rather than a banner. */
.sf-ehero::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background:
    linear-gradient(to bottom, rgb(var(--sf-bg-rgb) / 0.55) 0%, rgb(var(--sf-bg-rgb) / 0.20) 40%, rgb(var(--sf-bg-rgb) / 0.96) 100%),
    linear-gradient(to right, rgb(var(--sf-bg-rgb) / 0.85) 0%, rgb(var(--sf-bg-rgb) / 0) 22%, rgb(var(--sf-bg-rgb) / 0) 78%, rgb(var(--sf-bg-rgb) / 0.85) 100%);
}

/* The hero's content box is the PAGE's content box.
 *
 * It has to be, or the plate and the portraits under it start at different
 * left edges and the summary stops short of where the cast row ends. The hero
 * bleeds to the window, so the box has to be rebuilt inside it rather than
 * inherited: same 1040 as `.sf-page--reader`, same `--sf-page-pad`, which is
 * also why the pad is the token and not the 24px this used to guess at. */
/* The crumbs row: same box as the plate below it, so the trail starts exactly
   where the artwork does. */
.sf-ehero__top {
  position: relative;
  z-index: 2;
  max-width: var(--sf-epage-max);
  margin: 0 auto 18px;
  padding: 0 var(--sf-page-pad);
}
/* It came out of a page that gave it a bottom rule and its own margins. On the
   wash it needs neither - the hero already separates itself from what follows. */
.sf-ehero__top .sf-crumbs { margin: 0; border: 0; padding: 0; }

.sf-ehero__inner {
  position: relative;
  z-index: 2;
  max-width: 1040px;
  margin: 0 auto;
  padding: 0 var(--sf-page-pad);
  display: grid;
  grid-template-columns: minmax(0, 340px) minmax(0, 1fr);
  gap: 34px;
  align-items: center;
}

/* The picture, whole. */
.sf-ehero__plate {
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
  border-radius: var(--sf-radius-lg);
  background-image: image-set(var(--e-xl) 1x, var(--e-xxl) 2x);
  background-size: cover;
  background-position: center;
  box-shadow: 0 0 0 1px rgb(var(--sf-bg-rgb) / 0.5), 0 26px 60px rgba(0, 0, 0, 0.62);
}

.sf-ehero__meta { min-width: 0; }

.sf-ehero__kicker {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  margin: 0 0 10px;
  font-size: var(--sf-size-10);
  font-weight: var(--sf-weight-label);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--sf-muted);
}
.sf-ehero__num {
  padding: 3px 8px;
  border-radius: var(--sf-radius-pill);
  background: rgb(var(--sf-bg-rgb) / 0.55);
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
  font-family: var(--sf-display);
  letter-spacing: 0.05em;
  color: var(--sf-pop-ink);
}
.sf-ehero__fact--quiet { color: var(--sf-muted3); }
.sf-ehero__sep {
  width: 3px; height: 3px; flex: none;
  border-radius: 50%;
  background: var(--sf-muted3);
}

.sf-ehero__title {
  margin: 0 0 12px;
  max-width: 24ch;
  font-family: var(--sf-display);
  font-size: var(--sf-size-2);
  line-height: 1.02;
  letter-spacing: -0.02em;
  color: var(--sf-head);
  text-shadow: 0 2px 16px rgb(var(--sf-bg-rgb) / 0.55);
}

/* The part marker, beside the title rather than inside it. See the note on
   `.sf-etile__part` for why the words come out of the name. */
.sf-ehero__part {
  display: inline-block;
  margin: 0 0 12px;
  padding: 4px 11px;
  border-radius: var(--sf-radius-pill);
  background: rgb(var(--sf-bg-rgb) / 0.55);
  box-shadow: inset 0 0 0 1px var(--sf-rule3);
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-label);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--sf-text2);
}

/* The summary, at reading size. It is the one piece of prose the page shows
   without being asked, so it is set to be read rather than skimmed. */
/* No measure cap. 54ch was about 420px inside a 618px column, which left a
   couple of hundred pixels of nothing down the right-hand side of every hero -
   the one place on the page where emptiness reads as a mistake rather than as
   air. The column is already the constraint. */
.sf-ehero__standfirst {
  font-size: var(--sf-size-7);
  font-weight: var(--sf-weight-body);
  line-height: 1.6;
  color: var(--sf-text2);
}
.sf-ehero__standfirst p + p { margin-top: 9px; }

/* Where to find it, in the hero. One of the three things a reader comes here
   for, and the rail is a column away and below the fold. */
.sf-ehero__album {
  /* flex, not inline-flex: this is a card across the column. Inline-flex sized
     it to its own text, which beside a 618px measure read as a stray tag
     rather than as the answer to "where do I find this". */
  display: flex;
  width: 100%;
  align-items: center;
  gap: 14px;
  margin-top: 20px;
  padding: 10px 16px 10px 10px;
  border-radius: var(--sf-radius);
  background: rgb(var(--sf-bg-rgb) / 0.5);
  box-shadow: inset 0 0 0 1px var(--sf-rule2);
  backdrop-filter: blur(9px);
  -webkit-backdrop-filter: blur(9px);
  color: var(--sf-text);
  transition: background 140ms ease-out;
}
.sf-ehero__album:hover { background: rgb(var(--sf-bg-rgb) / 0.72); }
.sf-ehero__album .sf-cover { width: 58px; height: 58px; border-radius: 6px; flex: none; }
.sf-ehero__albumtext { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.sf-ehero__albumtitle {
  font-family: var(--sf-display);
  font-size: var(--sf-size-6);
  line-height: 1.15;
  color: var(--sf-head);
}
.sf-ehero__album:hover .sf-ehero__albumtitle { color: var(--sf-accent-ink); }

/* The shelf mark and the size, under the title. */
.sf-ehero__albummeta {
  font-size: var(--sf-size-10);
  font-weight: var(--sf-weight-label);
  letter-spacing: 0.04em;
  color: var(--sf-muted3);
}

/* The arrow holds the far end, which is what stops a short album title leaving
   the card looking padded rather than filled. */
.sf-ehero__albumgo {
  margin-left: auto;
  flex: none;
  font-size: var(--sf-size-6);
  color: var(--sf-muted3);
  transition: transform 140ms ease-out, color 140ms ease-out;
}
.sf-ehero__album:hover .sf-ehero__albumgo { color: var(--sf-accent-ink); transform: translateX(3px); }


/* The header under the hero has lost its title, kicker and standfirst. */
.sf-reader__header--hero { padding-top: 0; }


/* ------------------------------------------------------------- disclosure -- */
/* The synopsis and the transcript, folded away.
 *
 * The page was a hero and then eleven screens of prose. The hero carries the
 * summary, so the story is already there for a reader who wants it; the
 * article and the transcript are for the one who wants all of it, and they ask.
 *
 * A real <details>: it works with scripting off, it is keyboard-operable and
 * announced without ARIA, and it is the element that means this. */
.sf-disclose { border-top: 1px solid var(--sf-rule); padding-top: 16px; }
.sf-disclose__head {
  display: flex;
  align-items: baseline;
  gap: 12px;
  cursor: pointer;
  list-style: none;
  padding: 4px 0;
}
.sf-disclose__head::-webkit-details-marker { display: none; }
/* The chevron is drawn here rather than left to the browser's own marker, which
   is a different glyph in each of them and cannot be positioned. */
.sf-disclose__head::after {
  content: '';
  width: 7px;
  height: 7px;
  margin-left: -4px;
  border-right: 2px solid var(--sf-muted2);
  border-bottom: 2px solid var(--sf-muted2);
  transform: rotate(45deg) translateY(-2px);
  transition: transform 160ms ease-out;
}
.sf-disclose[open] > .sf-disclose__head::after { transform: rotate(-135deg) translateY(-2px); }
.sf-disclose__head:hover .sf-label { color: var(--sf-accent-ink); }
.sf-disclose__head:hover::after { border-color: var(--sf-accent-ink); }
.sf-disclose__note {
  margin-left: auto;
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-label);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--sf-muted3);
}
.sf-disclose > .sf-prose { margin-top: 14px; }


/* ------------------------------------------------- the part marker on a tile */
/* "…, Part 1 of 2" is up to fourteen characters of a title that are not the
 * title. On a wall of 99 tiles it was the difference between one line and two
 * on most multi-part stories, and there are a lot of them.
 *
 * So the words come out of the name and the fact goes to the top right of the
 * picture, opposite the episode number: same corner treatment, same blurred
 * pill, and it reads as what it is - a position in a set - instead of as the
 * end of a sentence. Html::splitPart() does the parsing; the title falls
 * through unchanged when there is no part in it. */
.sf-etile__part {
  position: absolute;
  top: 9px;
  right: 9px;
  z-index: 2;
  padding: 3px 8px;
  border-radius: var(--sf-radius-pill);
  background: rgb(var(--sf-bg-rgb) / 0.58);
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-label);
  letter-spacing: 0.06em;
  color: var(--sf-text2);
  white-space: nowrap;
}
/* The album chip shares that corner. Where a tile has both, the chip steps down
   below the marker rather than under it. */
.sf-etile__part ~ .sf-etile__chip { top: 40px; }




/* ------------------------------------------------ the artwork page's width */
/* This page stopped being a reader when the artwork took it over.
 *
 * `.sf-page--reader` caps at 1040px, which is a measure - the width prose wants
 * to be read at. That is right for the album and character readers, whose main
 * column IS an article. Here the article is folded away by default and what the
 * page actually holds is a 2048px picture, a row of six portraits and a
 * thirteen-tile carousel, none of which are prose and all of which were being
 * squeezed into a column sized for paragraphs while 250px sat empty either side.
 *
 * One token, because the hero has to rebuild the same box after bleeding to the
 * window - two numbers that must agree is exactly how they drift apart. */
:root { --sf-epage-max: 1240px; }

.sf-page--artwork { max-width: var(--sf-epage-max); }
.sf-ehero__inner { max-width: var(--sf-epage-max); }

/* The plate grows with the page rather than staying at the width it needed when
   the column was 1040. */
/* The picture takes the space the prose cannot use.
 *
 * The standfirst is capped at --sf-measure because a 790px line is unreadable
 * however wide the page is - so widening the page handed the text column slack
 * it could not spend. The plate can spend it: it is a 2048px picture and this
 * is the page that exists to show it. */
/* The picture takes ALL the space the prose cannot use.
 *
 * The text column is capped at --sf-measure because a 700px line is unreadable
 * however wide the page is, so a fixed plate left the difference sitting empty
 * to the right of the summary. Reversing which column is fixed spends it: the
 * measure is the fixed side and the plate is `1fr`, so the artwork grows to
 * take whatever the page has and there is no slack left to be dead space. */
.sf-ehero__inner { grid-template-columns: minmax(420px, 1fr) minmax(0, var(--sf-measure)); }

/* The 420 floor is not decoration, it is which column gives way first.
 *
 * With the plate at `1fr` against a rigid measure, every pixel the window loses
 * comes out of the PICTURE - at 1920 it is 531px, but by about 1050 of content
 * it has dropped under the 420 it used to be fixed at, and it keeps going. That
 * is backwards on a page whose argument is the artwork: prose reflows to a
 * tighter measure perfectly well down to about 45ch, a picture just gets
 * smaller. So the plate has a floor and the text column is the one that yields.
 *
 * Below 860px they stack and neither constraint applies. */

/* Nothing inside the column caps itself.
 *
 * `--sf-measure` is 68ch, and `ch` resolves against the FONT-SIZE OF THE
 * ELEMENT IT IS ON - so the same token gave the 14px standfirst about 476px and
 * the larger album card about 503px, and the two ended at different places down
 * the right-hand side. The column is already the measure; the children just
 * fill it, and one edge is one edge. */

/* Prose keeps its measure even though the page no longer has one.
 *
 * The whole point of widening was that the page is not an article - but the
 * synopsis inside the fold still is, and a 1,200px line is unreadable whatever
 * the container is doing. `--sf-measure` is the token the site already uses for
 * exactly this. */
.sf-page--artwork .sf-prose { max-width: var(--sf-measure); }
/* The column is the measure - see the note above the grid. */


/* ----------------------------------------------------- the step buttons -- */
/* 30x26 was small for a control, and on the episode page it is now the only
 * text pager there is - the run carousel replaced the one at the foot. A
 * 36x30 hit area is still compact enough for a breadcrumb line and is a good
 * deal easier to actually press, on a trackpad or a thumb.
 *
 * Global rather than scoped: the same control sits in the album reader, the
 * character reader and the drawer's own bar, and it was equally small in all
 * of them. */
.sf-step {
  width: 36px;
  height: 30px;
  font-size: var(--sf-size-7);
}


/* The page's own top padding is the last of that collapsed band: with the
   crumbs inside the hero there is nothing above it to space away from. */
.sf-page--artwork { padding-top: 0; }


/* ------------------------------------------------------------- narrow -- */
/* LAST IN THE FILE, and that is the whole point.
 *
 * This block used to sit above the desktop grid rules. Both are
 * `.sf-ehero__inner` - the same specificity - so the later one wins, and the
 * later one was `minmax(420px, 1fr) minmax(0, var(--sf-measure))`. A media
 * query does not raise specificity; it only decides whether a rule applies.
 * So at 410px the desktop grid still won: the plate took its 420px floor out
 * of a 410px viewport, the text column computed to ZERO, and the summary
 * wrapped a character at a time into a hero 3,944px tall.
 *
 * Anything that overrides a responsive rule has to come before it. */
@media (max-width: 860px) {
  /* One column. The floor goes with it - a minimum width is a desktop idea,
     and here the picture is simply as wide as the page allows, capped so a
     square does not become the entire first screen. */
  .sf-ehero__inner {
    grid-template-columns: minmax(0, 1fr);
    gap: 20px;
  }
  /* Centred, not left-aligned. Stacked, the plate is capped at 320 inside a
     column that is the full width of the page, so left-aligning it hangs the
     picture off one edge with a band of nothing beside it. The text under it
     stays ranged left - it is prose, and centred prose is unreadable - so only
     the picture moves. */
  .sf-ehero__plate { max-width: 320px; margin-inline: auto; }
  .sf-ehero__title { font-size: var(--sf-size-3); max-width: none; }
}

/* The full-bleed hero overflowed the page by the width of the scrollbar.
 *
 * `100vw` is the viewport INCLUDING the scrollbar, while the page is only as
 * wide as the content box - so a 100vw child of a centred column is always
 * about 8px too wide on a desktop, and that 8px is a horizontal scrollbar on
 * every episode page. `clip` rather than `hidden`: hidden makes the element a
 * scroll container, which breaks `position: sticky` on anything inside it -
 * and this site has a sticky header. */
/* Belt and braces behind the calc above: anything else that bleeds wide by a
   pixel or two should not become a sideways scroll either. */
html { overflow-x: clip; }

/* The header with no picture in it.
 *
 * 254 episodes have no artwork, and for them this is simply the page's header:
 * no wash to sit on, no plate beside the words, one column. It keeps the hero's
 * spacing and its full-bleed box so the page below starts in the same place
 * whether or not there is a picture - what changes is what is in it, not where
 * anything sits. */
.sf-ehero--bare { padding: 14px 0 26px; }
.sf-ehero--bare .sf-ehero__inner { grid-template-columns: minmax(0, 1fr); }
.sf-ehero--bare::after { background: none; }


/* ========================================================== episode page == */
/* Sections, not a scroll.
 *
 * The page below the hero is a stack of blocks, each answering one question and
 * separated by a rule and real space. Before this it was a folded synopsis, an
 * eighteen-row credits table and a pager, in that order, with no heading
 * telling a reader which was which. */
.sf-epage {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

/* The section owns the space above it, and it is the only thing that does -
   see the note on `.sf-ehero`'s margin. */
.sf-esec { padding: 24px 0 0; }
.sf-esec + .sf-esec { border-top: 1px solid var(--sf-rule); }

.sf-esec__head {
  display: flex;
  align-items: baseline;
  gap: 12px;
  margin: 0 0 16px;
  font-family: var(--sf-body);
  font-size: var(--sf-size-10);
  font-weight: var(--sf-weight-title);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--sf-muted2);
}
/* The section heading is a label, so the figure beside it is a quieter one
   still - and the link to the whole album goes to the far end, where a "see
   all" belongs. */
.sf-esec__note {
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-label);
  letter-spacing: 0.06em;
  color: var(--sf-muted3);
}
.sf-esec__all {
  margin-left: auto;
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-label);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--sf-accent-ink);
}
.sf-esec__all:hover { color: var(--sf-head); }


/* --------------------------------------------------------------- the cast -- */
/* Portraits, first, and above the prose.
 *
 * "Who is in it" is the question a picture makes somebody ask, and it was being
 * answered by a two-column list of names four screens down. A face is also the
 * one piece of art the site already had for characters and never used at this
 * size: 213 of them have a portrait and the rest render a gradient monogram, so
 * a row of six is never ragged whichever way it falls. */
.sf-castrow {
  display: grid;
  grid-template-columns: repeat(6, minmax(0, 1fr));
  gap: 18px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.sf-castcard { display: flex; flex-direction: column; gap: 4px; min-width: 0; }

.sf-castcard__link {
  display: flex;
  flex-direction: column;
  gap: 9px;
  color: var(--sf-text);
  min-width: 0;
}
/* The portrait fills the column rather than taking a fixed size: six across a
   1,040px page is about 155px, and the face partial's `lg` is smaller than
   that. Letting it grow is what makes this a row of pictures and not a row of
   avatars with captions. */
/* `.sf-face` is an inline-flex row - an avatar beside a name - because every
   other place on the site uses it at 26 to 56px in a line of text. Here it is
   the picture, so it becomes a block that fills its column, and the avatar
   inside it stops being 56px and becomes the column too. Both need more
   specificity than `.sf-avatar--lg`, which sets a hard width. */
.sf-castcard__link .sf-face { display: block; width: 100%; }
.sf-castcard__link .sf-avatar {
  width: 100%;
  height: auto;
  aspect-ratio: 1 / 1;
  font-size: var(--sf-size-2);
  border-radius: var(--sf-radius);
  transition: box-shadow 140ms ease-out;
}
.sf-castcard__link:hover .sf-avatar { box-shadow: 0 0 0 2px var(--sf-accent-ink); }
/* The monogram is a fallback, not a portrait, and at 145px its saturated fill
   shouts over the drawn faces beside it - which is exactly backwards, since the
   drawn face is the one carrying information. Pulled back enough to sit in the
   row; the hue still identifies the character. */
.sf-castcard__link .sf-avatar:not(:has(.sf-avatar__img)) { filter: saturate(0.72) brightness(0.86); }

.sf-castcard__char {
  font-family: var(--sf-display);
  font-size: var(--sf-size-8);
  line-height: 1.2;
  color: var(--sf-head);
}
.sf-castcard__link:hover .sf-castcard__char { color: var(--sf-accent-ink); }

/* Teal, the ink this site has always set actor names in. */
.sf-castcard__actor {
  font-size: var(--sf-size-10);
  font-weight: var(--sf-weight-label);
  line-height: 1.3;
  color: var(--sf-teal-ink);
}
.sf-castcard__actor:hover { color: var(--sf-head); }
.sf-castcard__actor--none { color: var(--sf-faint); font-style: italic; font-weight: var(--sf-weight-body); }

@media (max-width: 900px) { .sf-castrow { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
@media (max-width: 560px) { .sf-castrow { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } }


/* ------------------------------------------------------------- the story -- */
/* The opening paragraph is on the page; the rest is behind a control.
 *
 * Two earlier attempts were wrong. Folding the whole synopsis left the section
 * as a heading and a button, which made the page look empty - it is the best
 * writing on the site. Capping the height inside a closed <details> showed
 * nothing at all, because a closed <details> hides its content through
 * `::details-content` and no rule on the child can reach that.
 *
 * Splitting the article needs neither trick: the lede is ordinary prose, the
 * fold lands on a paragraph boundary instead of mid-sentence, and the whole
 * thing still works with scripting off. */
.sf-longread__lede { margin: 0; }
.sf-longread__lede p:first-child { margin-top: 0; }

.sf-longread { margin-top: 4px; }
.sf-longread__more {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin-top: 10px;
  padding: 7px 15px;
  border-radius: var(--sf-radius-pill);
  box-shadow: inset 0 0 0 1px var(--sf-rule3);
  cursor: pointer;
  list-style: none;
  font-size: var(--sf-size-10);
  font-weight: var(--sf-weight-label);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--sf-text2);
  transition: background 140ms ease-out, box-shadow 140ms ease-out, color 140ms ease-out;
}
.sf-longread__more::-webkit-details-marker { display: none; }
.sf-longread__more::after {
  content: '';
  width: 6px;
  height: 6px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg) translateY(-2px);
  transition: transform 160ms ease-out;
}
.sf-longread__more:hover {
  background: var(--sf-panel);
  box-shadow: inset 0 0 0 1px var(--sf-accent-ink);
  color: var(--sf-head);
}
.sf-longread[open] .sf-longread__more { margin-bottom: 6px; }
.sf-longread[open] .sf-longread__more::after { transform: rotate(-135deg) translateY(-1px); }


/* ------------------------------------------------- the rest of the album -- */
/* A scroller, because an album is twelve and the page has room for four.
 *
 * Wrapping them into three rows of four turns a footnote into a second listing
 * page; scrolling keeps it a shelf. Scroll snap so it stops on a tile rather
 * than halfway through one, and the tiles keep the wall's own proportions so
 * the row reads as the same object seen smaller. */
.sf-eshelf {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(210px, 1fr);
  gap: 14px;
  overflow-x: auto;
  scroll-snap-type: x proximity;
  padding-bottom: 10px;
  /* The scrollbar is the only affordance saying there is more, so it stays -
     but thin, and in the palette rather than the platform's. */
  scrollbar-width: thin;
  scrollbar-color: var(--sf-rule3) transparent;
}
/* The tiles are wrapped now, so the wrapper is the grid child. */
.sf-eshelf__step { min-width: 0; scroll-snap-align: start; position: relative; }

/* Where you are.
 *
 * This is the pager's job, done by the carousel: the tile either side of the
 * marked one IS Previous and Next, and the other ten steps came free. Marked
 * with a ring and a label rather than by removing the link - the current
 * episode staying clickable is what lets the browser's own back/forward and a
 * bookmarked link behave normally. */
.sf-eshelf__step--here .sf-etile__art {
  box-shadow: 0 0 0 2px var(--sf-accent-ink), var(--sf-shadow-cover);
}
.sf-eshelf__step--here::after {
  content: 'You are here';
  position: absolute;
  left: 8px;
  bottom: -20px;
  font-size: var(--sf-size-11);
  font-weight: var(--sf-weight-label);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--sf-accent-ink);
}
/* Room for the ring, and for the label under the row.
 *
 * The ring is a box-shadow on the tile and the shelf is `overflow-x: auto`,
 * which makes it a clipping box - and setting overflow on one axis computes the
 * other to `auto` too, so it clipped the highlight on all four sides. The tile
 * at the scroll edge lost it entirely.
 *
 * Padding gives the shadow somewhere to be drawn; the negative inline margin
 * pulls the container back out so the first tile still starts on the section's
 * own left edge rather than 8px inside it. */
.sf-esec--run .sf-eshelf {
  padding: 8px 8px 30px;
  margin-inline: -8px;
}
/* The shelf's own crop: 3:2, the cinematic proportion, at a size where a
   16:9 would be a letterbox and a square would be a wall of squares. */
.sf-eshelf .sf-etile,
.sf-eshelf .sf-etile__art > .sf-etile__pic { aspect-ratio: 3 / 2; }
.sf-eshelf .sf-etile__title { font-size: var(--sf-size-8); }
.sf-eshelf .sf-etile__sub { display: none; }

/* The cost of opening the fold, on the control itself. */
.sf-longread__cost {
  padding: 2px 8px;
  border-radius: var(--sf-radius-pill);
  background: rgb(var(--sf-bg-rgb) / 0.45);
  box-shadow: inset 0 0 0 1px var(--sf-rule2);
  font-size: var(--sf-size-11);
  color: var(--sf-muted3);
}


/* ----------------------------------------------------------- on a phone -- */
/* These come after everything they override, for the reason spelled out at the
   foot of the hero block: a media query does not raise specificity. */
@media (max-width: 620px) {
  /* Two portraits a row, not three. At 410px three columns is 113px a face -
     small enough that the drawn detail is gone and it may as well be a
     monogram, which is the opposite of the point. */
  .sf-castrow { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 14px; }

  /* The run shelf's tiles were sized for a desktop row. At 210px only one and
     a half fit, which reads as a broken grid rather than as a shelf; 150px
     shows two and the edge of a third, which is what says "this scrolls". */
  .sf-eshelf { grid-auto-columns: minmax(150px, 1fr); }
  .sf-esec--run .sf-eshelf { padding: 8px 8px 26px; }

  /* Sections need less air between them when the whole page is one column. */
  .sf-esec { padding: 20px 0 0; }
  .sf-esec__head { margin-bottom: 12px; }

  /* The crumb trail and its arrows on one line, with the trail allowed to
     shrink rather than push the arrows off the edge. */
  .sf-ehero__top .sf-crumbs { gap: 8px; }

  /* The "found on" card keeps its sleeve but loses the arrow: at this width the
     row is title plus meta plus a chevron competing for 300px, and the whole
     card is already the tap target. */
  .sf-ehero__albumgo { display: none; }
}
