/*
 * cx-base.css — base styles for contexts.live
 * Fonts, reset, shared CSS variables, layout primitives.
 * Always load this first, then a theme file.
 */

@import url('https://fonts.googleapis.com/css2?family=IM+Fell+Great+Primer:ital@0;1&family=EB+Garamond:ital@0;1&family=Source+Sans+3:wght@300;400;600&family=Roboto+Mono:wght@400;700&family=Noto+Serif+SC&family=Voces&family=Bubblegum+Sans&display=swap');

/* ── Shared variables ────────────────────────────────────────────────── */
:root {
    /* ── Fonts ───────────────────────────────────────────────────────
     * Serif (display)  — EB Garamond:        subtitle / display reading text
     * Serif (title)    — IM Fell Great Primer: brand, titles, smatter splash
     * Sans (body)      — Source Sans Pro:    UI, chrome, plain text, braille
     * Sans (control)   — Source Sans Pro:    control / operator surfaces
     * Both sans roles point at Source Sans Pro for now; split by changing one line.
     */
    --cx-font-serif-display: 'EB Garamond', serif;
    --cx-font-serif-title:   'IM Fell Great Primer', serif;
    --cx-font-body:          'Source Sans 3', sans-serif;
    --cx-font-control:       'Source Sans 3', sans-serif;

    --cx-font-mono:          'Roboto Mono', monospace;
    --cx-font-zh:            'Noto Serif SC', serif;
    --cx-font-ipa:           'Voces', sans-serif;
    --cx-font-fun:           'Bubblegum Sans', cursive;

    --cx-base-version:    'B.3.70';

    /* Height of the pinning arm bar. The save control clears it by this much,
     * so the two are never set independently and cannot drift apart. */
    --cx-pin-arm-height:  2.4rem;

    /* Height of an off-view marker bar. Fixed, and cancelled again by an equal
     * negative margin, so the bar takes NO space in the scroll flow: the chips
     * overlay the page instead of pushing it. See .cx-pin-marks. */
    --cx-pin-marks-height: 1.75rem;

    /* Pin marker geometry. em, so both track the pin's own size. The drop is
     * also what lifts the box off its point — set it to 0 with the parked
     * crosshair marker, which sits the box's corner on the point instead. */
    --cx-pin-drop:        0.9em;
    --cx-pin-dot:         0.3em;

    /* Pin box fill. Literal, like the rest of a pin's colours — a pin sits on
     * a white score page whichever theme the operator is running. */
    --cx-pin-bg-color:    #ffe81f;   /* cx-colour-ok */
    --cx-pin-bg-alpha:    62%;

    --cx-radius:          4px;
    --cx-transition-fast: 0.08s;
    --cx-transition-mid:  0.15s;
    --cx-transition-slow: 0.3s;

    --cx-accent:       #c9a227;
    --cx-accent-dim:   #8a7018;
    --cx-negative:     #e84040;
    --cx-negative-dim: #a02a2a;
    --cx-ok:           #44cc44;
    --cx-err:          #cc4444;
    --cx-warn:         #c8a000;
    --cx-media:        #6abfcc;   /* media raw-type badge */

    /* ── Colour roles that hold in both themes. Named rather than literal so
     * a theme can disagree later, and so a hex in a component sheet always
     * means a physical requirement (QR white, video matte, paper) rather
     * than an unthemed colour. */
    --cx-on-accent:       #111;      /* text on an accent-coloured ground */
    --cx-on-brand:        #ffffff;   /* text on brand red */
    --cx-highlight-bg:    #f5e642;   /* search-hit marker */
    --cx-highlight-color: #111;      /* must be explicit — inherit goes
                                      * near-white under a dark theme */

    /* ── Palette white. One hex for antiquewhite, so changing it is one edit.
     * Consumed by --cx-text-strong, --cx-white and --cx-red-color in the dark
     * palette, and by .cx-pane-dark's text under light. */
    --cx-cream:  #faebd7;

    /* ── background= palette — LITERAL, and deliberately never redefined per
     * theme. A colour word names a physical ground here, not a foreground
     * role. --cx-white means "strong foreground" (#faebd7 dark, #222222
     * light), so routing background= through it turned score paper
     * near-black under the light theme; score pages are transparent PNGs
     * and need white paper in either theme.
     *
     * Prefixed --cx-paper- rather than --cx-bg-, because --cx-bg-lighten and
     * --cx-bg-darken already exist, are themed, and "lighten"/"darken" are
     * real style words — background=darken would otherwise land on a themed
     * translucent overlay by accident.
     *
     * Values are the dark palette's, so dark-theme grounds are unchanged.
     * They read pale because they were foreground colours; retune freely,
     * this block is the only place they live. white is the exception —
     * literal #ffffff, with cream keeping antiquewhite paper. */
    --cx-paper-white:    #ffffff;
    --cx-paper-cream:    var(--cx-cream);
    --cx-paper-black:    black;
    --cx-paper-grey:     #aaaaaa;
    --cx-paper-gold:     gold;
    --cx-paper-green:    #8fd6aa;
    --cx-paper-orange:   #e8a84a;
    --cx-paper-red:      #e88888;
    --cx-paper-blue:     #7d9dc4;
    --cx-paper-purple:   #b39ddb;
    --cx-paper-pink:     #e198a4;
    --cx-paper-yellow:   #fff176;
    --cx-paper-brown:    #c49a6c;
    --cx-paper-cyan:     #7fd6e0;
    --cx-paper-contrast: #ffffff;

    /* ── Component colours previously hardcoded per theme.
     * Defaults are the dark values; cx-theme-light.css overrides those that
     * differ, exactly as it already does for --cx-accent. */
    --cx-negative-bg:        var(--cx-negative-dim);
    --cx-negative-bg-hover:  var(--cx-negative);
    --cx-negative-color:     white;
    --cx-negative-border:    none;

    --cx-overlay-color:      white;
    --cx-overlay-halo:       var(--cx-bg);
    --cx-splash-bg:          rgba(0,0,0,0.92);

    --cx-now-bg-default:     var(--cx-surface-high);
    --cx-gold-hl-bg:         var(--cx-focus-bg);
    --cx-gold-hl-color:      var(--cx-gold);
    --cx-gold-hl-border:     var(--cx-gold);

    --cx-cursor-color:       gold;
    --cx-header-border:      var(--cx-border-dim);
    --cx-glossary-border:    var(--cx-border);
    --cx-nudge-reset-color:  var(--cx-accent);
    --cx-brand-span-opacity: 0.7;
}

/* ── Reset ───────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* 100dvh, with 100vh as the fallback, throughout this file: on iPhone
 * Safari 100vh is the height with the toolbar hidden, so a 100vh layout ran
 * behind the bottom toolbar — the corner language menu could only be hit
 * after scrolling up. dvh is the area actually visible. */
html {
    min-height: 100vh;
    min-height: 100dvh;
    background: var(--cx-bg);
}
body {
    font-family: var(--cx-font-body);
    font-size: 1rem;
    line-height: 1.5;
    position: relative;
    margin: 0;
    background: var(--cx-bg);
    color: var(--cx-text);
}
input, select, textarea, button { font-family: inherit; font-size: inherit; }
*:focus { outline: none; }
div { float: none !important; }
a { color: var(--cx-link); text-decoration: none; }
a:hover { text-decoration: underline; }

/* ── Body layout modifiers ───────────────────────────────────────────── */

/*
 * data-cx on body opts into the app-style full-viewport flex layout.
 * Without one of these, body is plain document flow — ordinary HTML.
 * Add "center" (the general alignment word, used elsewhere too) to
 * centre content both ways — no dedicated "centered" word needed.
 */
body[data-cx~="row"],
body[data-cx~="col"] {
    display: flex;
    height: 100vh;
    height: 100dvh;
    overflow: hidden;
}

body[data-cx~="row"] {
    flex-direction: row;
}

/* col — flex column, header/content/footer stack vertically. */
body[data-cx~="col"] {
    flex-direction: column;
}

/* gaps — panes float apart with the page background showing between
 * them. Body opens gap + padding; panes get rounded corners here and
 * their own background from the theme (otherwise the page background
 * would show through the panes too, not just the gaps). One word does
 * both jobs: bare "gaps" uses the default size, "gaps=1.5" sets it
 * (cx-render writes --cx-gap; also settable in org CSS). */
body[data-cx~="gaps"],
body[data-cx*="gaps="] {
    gap: var(--cx-gap, 0.75rem);
    padding: var(--cx-gap, 0.75rem);
    box-sizing: border-box;
}
body[data-cx~="gaps"] > [data-cx^="pane"],
body[data-cx*="gaps="] > [data-cx^="pane"] {
    border-radius: var(--cx-radius);
    /* width= panes get inline flex:none; !important lets them shrink by
     * the gap total so the right/bottom padding isn't eaten. */
    flex-shrink: 1 !important;
    min-width: 0;
}

/* background=transparent — no painted background, for capture and keying
 * experiments (OBS browser source, vision mixer). The param itself is
 * applied by cx-render's init pass (literal fallback — no theme var
 * needed); this rule clears the html backdrop behind it, which paints
 * --cx-bg and would otherwise show through. */
html:has(body[data-cx*="background=transparent"]) { background: transparent; }

/* Page backdrop — an iframe holding a whole page (an animated or CSS
 * backdrop for a stage display or cyclorama) behind everything else.
 * Inserted as body's first child by cx-render, from meta.background or a
 * body background= whose value ends .html.
 *
 * z-index:-1 rather than lifting the panes: panes are static flex children,
 * so a z-index on them would do nothing without also giving them
 * position:relative — which would change the containing block for every
 * absolutely-positioned descendant. Going negative instead leaves all
 * existing positioning untouched. Body is position:relative but
 * z-index:auto, so it is not a stacking context and the frame lands behind
 * body's own background — hence clearing body AND html here, the same pair
 * background=transparent needs above. Both rules are scoped to the frame's
 * presence, so removing it restores the theme background with no unwinding.
 *
 * pointer-events:none keeps clicks and cx-keys with the cx page.
 *
 * position:fixed, not absolute. Body is position:relative (above), so an
 * absolute frame took its size from BODY's padding box rather than the
 * viewport — fine on the pages that had backdrops, which all carry
 * data-cx="row"/"col" on body and so are 100vh, but on a page of bare
 * elements body is only as tall as its content and the backdrop became a
 * band across the top with the canvas showing beneath. fixed resolves
 * against the viewport whatever any ancestor does, so body's height stops
 * mattering. It also holds the backdrop still on a scrollable page, which
 * is what a full-bleed layer behind everything wants anyway.
 *
 * inset:0 already sets both axes; the width/height pair is left in place as
 * a fallback for the frame's own default 300x150 intrinsic size. */
iframe.cx-backdrop {
    position: fixed;
    inset: 0;
    width: 100%;
    height: 100%;
    border: 0;
    z-index: -1;
    pointer-events: none;
}
body:has(> iframe.cx-backdrop) { background: transparent; }
html:has(body > iframe.cx-backdrop) { background: transparent; }

/* Panes paint an opaque --cx-surface (see the pane rule further down), and
 * a pane without an explicit width fills the body — so a pane would hide
 * the backdrop completely. Cleared here while a backdrop is present, at any
 * depth, since a nested pane hides it just as well. A pane's own
 * background= still wins: inline style beats this, so a deliberate opaque
 * panel over the backdrop remains possible. Tint words (lighten, darken)
 * also still paint, via --cx-bgmod — asking for a tint over the backdrop is
 * a coherent thing to want. */
body:has(> iframe.cx-backdrop) [data-cx^="pane"] { background: transparent; }

/* ── Layout primitives ───────────────────────────────────────────────── */

/*
 * cx-row / cx-col — full-screen flex containers for non-body elements.
 * On body, use data-cx="row" instead.
 * Children without a size get flex:1 — they fill remaining space.
 * Size with cx-pane-30 etc or data-cx="pane width=30"
 */
.cx-row {
    display: flex;
    flex-direction: row;
    height: 100vh;
    height: 100dvh;
    overflow: hidden;
}

.cx-col {
    display: flex;
    flex-direction: column;
    height: 100vh;
    height: 100dvh;
    overflow: hidden;
}

/* Children of a flex-row body default to content size — row layout wants
 * chrome-sized items, not everything trying to grow equally. col lets
 * children keep their own flex value (panes default to flex:1, see
 * below); a plain (non-flex) body doesn't reach this rule at all, so
 * ordinary document content never gets clipped by it. */
body[data-cx~="row"] > *:not(header):not(footer),
.cx-row > *:not(header):not(footer),
.cx-col > *:not(header):not(footer) {
    flex: none;
    min-width: 0;
    min-height: 0;
    overflow: hidden;
}

/* Chrome elements don't grow — fixed height */
header, footer,
.cx-header, .cx-status, .cx-footer, .cx-auto-header {
    flex: none;
}

/* ── Alignment modifiers ─────────────────────────────────────────────── */

/*
 * Applied to flex containers to control child distribution.
 * Applied to flex children to control individual positioning.
 *
 * Container use:
 *   data-cx="header spread"  → children spread across full width
 *   data-cx="header left"    → children cluster left
 *   data-cx="header right"   → children cluster right
 *
 * Child use:
 *   data-cx="meta title end"   → pushes this child to the far end
 *   data-cx="meta title start" → pushes this child to the far start
 */
[data-cx~="spread"] { justify-content: space-between; }
[data-cx~="left"]   { --cx-align: left;   justify-content: flex-start; }
[data-cx~="right"]  { --cx-align: right;  justify-content: flex-end; }
/* centre is the cx spelling; center is accepted as an alias. One rule, so the
 * two cannot drift into meaning different things — which they had: centre was
 * a separate style word setting only --cx-align, which text-align already
 * defaults to, so it did almost nothing. */
[data-cx~="centre"],
[data-cx~="center"] { --cx-align: center; justify-content: center; align-items: center; }
[data-cx~="end"]    { --cx-align: right;  margin-left: auto; }
[data-cx~="start"]  { --cx-align: left;   margin-right: auto; }

/* Decorative bullet separator — used between meta spans in headers */
[data-cx~="dot"]::before { content: "\2022"; position: relative; top: -0.2em; }

/* ── Panel system ────────────────────────────────────────────────────── */

/*
 * cx-pane-* — coloured panels, self-contained.
 * data-cx="pane ..." — layout-only panels, no padding.
 * Colour variants defined in theme files.
 * Size with cx-pane-30 etc or data-cx="pane width=30"
 * Unsized panel fills remaining space via flex:1 — see the dedicated
 * rule further down (uses !important, overrides the squasher above
 * regardless of specificity; excludes width= and fixed panes).
 */
[class*="cx-pane-"],
[data-cx^="pane"] {
    display: flex;
    flex-direction: column;
    overflow: hidden;
}

/* row/col are body-layout words (RESERVED_MODIFIERS) but were only ever
 * wired up for <body> below — panes always forced column regardless.
 * Same specificity as the rule above; source order makes this win for
 * any pane that opts into row. col needs no rule — already the default. */
[data-cx^="pane"][data-cx~="row"] { flex-direction: row; }

[class*="cx-pane-"] {
    padding: 1.5rem;
    gap: 0.75rem;
}

/* ── data-cx element defaults ────────────────────────────────────────── */

/*
 * Presentation rules targeting data-cx attributes directly.
 * No classes needed in HTML for these elements.
 * Theme files provide colours; these rules provide layout and typography.
 */

/* Text render elements — fill remaining space by default */
[data-cx~="st"],
[data-cx~="ad"],
[data-cx~="sd"],
[data-cx~="sp"],
[data-cx*=".text"],
[data-cx*=".ipa"],
[data-cx*=".short"] {
    flex: 1 !important;
}

/* A snippet is a click-to-play caption line, not a scrolling transcript —
 * it must not grow to fill the pane like the rule above gives ordinary
 * st/ad/sd/.ipa text renders, or it swallows the pane's remaining height
 * and pushes any siblings below it (track-menu, synced audio). Same
 * specificity as the rule above; source order wins the tie. */
[data-cx~="snippet"] {
    flex: none !important;
    min-height: calc(4.2em + 2.2rem);
}

/* Snippet text area — reserves three lines of its own text (em: scales
 * with the element's size modifier) even while empty, so the speed menu
 * below it and everything after stays put as lines come and go. */
[data-cx~="snippet"] .cx-snippet-text {
    line-height: 1.4;
    min-height: 4.2em;
}

/* st / ad / sd / sp / meta — THE single default text style, shared by all.
 * No per-type size or colour baked in here — every visual property routes
 * through an inherited custom property, so style modifiers (on the element
 * or any ancestor pane) work by setting the variable — see "Style modifier
 * vocabulary" below. The responsive clamp is the base size; size modifiers
 * scale it, never replace it. Want any of these to look different from
 * another? Say so with modifier words on that element, e.g.
 * "ad.text small orange", "meta festival italic". */
[data-cx~="st"],
[data-cx~="ad"],
[data-cx~="sd"],
[data-cx~="sp"],
[data-cx~="meta"],
[data-cx*=".text"],
[data-cx*=".ipa"],
[data-cx*=".short"] {
    font-family: var(--cx-family, var(--cx-font-body));
    font-size: var(--cx-size, calc(var(--cx-scale, 1) * clamp(1.4rem, 5vw, 3.5rem)));
    font-weight: var(--cx-weight, 400);
    font-style: var(--cx-fstyle, normal);
    font-variant: var(--cx-caps, normal);
    letter-spacing: var(--cx-tracking, normal);
    text-decoration: var(--cx-decoration, none);
    line-height: 1.3;
    text-align: var(--cx-align, center);
    color: var(--cx-color, var(--cx-text-strong));
    background: var(--cx-bgmod, transparent);
}

@supports (color: color-mix(in oklch, red, blue)) {
[data-cx~="st"],
[data-cx~="ad"],
[data-cx~="sd"],
[data-cx~="sp"],
[data-cx*=".text"],
[data-cx*=".ipa"],
[data-cx*=".short"] {
    color: color-mix(in oklch, var(--cx-color, var(--cx-text-strong)), var(--cx-contrast) var(--cx-bright, 0%));
}
}

/* Offset lines carry NO built-in styling — an offset element renders
 * identically to the main element. Pages opt into a distinct look with
 * style modifiers, e.g. data-cx="st.text offset=1 small italic dim". */

/* ── Style modifier vocabulary ───────────────────────────────────────── */

/*
 * Small composable words that package the underlying styling. Each sets an
 * inherited custom property, so a modifier on a pane scopes to everything
 * inside it and the nearest setting wins — same shape as the pane param
 * model, handled entirely by the CSS cascade (no JS inheritance).
 *
 * Axes:  size    micro tiny small medium large huge (medium = default)
 *        family  sans serif                        (sans   = default)
 *        weight  light regular bold                (regular= default)
 *        style   italic underline
 *        colour  white grey gold yellow green orange red blue purple pink
 *                brown cyan contrast
 *        bg      lighten darken
 *        misc    dim bright caps spaced condense outline
 *
 * plain resets every axis to its default. It sits FIRST in source order,
 * so "plain large" composes naturally: reset everything, then large.
 * Nonsense combos ("small large"): later rule in THIS file wins.
 */
[data-cx~="plain"] {
    --cx-scale: 1;
    --cx-size: initial;
    --cx-family: var(--cx-font-body);
    --cx-weight: 400;
    --cx-fstyle: normal;
    --cx-decoration: none;
    --cx-color: var(--cx-text-strong);
    --cx-bgmod: transparent;
    --cx-align: center;
    --cx-bright: 0%;
    opacity: 1;
    /* Clears an outline halo inherited from an ancestor pane. text-shadow is
     * a final property rather than an axis variable, like opacity above.
     * Note this rule sits BEFORE the style words, so on an element carrying
     * both ("plain outline") the later rule wins and the halo stays — the
     * same source-order behaviour "plain huge" already has for size. */
    text-shadow: none;
}

/* Size — scales the element's responsive base, never replaces it. An
 * absolute size= param (--cx-size, set by cx-render's init pass) replaces
 * it outright instead: a number always beats a named rung, so
 * "huge size=27" is 27px. Inherits like every other style axis, so it
 * works page- and pane-level. */
[data-cx~="pico"]   { --cx-scale: 0.18; }
[data-cx~="nano"]   { --cx-scale: 0.25; }
[data-cx~="micro"]  { --cx-scale: 0.35; }
[data-cx~="tiny"]   { --cx-scale: 0.5; }
[data-cx~="small"]  { --cx-scale: 0.75; }
[data-cx~="medium"] { --cx-scale: 1; }
[data-cx~="large"]  { --cx-scale: 1.5; }
[data-cx~="huge"]   { --cx-scale: 2.25; }

/* Family */
[data-cx~="sans"]   { --cx-family: var(--cx-font-body); }
[data-cx~="serif"]  { --cx-family: var(--cx-font-serif-display); }

/* Weight */
[data-cx~="light"]   { --cx-weight: 300; }
[data-cx~="regular"] { --cx-weight: 400; }
[data-cx~="bold"]    { --cx-weight: 600; }

/* Style */
[data-cx~="italic"]    { --cx-fstyle: italic; }
[data-cx~="underline"] { --cx-decoration: underline; }

/* Colour — values are theme variables, remapped per theme */
[data-cx~="white"]  { --cx-color: var(--cx-white); }
[data-cx~="black"]  { --cx-color: var(--cx-black); }
[data-cx~="grey"]   { --cx-color: var(--cx-grey); }
[data-cx~="gold"]   { --cx-color: var(--cx-gold); }
[data-cx~="green"]  { --cx-color: var(--cx-green); }
[data-cx~="orange"] { --cx-color: var(--cx-orange); }
[data-cx~="red"]    { --cx-color: var(--cx-red); }
[data-cx~="blue"]   { --cx-color: var(--cx-blue); }
[data-cx~="purple"] { --cx-color: var(--cx-purple); }
[data-cx~="pink"]   { --cx-color: var(--cx-pink); }
[data-cx~="yellow"] { --cx-color: var(--cx-yellow); }
[data-cx~="brown"]  { --cx-color: var(--cx-brown); }
[data-cx~="cyan"]   { --cx-color: var(--cx-cyan); }

/* Contrast — deliberately literal white/black per theme, not a warm-palette
 * colour. An accessibility escape hatch, not a colour choice like the rest
 * of this list — use when you need maximum legibility over aesthetic fit. */
[data-cx~="contrast"] { --cx-color: var(--cx-contrast); }

/* Background — one step lighter or darker than the surface, per theme */
[data-cx~="lighten"] { --cx-bgmod: var(--cx-bg-lighten); }
[data-cx~="darken"]  { --cx-bgmod: var(--cx-bg-darken); }

/* Dim — subdued; the old built-in offset look, now opt-in */
[data-cx~="dim"] { opacity: 0.5; }

/* Bright — the inverse of dim. On text it pushes the colour toward
 * --cx-contrast (white in dark theme, black in light). On a pin it pushes
 * toward black in BOTH themes: a pin sits on a white score page either way,
 * so mixing toward white would lose it. Applied by the @supports overrides
 * on the two colour rules, since a var() inside color-mix falls back to the
 * inherited colour where color-mix is unsupported. */
[data-cx~="bright"] { --cx-bright: 40%; }

/* Caps / spaced / condense — small-caps + letter-spacing, the old sp
 * speaker-name look, now opt-in and combinable with anything else. */
[data-cx~="caps"]     { --cx-caps: small-caps; }
[data-cx~="spaced"]   { --cx-tracking: 0.05em; }
[data-cx~="condense"] { --cx-tracking: -0.03em; }

/* Outline — thin 8-direction text-shadow halo so text stays legible over
 * video/projection (e.g. white text against a white shirt). Colour from
 * --cx-outline (set by the outline= param: theme word via var(--cx-{word})
 * or literal), default black.
 * text-shadow inherits, so it works pane-level like any style word.
 * Like gaps/gaps=, one token does both jobs: bare "outline" is the
 * default black halo, "outline=green" is a halo in that colour.
 *
 * Width scales with the text but SUB-linearly: a flat 0.055em looked
 * correct at projection sizes and visibly fattened small text, because a
 * halo that is a constant fraction of the font size is a much larger
 * fraction of the stem weight as the text gets smaller. Subtracting a
 * constant bends the curve — the halo runs from about 2% of the font size
 * at 16px to about 5% at 126px, so large text is essentially unchanged and
 * small text is roughly a third thinner. max() puts a floor under it: the
 * expression goes negative below ~11px and the halo would invert.
 *
 * The orthogonal offset is the halo width; the diagonals are that times
 * 0.707 per axis, so their true distance is also the halo width and the
 * halo reads as round rather than a square with fat corners. */
[data-cx~="outline"],
[data-cx*="outline="] {
    --cx-outline-width: max(0.3px, calc(0.055em - 0.6px));
    --cx-outline-diag:  calc(var(--cx-outline-width) * 0.707);
    /* cx-colour-ok — #000 is the var() fallback only: the default halo is
     * deliberately black in both themes (physical legibility over video),
     * and outline= overrides it via --cx-outline. */
    text-shadow:
        calc(-1 * var(--cx-outline-diag))  calc(-1 * var(--cx-outline-diag)) 0 var(--cx-outline, #000),
        var(--cx-outline-diag)             calc(-1 * var(--cx-outline-diag)) 0 var(--cx-outline, #000),
        calc(-1 * var(--cx-outline-diag))  var(--cx-outline-diag)            0 var(--cx-outline, #000),
        var(--cx-outline-diag)             var(--cx-outline-diag)            0 var(--cx-outline, #000),
        calc(-1 * var(--cx-outline-width)) 0                                0 var(--cx-outline, #000),
        var(--cx-outline-width)            0                                0 var(--cx-outline, #000),
        0                                  calc(-1 * var(--cx-outline-width)) 0 var(--cx-outline, #000),
        0                                  var(--cx-outline-width)            0 var(--cx-outline, #000);
}

/* Per-axis resets — the single-axis counterpart to "plain". A "-axis"
 * word cancels whatever value that axis inherited from an ancestor pane,
 * re-setting the property to the same default "plain" uses (never
 * "initial" — the defaults here are explicit theme values). Placed after
 * every style word above so a reset always wins the source-order contest
 * on the same element; on a descendant it overrides the inherited custom
 * property locally, which is what "stop inheriting" means for CSS axes. */
[data-cx~="-size"]       { --cx-scale: 1; --cx-size: initial; }
[data-cx~="-colour"]     { --cx-color: var(--cx-text-strong); }
[data-cx~="-family"]     { --cx-family: var(--cx-font-body); }
[data-cx~="-weight"]     { --cx-weight: 400; }
[data-cx~="-style"]      { --cx-fstyle: normal; --cx-decoration: none; }
[data-cx~="-align"]      { --cx-align: center; }
[data-cx~="-background"] { --cx-bgmod: transparent; }
[data-cx~="-caps"]       { --cx-caps: normal; }
[data-cx~="-tracking"]   { --cx-tracking: normal; }
[data-cx~="-dim"]        { opacity: 1; }
[data-cx~="-bright"]     { --cx-bright: 0%; }
[data-cx~="-outline"]    { text-shadow: none; }

/* Text alignment now flows from --cx-align (set by left/right/center/
 * centre/start/end above) into the shared text rule's text-align, the
 * same inherited-custom-property shape as every other style axis — so
 * an alignment word on an ancestor pane reaches text elements inside it,
 * not just the same element. */

/* Vertical position — opt-in: the element becomes a flex column so its
 * text block sits at the top, middle, or bottom of the space it fills.
 * Declared after the alignment rules so top/middle/bottom win the
 * justify-content contest against left/right on the same element. */
[data-cx~="top"],
[data-cx~="middle"],
[data-cx~="bottom"] {
    display: flex;
    flex-direction: column;
}
[data-cx~="top"]    { justify-content: flex-start; }
[data-cx~="middle"] { justify-content: center; }
[data-cx~="bottom"] { justify-content: flex-end; }

/* ── Connection status light ─────────────────────────────────────────── */

/*
 * data-cx="meta status" — connection liveness, driven by cx-connect.js.
 * cx-connect.js sets data-state (green|amber|red) and the word (live|down).
 * Dot is a ::before pseudo-element; green and amber pulse slowly, red is solid.
 */
[data-cx~="meta"][data-cx~="status"] {
    font-family: var(--cx-font-body);
    font-size: 0.8rem;
    color: var(--cx-text-mid);
    display: inline-flex;
    align-items: center;
    gap: 0.4em;
}
[data-cx~="meta"][data-cx~="status"]::before {
    content: '';
    width: 0.6em;
    height: 0.6em;
    border-radius: 50%;
    background: var(--cx-text-dim);
    flex: none;
}
[data-cx~="meta"][data-cx~="status"][data-state="green"]::before {
    background: var(--cx-ok);
    animation: cx-status-pulse 6s ease-in-out infinite;
}
[data-cx~="meta"][data-cx~="status"][data-state="amber"]::before {
    background: var(--cx-accent);
    animation: cx-status-pulse 6s ease-in-out infinite;
}
[data-cx~="meta"][data-cx~="status"][data-state="red"]::before {
    background: var(--cx-err);
}

/* The word is a button that opens the details panel (cx-connect.js). It
 * should look like the word it replaced, not like a control. */
.cx-status-btn {
    all: unset;
    cursor: pointer;
    font: inherit;
    color: inherit;
    border-radius: 0.2em;
}
/* Stretch the button's hit area over the whole light, so the dot (the
 * element's ::before) and the issues mark (its ::after) open the panel too.
 * A layer on the button rather than a second listener: one focusable target,
 * one thing for a screen reader to find. */
/* Unconditional, not :has(): on a browser without :has() the layer below
 * would stretch to the nearest positioned ancestor — possibly a whole pane —
 * and swallow its taps. */
[data-cx~="meta"][data-cx~="status"] {
    position: relative;
    cursor: pointer;
}
.cx-status-btn::before {
    content: '';
    position: absolute;
    inset: 0;
}
.cx-status-btn:focus-visible {
    outline: 2px solid var(--cx-focus-color, var(--cx-accent));
    outline-offset: 2px;
}
/* Something besides the socket has gone wrong — a pack denied or failing.
 * A small mark after the word, so a green light with a problem behind it
 * still invites a tap. */
[data-cx~="meta"][data-cx~="status"][data-issues]::after {
    content: '!';
    font-weight: bold;
    color: var(--cx-err);
}

/* Details panel — one per page, attached to <body>, placed under the light. */
.cx-status-panel {
    position: fixed;
    z-index: 10000;
    max-width: min(28rem, calc(100vw - 16px));
    max-height: calc(100vh - 16px);
    overflow: auto;
    padding: 0.6rem 0.8rem;
    background: var(--cx-bg-high);
    color: var(--cx-text);
    border: 1px solid var(--cx-border);
    border-radius: 6px;
    box-shadow: 0 4px 16px rgb(0 0 0 / 0.35);  /* cx-colour-ok — a drop shadow, not a theme colour */
    font-family: var(--cx-font-body);
    font-size: 0.85rem;
    line-height: 1.4;
}
.cx-status-panel-head {
    font-weight: bold;
    margin-bottom: 0.3rem;
}
.cx-status-panel-head[data-state="green"] { color: var(--cx-ok); }
.cx-status-panel-head[data-state="amber"] { color: var(--cx-accent); }
.cx-status-panel-head[data-state="red"]   { color: var(--cx-err); }
.cx-status-panel dl {
    display: grid;
    grid-template-columns: max-content 1fr;
    gap: 0.1rem 0.8rem;
    margin: 0;
}
.cx-status-panel dt { color: var(--cx-text-mid); }
.cx-status-panel dd { margin: 0; overflow-wrap: anywhere; }
.cx-status-panel-sub {
    margin-top: 0.6rem;
    color: var(--cx-text-mid);
    font-size: 0.75rem;
    text-transform: uppercase;
    letter-spacing: 0.05em;
}
.cx-status-panel ul {
    list-style: none;
    margin: 0.2rem 0 0;
    padding: 0;
    font-family: var(--cx-font-mono);
    font-size: 0.78rem;
}
.cx-status-panel li { padding: 0.05rem 0; }
.cx-status-panel .cx-status-none { color: var(--cx-text-dim); font-family: var(--cx-font-body); }

@keyframes cx-status-pulse {
    0%, 100% { opacity: 1; }
    50%      { opacity: 0.3; }
}

/* Respect reduced-motion — the word carries the meaning, pulse is decoration. */
@media (prefers-reduced-motion: reduce) {
    [data-cx~="meta"][data-cx~="status"]::before {
        animation: none;
    }
}

/* Score image — data-cx can sit on the <img> itself, or on a wrapping
 * <div> that gets a managed <img> injected into it (see renderImage).
 * Scroll-list score images are excluded from the > img half — they size
 * width:100% / height auto (inline, from makeScoreImagesComponent);
 * height:100% + contain here letterboxed each page to one container
 * height in Firefox (Safari never resolved the percentage, masking it). */
[data-cx~="score.image"],
[data-cx~="score.image"]:not([data-cx~="scroll"]) > img {
    width: 100%;
    height: 100%;
    object-fit: contain;
    display: block;
}

/* Language menu — absolute bottom-right corner.
 * align-self/justify-self pin this to its corner regardless of body's
 * align-items/justify-content (e.g. data-cx="col center") — otherwise an
 * absolutely-positioned flex child still takes its alignment from the
 * container on any axis where the opposite inset is auto. */
[data-cx~="language-menu"] {
    position: absolute;
    bottom: max(1rem, env(safe-area-inset-bottom));
    right: max(1rem, env(safe-area-inset-right));
    align-self: flex-end;
    justify-self: flex-end;
    flex: none;
}

/* In a header or footer it is a control on the bar, not a floating corner
 * control, so the absolute positioning above has to come off — otherwise it
 * pins to the viewport's bottom-right (its nearest positioned ancestor is
 * body, since neither bar is positioned) and disappears behind whatever
 * fills the page. Same applies to a buttons bar placed in a header. */
.cx-header > [data-cx~="language-menu"],
.cx-footer > [data-cx~="language-menu"],
.cx-header > [data-cx~="buttons"],
.cx-footer > [data-cx~="buttons"] {
    position:     static;
    align-self:   center;
    justify-self: auto;
}

/* Invert button — plain flex child, laid out by whichever buttons bar it sits in.
 * Since cx-render B.3.132 it also holds the fullscreen button where the browser
 * supports it; the gap matches the buttons bar's own. */
[data-cx~="invert"] {
    display: flex;
    align-items: center;
    gap: 0.75rem;
}

/* Buttons bar — houses audio controls / mute-button / invert as plain flex
 * children, replacing the old per-widget JS-computed positioning.
 * Default corner: bottom-left. right/center modifiers reposition it.
 * align-self/justify-self pin this regardless of body's align-items/
 * justify-content (e.g. data-cx="col center") — same reasoning as
 * language-menu above. */
[data-cx~="buttons"] {
    position: absolute;
    bottom: 1rem;
    left: 1rem;
    align-self: flex-start;
    justify-self: flex-end;
    display: flex;
    align-items: center;
    gap: 0.75rem;
    flex: none;
}
[data-cx~="buttons"][data-cx~="right"] {
    left: auto;
    right: 1rem;
    align-self: flex-end;
}
[data-cx~="buttons"][data-cx~="centre"],
[data-cx~="buttons"][data-cx~="center"] {
    left: 50%;
    right: auto;
    transform: translateX(-50%);
}

/* Autohide — `autohide` on body. cx-render (setupAutohide) sets data-cx-idle
 * on <html> after a few seconds untouched and clears it on any tap or key;
 * this fades the corner controls while it is set. Corner only — the same
 * exclusions as the corner rules above and in cx-render's CORNER_CONTROLS.
 * Opacity rather than display or visibility: the controls keep their place
 * and stay in the accessibility tree, so focus can still reach them, and a
 * tap on one lands on it (cx-render swallows that first tap). */
body[data-cx~="autohide"] [data-cx~="buttons"]:not([data-cx^="pane"] *):not(.cx-header > *):not(.cx-footer > *),
body[data-cx~="autohide"] [data-cx~="language-menu"]:not([data-cx^="pane"] *):not(.cx-header > *):not(.cx-footer > *) {
    transition: opacity 0.6s ease;
}
html[data-cx-idle] body[data-cx~="autohide"] [data-cx~="buttons"]:not([data-cx^="pane"] *):not(.cx-header > *):not(.cx-footer > *),
html[data-cx-idle] body[data-cx~="autohide"] [data-cx~="language-menu"]:not([data-cx^="pane"] *):not(.cx-header > *):not(.cx-footer > *) {
    opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
    body[data-cx~="autohide"] [data-cx~="buttons"],
    body[data-cx~="autohide"] [data-cx~="language-menu"] {
        transition: none;
    }
}

/* Audio widget itself — label/select/mute-button laid out in a row.
 * Internal layout only; page position comes from the buttons bar above. */
[data-cx*=".audio"][data-cx~="controls"],
[data-cx*=".audio"][data-cx~="mute-button"] {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 0.5em;
}

/* Breathing room before the track-menu — separates the "current line"
 * module (video/caption/speed) above from the "track picker + synced
 * audio" module below. Margin on this one element, not a pane-level gap,
 * so this transition gets space without spacing out every child. */
[data-cx~="track-menu"] {
    margin-top: 2.5rem;
    /* Reserve the radio row's height while empty — the menu only builds
     * once the pack's meta has resolved (nothing on reload/mid-switch). */
    min-height: 1.4rem;
}

/* Track-menu radio group — centred, spread out so the options read as
 * separate choices rather than a clump. Not dimmed by default — use the
 * existing generic [data-cx~="dim"] modifier on meta.track-menu if wanted. */
.cx-track-radio-group {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 1.25rem;
}
.cx-track-radio-label {
    display: inline-flex;
    align-items: center;
    gap: 0.35em;
    font-family: var(--cx-font-body);
    font-size: 0.85rem;
    white-space: nowrap;
    cursor: pointer;
    /* Full-strength text, not the muted secondary look; dimming is opt-in
     * via the `dim` modifier on meta.track-menu. */
    color: var(--cx-text-strong);
}
.cx-track-radio-label input[type="radio"] {
    accent-color: var(--cx-accent);
}

/* Track sliders — the alternative to the radio group: every present track
 * plays at once with its own gain/mute. One grid so the label, slider and
 * checkbox columns line up across all rows, including the speed row at the
 * bottom (whose checkbox cell is an empty spacer, keeping its slider the
 * same width as the volume sliders above). Column widths come from the
 * widest label, so a long name from meta.tracks widens the column rather
 * than breaking the alignment. */
.cx-track-sliders {
    display: grid;
    grid-template-columns: auto minmax(6rem, 1fr) auto;
    align-items: center;
    gap: 0.35rem 0.6rem;
    justify-content: center;
    max-width: 22rem;
    margin: 0 auto;
}
.cx-track-slider-row {
    display: contents;
}
.cx-track-slider-label {
    font-family: var(--cx-font-body);
    font-size: 0.85rem;
    white-space: nowrap;
    text-align: right;
    cursor: pointer;
}
.cx-track-slider-input {
    width: 100%;
    accent-color: var(--cx-accent);
    cursor: pointer;
}
.cx-track-slider-mute {
    accent-color: var(--cx-accent);
    cursor: pointer;
}

/* Absent — in the programme's full set but not this chapter's meta.tracks.
 * The row stays in place so the desk keeps its shape across chapters. */
.cx-track-slider-row.cx-absent .cx-track-slider-label,
.cx-track-slider-row.cx-absent .cx-track-slider-input,
.cx-track-slider-row.cx-absent .cx-track-slider-mute {
    opacity: 0.3;
    cursor: default;
}

/* Speed row — separated from the volume sliders by a gap. */
.cx-track-speed-row .cx-track-slider-label,
.cx-track-speed-row .cx-track-slider-input,
.cx-track-speed-row .cx-track-slider-mute-pad {
    margin-top: 0.9rem;
}

/* Synced audio element — its visible children are just the speed
 * label + select (the <audio> itself is hidden); centre them as a row. */
[data-cx~="synced"] {
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 0.5em;
    /* Reserve the speed row's height while empty — controls only build
     * once a track has resolved. */
    min-height: 1.9rem;
}

/* fit — pane shrinks to its content height instead of stretching to the
 * full row height, so the page background shows through below it.
 * e.g. data-cx="pane width=25 fit". */
[data-cx^="pane"][data-cx~="fit"] {
    align-self: flex-start;
}

/* resizable — drag gutters between top-level panes (built by cx-render).
 * Panes may shrink so the fixed-width gutters never clip the last pane
 * (same reasoning as under gaps). */
body[data-cx~="resizable"] > [data-cx^="pane"] {
    flex-shrink: 1 !important;
    min-width: 0;
    min-height: 0;
}
.cx-resize-gutter {
    flex: none;
    width: 6px;
    /* Overlay the pane edges rather than opening a gap of its own. */
    margin: 0 -3px;
    align-self: stretch;
    cursor: col-resize;
    touch-action: none;
    border-radius: 3px;
    background: currentColor;
    opacity: 0;
    transition: opacity 0.15s;
}
.cx-resize-gutter-col {
    width: auto;
    height: 6px;
    margin: -3px 0;
    cursor: row-resize;
}
.cx-resize-gutter:hover,
.cx-resize-gutter:active {
    opacity: 0.2;
}
/* Under gaps: the flex gap applies either side of the gutter too, tripling
 * the spacing at gutter positions. Make the gutter exactly gap-wide and
 * pull it back by a gap each side — net spacing stays one gap, and the
 * whole visual gap becomes the drag hit area. */
body[data-cx~="gaps"] > .cx-resize-gutter,
body[data-cx*="gaps="] > .cx-resize-gutter {
    width: var(--cx-gap, 0.75rem);
    margin-left: calc(var(--cx-gap, 0.75rem) * -1);
    margin-right: calc(var(--cx-gap, 0.75rem) * -1);
}
body[data-cx~="gaps"] > .cx-resize-gutter-col,
body[data-cx*="gaps="] > .cx-resize-gutter-col {
    width: auto;
    height: var(--cx-gap, 0.75rem);
    margin: calc(var(--cx-gap, 0.75rem) * -1) 0;
}

/* Menu labels — shared for the same reason as the selects below. */
.cx-audio-speed-label,
.cx-eq-label {
    font-family: var(--cx-font-body);
    font-size: 0.85rem;
    white-space: nowrap;
}

/* ── Menu selects ────────────────────────────────────────────────────────
 * Speed, EQ and language share one rule rather than three copies of the
 * same declarations. Each was unstyled once and rendered as a bare native
 * control, which keeps its own light chrome whatever the theme — that is
 * what made the language menu read brighter than the two beside it. A new
 * menu select should be added here, not restyled separately. The one
 * exception is the floating corner language menu, enlarged in its own rule
 * below: audiences find it on a phone in the dark, and dress rehearsal
 * feedback said the shared size was too small to hit. */
.cx-audio-speed-select,
.cx-eq-select,
.cx-lang-select {
    font-family: var(--cx-font-body);
    font-size: 0.85rem;
    color: inherit;
    background: var(--cx-surface-mid);
    border: 1px solid var(--cx-border-dim);
    border-radius: var(--cx-radius);
    padding: 0.15rem 0.4rem;
    cursor: pointer;
}
.cx-audio-speed-select:hover,
.cx-audio-speed-select:focus,
.cx-eq-select:hover,
.cx-eq-select:focus,
.cx-lang-select:hover,
.cx-lang-select:focus {
    border-color: var(--cx-border);
}

/* Corner language menu only — not in a pane, header or footer, where it sits
 * inline among other controls and stays compact. 48px clears the 44px Apple HIG
 * and WCAG 2.5.5 minimum touch target. A font-size of 16px or more also stops iOS
 * Safari zooming the page when the select takes focus, which it does for
 * any form control under 16px. */
[data-cx~="language-menu"]:not([data-cx^="pane"] *):not(.cx-header > *):not(.cx-footer > *) > .cx-lang-select {
    font-size: 1.125rem;
    min-height: 48px;
    min-width: 48px;
    padding: 0.6rem 0.9rem;
}

/* Mute button — icon-only toggle, mirrors invert button's opacity treatment */
.cx-mute-btn {
    background: transparent;
    border: none;
    padding: 0;
    cursor: pointer;
    line-height: 0;
    color: inherit;
    opacity: 0.6;
    display: inline-flex;
    align-items: center;
}
.cx-mute-btn:hover,
.cx-mute-btn.cx-muted {
    opacity: 1;
}
.cx-mute-btn .cx-mute-slash {
    opacity: 0;
    stroke: var(--cx-negative);
}
.cx-mute-btn.cx-muted .cx-mute-slash {
    opacity: 1;
}

/* ── EQ (`eq` modifier) ──────────────────────────────────────────────────
 * Label + select pair. In an audio controls row it's two more flex
 * children alongside the speed control; on a video it's a slim bar below
 * the picture (rule further down). Matches .cx-audio-speed-* deliberately
 * — same row, same visual weight. */
.cx-eq-wrap {
    display: inline-flex;
    align-items: center;
    gap: 0.4em;
}

/* .cx-eq-label and .cx-eq-select are styled with the speed and language
 * menus above — same row, same visual weight, one rule. */

/* Video host — no cx controls row exists, and native browser controls
 * can't take injected widgets, so the pair sits in its own bar directly
 * below the picture. Below rather than above: nearer the native control
 * bar the user is already reaching for. Idle opacity mirrors the
 * mute/invert treatment. */
[data-cx*=".video"][data-cx~="eq"] > .cx-eq-wrap {
    display: flex;
    justify-content: flex-end;
    padding: 0.3rem 0.5rem 0;
    opacity: 0.7;
    transition: opacity var(--cx-transition-fast);
}
[data-cx*=".video"][data-cx~="eq"] > .cx-eq-wrap:hover,
[data-cx*=".video"][data-cx~="eq"] > .cx-eq-wrap:focus-within {
    opacity: 1;
}

/* Audio host without `controls` — the wrap is the only child, so it needs
 * its own row layout rather than relying on the controls flex rule. */
[data-cx*=".audio"][data-cx~="eq"] {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 0.5em;
}

/* Full EQ popup */
.cx-eq-overlay {
    position: fixed;
    inset: 0;
    z-index: 200;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(0, 0, 0, 0.45); /* cx-colour-ok — scrim, not themed */
}

.cx-eq-panel {
    background: var(--cx-surface);
    color: var(--cx-text);
    border: 1px solid var(--cx-border);
    border-radius: var(--cx-radius);
    padding: 1rem 1.25rem;
    min-width: min(20rem, 90vw);
    font-family: var(--cx-font-body);
}

.cx-eq-title {
    font-size: 1rem;
    color: var(--cx-text-strong);
    margin-bottom: 0.75rem;
}

.cx-eq-row {
    display: grid;
    grid-template-columns: 4.5rem 1fr 3.5rem;
    align-items: center;
    gap: 0.5rem;
    padding: 0.2rem 0;
}

.cx-eq-row-label {
    font-size: 0.85rem;
    white-space: nowrap;
}

.cx-eq-row-val {
    font-family: var(--cx-font-mono);
    font-size: 0.8rem;
    color: var(--cx-text-dim);
    text-align: right;
}

.cx-eq-actions {
    display: flex;
    justify-content: flex-end;
    gap: 0.5rem;
    margin-top: 1rem;
}

.cx-eq-actions button {
    font-family: var(--cx-font-body);
    font-size: 0.85rem;
    color: inherit;
    background: var(--cx-surface-mid);
    border: 1px solid var(--cx-border-dim);
    border-radius: var(--cx-radius);
    padding: 0.25rem 0.75rem;
    cursor: pointer;
}
.cx-eq-actions button:hover,
.cx-eq-actions button:focus {
    border-color: var(--cx-border);
}

/* Scrolling transcript */
[data-cx~="append"] {
    flex: 1 !important;
    overflow-y: auto;
    padding: 1rem 1.5rem;
}

/* Words / controlling panel.
 * Video renders are excluded: a controlling video wrapper must NOT grow to
 * fill the pane — the !important here beat renderVideo's inline flex:none
 * + aspect-ratio, stretching the wrapper so the picture letterboxed to the
 * middle of a huge box with its native controls pinned to the bottom. */
[data-cx~="controlling"]:not([data-cx*=".video"]) {
    flex: 1 !important;
    display: flex;
    flex-direction: column;
    overflow: hidden;
    /* Click targets — suppress browser drag-selection (grey overlay on
       score images / list lines that looks like a stuck highlight) */
    -webkit-user-select: none;
    user-select: none;
}

/* Video wrapper — content-sized: full pane width at its own aspect ratio.
 * 16/9 is only a placeholder box so the layout doesn't jump from zero
 * height; renderVideo sets the exact ratio inline on loadedmetadata.
 *
 * Not a timer widget on a video source (sl.video progress=Signing): it shows
 * no picture, and a 16:9 box at full width put it at a different height and
 * width from the audio bars beside it in a header. Substring matches, so the
 * labelled form (progress=Signing) is excluded as well as the bare word. */
[data-cx*=".video"]:not([data-cx*="progress"]):not([data-cx*="countdown"]):not([data-cx*="hourglass"]) {
    flex: none;
    width: 100%;
    aspect-ratio: 16/9;
}

/* A video is never taller than the pane or page it is in. Its box keeps the
 * full width, so in a wide space the picture sits whole in the middle of it
 * (renderVideo's object-fit: contain). Only where the height is set — a pane,
 * or a row/col page — since elsewhere a percentage has nothing to measure
 * against. !important because renderVideo sets max-height: 100vh inline. */
[data-cx^="pane"] > [data-cx*=".video"],
body[data-cx~="row"] > [data-cx*=".video"],
body[data-cx~="col"] > [data-cx*=".video"] {
    max-height: 100% !important;
}

/* A video and its subtitles on one col page (sl-video.html: a signer above
 * the st). The video gives way, the subtitles do not: when the page is too
 * short for both, the picture gets smaller — still whole, letterboxed by
 * object-fit: contain — and st keeps room for two lines.
 *
 * Two lines always, not the current line's own height: room that followed
 * the text would resize the signer every time a cue went from one line to
 * two. lh is the st's own line height, so the reserve follows its size.
 *
 * Only on a col page that has a video directly in it, and only the text
 * panes beside that video — a page without one is untouched. !important
 * because renderVideo sets flex: none inline on the video pane. min-height 0
 * because a flex item's automatic minimum would otherwise be its 16:9 height
 * and it could not shrink at all. */
body[data-cx~="col"] > [data-cx*=".video"] {
    flex-shrink: 1 !important;
    min-height: 0;
}
body[data-cx~="col"]:has(> [data-cx*=".video"]) > :is([data-cx~="st"], [data-cx~="ad"], [data-cx~="sd"], [data-cx*=".text"]) {
    min-height: 2lh;
}

/* Scroll list — fills container */
[data-cx~="scroll"] {
    flex: 1 !important;
    display: flex;
    flex-direction: column;
    min-height: 0;
}

/* Toolbar (Blank/Prev/Next + line info) — static at the top; without this
 * it had no flex-shrink guard and, more importantly, .cx-scroll-list below
 * had no flex parent to actually bound its height, so overflow-y:auto never
 * engaged and the toolbar scrolled away with everything else. */
.cx-scroll-toolbar {
    display: flex;
    align-items: center;
    gap: 0.5em;
    flex-shrink: 0;
    /* Fixed, unmodifiable styling — this toolbar has no data-cx, so nothing
     * here can be reached with modifier words. Indent roughly one button's
     * width so Blank isn't flush against the pane's edge. */
    padding: 0.5em 0 0.5em 4em;
}

.cx-scroll-lineinfo {
    font-size: 1.1rem;
    color: var(--cx-white);
}

/* Scroll list — fills container, scrollable */
.cx-scroll-list {
    flex: 1;
    overflow-y: auto;
    min-height: 0;
}

/* Scroll lines — flex row: optional index column + text */
[data-cx~="scroll"] [class*="cx-scroll-line-"] {
    display: flex;
    align-items: baseline;
    gap: 0.6em;
    /* Style words written onto a row by entries (st::gold, st:+1:green) set the same --cx-*
     * variables every other element uses, so they are read here directly.
     * --cx-now-* is the fallback half of each chain and is what .cx-scroll-now
     * supplies: a word wins because it comes FIRST in the var() chain, not by
     * specificity — which is why none of this needs !important any more.
     * --cx-now-scale is gone: a size word on the row sets --cx-scale on the row
     * itself, which this rule already reads. */
    font-size: calc(var(--cx-scale, 1) * 1.5rem);
    color: var(--cx-color, var(--cx-now-color, inherit));
    background: var(--cx-bgmod, var(--cx-now-bg, transparent));
    font-weight: var(--cx-weight, inherit);
    font-style: var(--cx-fstyle, normal);
    text-decoration: var(--cx-decoration, none);
    line-height: 1.3;
    padding: 8px 12px;
    cursor: pointer;
    transition: background var(--cx-transition-fast);
}

/* Index column — fixed width, subdued mono style */
[data-cx~="scroll"] .cx-scroll-index {
    flex: none;
    min-width: 2.5em;
    font-family: var(--cx-font-mono);
    font-size: 0.75em;
    opacity: 0.45;
    text-align: right;
}

/* Text column — fills remaining space */
[data-cx~="scroll"] .cx-scroll-text {
    flex: 1;
    min-width: 0;
}

/* A list with index= (cx-render adds .cx-scroll-indexed): the index column is
 * as wide as its widest entry all the way down, so the text lines up however
 * long a speaker's name is — and on a row with no index at all. Each row is a
 * subgrid of the list's two columns; a browser without subgrid keeps the flex
 * rows above, each index column as wide as its own entry. B.3.67. */
@supports (grid-template-columns: subgrid) {
    [data-cx~="scroll"] .cx-scroll-list.cx-scroll-indexed {
        display: grid;
        /* At most 30% for the index, so a long name wraps rather than
         * squeezing the text on a phone. */
        grid-template-columns: fit-content(30%) 1fr;
        column-gap: 0.6em;
        align-content: start;      /* rows their own height, not stretched to fill the pane */
    }
    [data-cx~="scroll"] .cx-scroll-indexed > [class*="cx-scroll-line-"] {
        display: grid;
        grid-column: 1 / -1;
        grid-template-columns: subgrid;
    }
    [data-cx~="scroll"] .cx-scroll-indexed .cx-scroll-index { grid-column: 1; min-width: 0; }
    [data-cx~="scroll"] .cx-scroll-indexed .cx-scroll-text  { grid-column: 2; }
}

/* A row whose text came from somewhere other than the list's own source —
 * a character cue, a musical number, anything the author kept out of st.
 * Indented so the list reads at a glance as "these reach the screen, these
 * do not". On the text span rather than the row, so the index column stays
 * aligned. The row is cued and clicked like any other; st-only removes
 * these rows altogether. */
/* An empty cue — a line whose source has audio or a position but no words.
 * Drawn as an outlined box at text height so the row reads as "a cue with
 * nothing in it" rather than as an empty row. Dashed, and dim, so it cannot be
 * mistaken for content. */
[data-cx~="scroll"] .cx-scroll-text.cx-scroll-blank::before {
    content: '';
    display: inline-block;
    vertical-align: middle;
    width: 3em;
    height: 0.85em;
    border: 1px dashed var(--cx-border);
    border-radius: 2px;
    opacity: 0.7;
}

[data-cx~="scroll"] .cx-scroll-text.cx-scroll-alt {
    padding-inline-start: 1.75em;
}

/* Glossary scroll */
[data-cx~="terms"] {
    flex: 1;
    overflow-y: auto;
    padding: 1rem 1.5rem;
}

/* Glossary panel */
[data-cx~="glossary"] {
    flex: 1;
    min-height: 0;
    overflow-y: auto;
    padding: 0.75rem 1.5rem;
}

/* Unsized pane — fills remaining space. A pane sized with height= is sized
 * too: until B.3.68 only width= was excluded, so in a col page this rule's
 * !important beat height='s inline flex:none and every pane took an equal
 * share, whatever height it asked for. */
[data-cx^="pane"]:not([data-cx*="width="]):not([data-cx*="height="]):not([data-cx~="fixed"]) {
    flex: 1 !important;
}

/* Fixed pane — content height, does not grow */
[data-cx~="pane"][data-cx~="fixed"] {
    flex: none !important;
}

/* Language menu inside a pane — inline, not absolute */
[data-cx^="pane"] [data-cx~="language-menu"] {
    position: static;
    flex: none;
    display: flex;
    justify-content: flex-end;
    padding: 0.25rem 0.5rem;
    border-top: 1px solid var(--cx-border-dim);
}

/* ── Auto control header ─────────────────────────────────────────────── */

/*
 * Two-row sticky header for cx-control-auto.html.
 * Grid layout ensures track and nudge controls share the same right-column width.
 */
.cx-auto-header {
    display: grid;
    grid-template-columns: 1fr auto;
    grid-template-rows: auto auto;
    align-items: center;
    position: sticky;
    top: 0;
    z-index: 10;
    flex: none;
    background: var(--cx-surface);
    border-bottom: 1px solid var(--cx-border);
}

.cx-auto-left,
.cx-auto-right {
    display: flex;
    align-items: center;
    padding: 0.4rem 1rem;
}
.cx-auto-left  {
    gap: 0.75rem;
    min-width: 0;
    border-bottom: 1px solid var(--cx-border-dim);
}
.cx-auto-right {
    gap: 0;
    border-bottom: 1px solid var(--cx-border-dim);
}

/* ── Test table ─────────────────────────────────────────────────────────── */

.cx-test-table {
    width: 100%;
    border-collapse: collapse;
    font-family: var(--cx-font-mono);
    font-size: 0.8rem;
    table-layout: fixed;
}

.cx-test-table col.cx-col-source  { width: 9rem; }
.cx-test-table col.cx-col-default { width: 14rem; }
.cx-test-table col.cx-col-lang    { width: 12rem; }
.cx-test-table col.cx-col-offset  { width: 10rem; }

.cx-test-table th,
.cx-test-table td {
    padding: 6px 12px;
    text-align: left;
    border-bottom: 1px solid var(--cx-border-dim);
    vertical-align: middle;
    overflow: hidden;
}

.cx-test-table th {
    color: var(--cx-text-dim);
    font-size: 0.75rem;
    text-transform: uppercase;
    letter-spacing: 0.05em;
}

.cx-test-table td:first-child {
    color: var(--cx-text-dim);
    white-space: nowrap;
}

.cx-test-table [data-cx] {
    font-size: 0.8rem !important;
    line-height: 1.4 !important;
    flex: none !important;
    text-align: left !important;
}

[data-cx~="raw"] {
    flex: 1;
    overflow-y: auto;
    min-height: 0;
    padding: 0.5rem 0;
    font-family: var(--cx-font-mono);
    font-size: 0.8rem;
}

.cx-raw-row {
    display: flex;
    gap: 0.75em;
    align-items: baseline;
    padding: 3px 12px;
    border-bottom: 1px solid var(--cx-border-dim);
    line-height: 1.4;
}

.cx-raw-row:last-child {
    border-bottom: none;
}

.cx-raw-ts {
    flex: none;
    color: var(--cx-text-dim);
    width: 19ch;
}

.cx-raw-type {
    flex: none;
    width: 6ch;
    text-align: center;
    font-weight: bold;
    text-transform: uppercase;
    font-size: 0.75em;
    letter-spacing: 0.05em;
    padding: 1px 4px;
    border-radius: var(--cx-radius);
    box-sizing: content-box;
}

.cx-raw-type--cue   { color: var(--cx-accent);    background: color-mix(in srgb, var(--cx-accent) 15%, transparent); }
.cx-raw-type--media { color: var(--cx-media); background: color-mix(in srgb, var(--cx-media) 15%, transparent); }

.cx-raw-detail {
    flex: 1;
    min-width: 0;
    color: var(--cx-text);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Red — alert or accent */
.cx-pane-red {
    background: var(--cx-red-bg);
    color: var(--cx-red-color);
}
/* Panes carry their own surface background so the page background (or a
 * meta background image) shows only in the gaps between them, never
 * through the pane body. Matches the raised-panel role of the header.
 * A background= param on the pane overrides this (inline style wins).
 * Override in org CSS for translucency etc. */
[data-cx^="pane"] {
    background: var(--cx-surface);
}
/* ── Page chrome ─────────────────────────────────────────────────────── */
.cx-header {
    display: flex;
    align-items: baseline;
    gap: 0.75rem;
    flex-wrap: wrap;
    flex-shrink: 0;
    padding: 0.6rem 1rem;
    background: var(--cx-surface);
    border-bottom: 1px solid var(--cx-header-border);
}
.cx-status {
    padding: 0.25rem 1rem;
    font-size: 0.78rem;
    background: var(--cx-surface);
    border-bottom: 1px solid var(--cx-border-dim);
    flex-shrink: 0;
}
.cx-footer {
    padding: 0.5rem 1rem;
    border-top: 1px solid var(--cx-border-dim);
    display: flex;
    gap: 1rem;
    align-items: center;
    flex-shrink: 0;
}
/* ── Typography ──────────────────────────────────────────────────────── */

/* Brand link */
.cx-brand-link { font-family: var(--cx-font-serif-title); color: var(--cx-brand); font-size: 1.1rem; }
.cx-brand-span {
    color: inherit; opacity: var(--cx-brand-span-opacity);
}
/* ── Text content elements — shape only, colour from panel ───────────── */
/* Overlay/gloss text use the display serif (EB Garamond). */
.cx-overlay-text,
.cx-gloss-line {
    font-family: var(--cx-font-serif-display);
}
/* ── Score image ─────────────────────────────────────────────────────── */
.cx-score-image {
    max-width: 100%;
    max-height: 100%;
    display: block;
    margin: 0 auto;
    opacity: 0;
    transition: opacity var(--cx-transition-slow);
}
/* ── Score image ─────────────────────────────────────────────────────── */
img[data-cx~="score.image"],
[data-cx~="score.image"] > img {
    opacity: 0;
    transition: opacity var(--cx-transition-slow);
}
img[data-cx~="score.image"].loaded,
[data-cx~="score.image"] > img.loaded {
    opacity: 1;
}
.cx-score-image.loaded { opacity: 1; }
/* ── Image annotations (pins) — annotate= ─────────────────────────────────
 * A .cx-pin-host wraps one image and positions .cx-pin-layer over it; pins
 * are placed inside the layer at percentage offsets, so they track the page
 * at any rendered size. The layer carries container-type: inline-size and
 * pins size in cqw, so a pin is the same proportion of the page on a phone
 * and on a desk monitor — a rem size would be right on neither.
 *
 * Boxed by default: score pages are dense, and unboxed text over staff lines
 * and slurs is unreadable. `plain` swaps the box for an outline halo.
 * Colours are literal rather than themed — a pin sits on a white score page
 * whichever theme the operator is running. */
/* content-visibility: auto — the browser skips rendering a page it cannot see,
 * and may drop its raster.
 *
 * Every page of the score is in the DOM and fetched up front, so that jumping
 * a long way is instant; that is a deliberate trade and stays. What does not
 * have to stay is several hundred full-width rasterised SVGs held at once,
 * which is what killed the tab on an iPad — Safari's "a problem repeatedly
 * occurred" is what running out of memory looks like there.
 *
 * A skipped host lays out as if empty, so it needs two things to keep its
 * height, or every scroll position and landing measurement is wrong:
 *   - contain-intrinsic-height — set inline by applyIntrinsicHeight in
 *     cx-render.js, from the pane width and the page ratio;
 *   - flex-shrink: 0 — the pane is a flex column, and an empty flex item is
 *     shrunk to zero whatever its intrinsic height says.
 * Without them, 287 of 296 hosts measured zero height. */
.cx-pin-host {
    content-visibility: auto;
    flex-shrink: 0;
    position: relative;
    display: block;
}
.cx-pin-layer {
    position: absolute;
    inset: 0;
    pointer-events: none;
    container-type: inline-size;
}
/* Fitted layers are measured in JS (object-fit: contain letterboxes the
 * painting inside the box) — inset would fight the computed left/top/size. */
.cx-pin-layer-fitted {
    inset: auto;
}
.cx-pin {
    position: absolute;
    /* The click lands below the box's bottom-left corner, at the end of the
     * stalk, so a label starts where it was placed and sits above the stave,
     * as it would on paper. Centring meant a longer label reached back over
     * the preceding bar, so the same click meant a different place depending
     * on how much the label said. The drop is what lifts the box off the
     * point; with `crosshair` it is zero and the corner sits on the point. */
    transform: translate(0, calc(-100% - var(--cx-pin-drop)));
    /* Wraps to a second line rather than running on — see the clamp below.
     * line-height has to leave the two lines legible without inflating the
     * common single-line box, which 1.15 does and 1.4 does not. */
    white-space: normal;
    overflow-wrap: break-word;
    line-height: 1.15;
    font-family: var(--cx-font-body);
    /* Style words written onto a pin set the same --cx-* variables every other
     * element uses; --cx-pin-* is the fallback half of each chain, so a word
     * wins by coming first rather than by specificity. Same shape as the
     * scroll row rule. */
    font-size: calc(var(--cx-scale, 1) * var(--cx-pin-scale, 1) * 1.8cqw);
    /* 500, not 700: bold sets wider and reads heavier, and on a yellow field
     * the fill already does the separating the weight was there for. The
     * `bold` and `regular` words still override, as before. */
    font-weight: var(--cx-weight, var(--cx-pin-weight, 500));
    font-style: var(--cx-fstyle, var(--cx-pin-style, normal));
    color: var(--cx-color, var(--cx-pin-color, var(--cx-blue)));
    /* Half-transparent so the notation stays legible through the box.
     *
     * text-shadow is cleared explicitly, not merely left unset: it inherits,
     * so a pane carrying `outline` handed every pin an eight-direction halo,
     * and at pin size that reads as a smear rather than an outline. The box
     * already separates the label from the notation. The soft shadow below is
     * on the BOX, which is a different thing. */
    text-shadow: none;
    /* Literal yellow, not themed --cx-gold: gold resolves to #9a7b00 on the
     * inverted theme, and a dark mustard at half strength over a white score
     * page is the murk. A high-luminance yellow stays light as it composites,
     * so it can be more opaque and still read as see-through. Alpha is its
     * own variable — the one thing worth tuning against a real page.
     * Was: color-mix(in srgb, var(--cx-pin-bg, var(--cx-gold)) 50%, transparent). */
    /* fill= (stage 3, B.3.61) sets --cx-pin-fill and --cx-pin-fill-alpha on
     * the pin; this default is what a pin with no fill= keeps. */
    background: color-mix(in srgb,
                          var(--cx-pin-fill, var(--cx-pin-bg, var(--cx-pin-bg-color)))
                          var(--cx-pin-fill-alpha, var(--cx-pin-bg-alpha)), transparent);
    /* The edge follows the pin's own colour where it has one, and falls back
     * to black where it does not. A colour change in the label alone is easy
     * to miss on a page of notation, and the edge is also what separates a pin
     * from the staves under it — so an uncoloured pin must keep its black one.
     *
     * A pin sits in the page host, outside its pane, so there is no pane colour
     * for it to inherit by accident: --cx-color is set here only by a style
     * word written onto the pin itself, which is exactly when the edge should
     * follow it. */
    /* edge= (stage 3) comes first; without it, as before. */
    border: 0.06em solid var(--cx-pin-edge, var(--cx-color, var(--cx-black)));
    border-radius: 0.25em;
    padding: 0.22em 0.4em;
    box-shadow: 0 0.04em 0.12em rgb(0 0 0 / 0.25);  /* cx-colour-ok — a soft drop shadow, not a theme colour */
}

/* ── Two lines, then stop ─────────────────────────────────────────────────
 * A label wraps to a second line before anything is cut, and a third line is
 * clamped away with an ellipsis. Without a max-width there is nothing to wrap
 * against — an absolutely positioned box shrinks to fit its text and would
 * run on forever — so the width is what decides where line one breaks.
 *
 * --cx-pin-max-width and PIN_MAX_CHARS in cx-render.js are a pair: the width
 * is roughly twelve characters at this size, and the character cap is two
 * lines of that. Retune one and the other wants the same treatment, or the
 * text is cut before it can reach line two (cap too low) or the clamp does
 * the cutting instead of the cap (cap too high).
 *
 * Image and unlabelled pins are excluded: neither holds text, and both set
 * their own dimensions that a max-width here would fight.
 *
 * The clamp goes on the label's own box, NOT on the pin: clamping needs
 * overflow:hidden, and on the pin that clips the stalk and the dot, which are
 * drawn outside it. cx-render.js puts the label in .cx-pin-text for this. */
/* bright on a pin mixes toward black in both themes, never --cx-contrast:
 * a pin sits on a white score page either way. Same @supports guard as the
 * text rule. */
@supports (color: color-mix(in oklch, red, blue)) {
.cx-pin {
    color: color-mix(in oklch,
                     var(--cx-color, var(--cx-pin-color, var(--cx-blue))),
                     var(--cx-paper-black) var(--cx-bright, 0%));
}
/* ...and raises the fill's strength by the same amount, so a bright pin reads
 * as one: a darker label on a paler box would be half of it. Its own rule, so
 * a browser that cannot do min() inside color-mix loses only this. */
.cx-pin[data-cx~="bright"] {
    background: color-mix(in srgb,
                          var(--cx-pin-fill, var(--cx-pin-bg, var(--cx-pin-bg-color)))
                          min(100%, calc(var(--cx-pin-fill-alpha, var(--cx-pin-bg-alpha)) + var(--cx-bright, 0%))),
                          transparent);
}
}

.cx-pin:not(.cx-pin-image):not(.cx-pin-unlabelled) {
    max-width: var(--cx-pin-max-width, 6em);
}
/* The ghost — what is about to be placed, following the cursor.
 *
 * Built by the same buildPin as a real pin, so everything about how it reads
 * is inherited from .cx-pin above and only these three lines differ. Half
 * strength so the notation stays legible under it, and transparent to the
 * pointer so the click it is previewing reaches the page beneath. */
.cx-pin-ghost {
    opacity: 0.55;
    pointer-events: none;
}
/* The ghost's layer sits over the page's own, so a ghost is never hidden
 * behind a pin already placed there. */
.cx-pin-ghost-layer { z-index: 2; }

/* Crosshair while placing: the pointer marks the anchor point and the ghost
 * carries the label, which is the division of labour a cursor image cannot
 * manage on its own. */
.cx-pinning-armed .cx-pin-host > img { cursor: crosshair; }

/* ── Off-view pin markers ────────────────────────────────────────────────
 * Stuck to the top and bottom edges of the scrolling score, naming the
 * sources that have pins beyond it and how many. position: sticky rather
 * than fixed or absolute: the pane is the scroll container, so sticky holds
 * the bar at the edge while everything else moves under it, and it costs no
 * scroll listener. */
/* The chips OVERLAY the score rather than sitting above it.
 *
 * A fixed height, cancelled by an equal negative margin: the bar draws at full
 * size and contributes nothing to the scroll flow. It used to be display:none
 * when empty and ~28px when not, so every page below it moved as chips came
 * and went — and since chips come and go BECAUSE of where you have scrolled
 * to, landing a page could change the thing that decided where to land it.
 * That is what made the score jump when you clicked a chip or an early cue.
 *
 * The cost is a chip or two covering the top corner of a landed page, which
 * is a poor place to want to pin anyway, and scrolling a little moves them. */
.cx-pin-marks {
    position: sticky;
    z-index: 3;
    display: flex;
    gap: 0.4rem;
    justify-content: flex-end;
    padding: 0.25rem 0.4rem;
    height: var(--cx-pin-marks-height);
    pointer-events: none;     /* the bar is not a surface; its chips are */
}
.cx-pin-marks-up   { top: 0; margin-bottom: calc(-1 * var(--cx-pin-marks-height)); }
/* A pinning pane has the arm bar stuck above, so the chips start below it. A
 * display pane has no bar, and they sit on the pane's own top edge. */
.cx-pinning > .cx-pin-marks-up { top: var(--cx-pin-arm-height); }
.cx-pin-marks-down { bottom: 0; margin-top: calc(-1 * var(--cx-pin-marks-height)); }
.cx-pin-marks:empty { display: none; }
.cx-pin-mark {
    pointer-events: auto;
    cursor: pointer;
    font-size: 0.75rem;
    line-height: 1;
    padding: 0.25em 0.5em;
    border: 1px solid var(--cx-border);
    border-radius: var(--cx-radius);
    background: var(--cx-surface);
    /* Same chain as a pin: a style word on the pane reaches the chip, so a
     * source is the same colour wherever it appears. */
    color: var(--cx-color, var(--cx-text-mid));
}
.cx-pin-mark:hover { background: var(--cx-surface-mid); }

.cx-pin-text {
    display:            -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    line-clamp:         2;
    overflow:           hidden;
}
/* ── Where the box sits, and the marker on the point ──────────────────────
 * The marker is a stalk: a thin descender off the box's bottom left corner
 * with a small dot at its end, the dot being where the operator clicked. The
 * box is lifted clear of the point by the length of the stalk (--cx-pin-drop),
 * so the notation under the point stays uncovered.
 *
 * The earlier crosshair marker is parked in a comment below this section —
 * an edit away, not a runtime choice.
 *
 * Either marker anchors the same bottom left corner, so a label starts where
 * it was placed and reads rightwards, and the edge test in cx-render.js is
 * the same for both.
 *
 * Unlabelled pins have neither marker — they are already a dot on their own
 * point, and they never get the flip classes (cx-render.js exempts them). */

/* Flipping. The box opens away from its point, and near an edge it opens the
 * other way instead — right edge, top edge, or at the top right both at once.
 * The anchor corner and the marker move with it, so the point never shifts. */
.cx-pin.cx-pin-flip-x {
    transform: translate(-100%, calc(-100% - var(--cx-pin-drop)));
}
.cx-pin.cx-pin-flip-y {
    transform: translate(0, var(--cx-pin-drop));
}
.cx-pin.cx-pin-flip-x.cx-pin-flip-y {
    transform: translate(-100%, var(--cx-pin-drop));
}

/* The stalk. Drawn in currentColor so it follows the pin's own colour, and
 * outside the half-transparent box so the two do not stack into a darker
 * patch — the same reason the crosshair sits outside it. */
.cx-pin::before {
    content:    '';
    position:   absolute;
    left:       0;
    bottom:     0;
    width:      0.07em;
    height:     var(--cx-pin-drop);
    transform:  translate(-50%, 100%);
    background: var(--cx-pin-marker, currentColor);   /* marker= (stage 3) */
    opacity:    0.55;
    pointer-events: none;
}

/* The dot, centred on the point itself. `bottom` carries the stalk's length
 * PLUS half the dot, so the dot's centre — not an edge — lands on the point
 * and the stalk's end is covered by its upper half. Doing it this way keeps
 * the vertical maths off percentages of the dot's own size, which is what
 * left a stub of stalk showing below it. */
.cx-pin::after {
    content:       '';
    position:      absolute;
    left:          0;
    bottom:        calc(-1 * (var(--cx-pin-drop) + var(--cx-pin-dot) / 2));
    width:         var(--cx-pin-dot);
    height:        var(--cx-pin-dot);
    border-radius: 50%;
    background:    var(--cx-pin-marker, currentColor);   /* marker= (stage 3) */
    transform:     translateX(-50%);
    pointer-events: none;
}

.cx-pin.cx-pin-flip-x::before { left: auto; right: 0; transform: translate(50%, 100%); }
.cx-pin.cx-pin-flip-x::after  { left: auto; right: 0; transform: translateX(50%); }

/* flip-y turns the descender into an ascender, the box hanging below. */
.cx-pin.cx-pin-flip-y::before { bottom: auto; top: 0; transform: translate(-50%, -100%); }
.cx-pin.cx-pin-flip-y::after  {
    bottom: auto; top: calc(-1 * (var(--cx-pin-drop) + var(--cx-pin-dot) / 2));
    transform: translateX(-50%);
}
.cx-pin.cx-pin-flip-x.cx-pin-flip-y::before {
    left: auto; right: 0; bottom: auto; top: 0;
    transform: translate(50%, -100%);
}
.cx-pin.cx-pin-flip-x.cx-pin-flip-y::after {
    left: auto; right: 0; bottom: auto;
    top: calc(-1 * (var(--cx-pin-drop) + var(--cx-pin-dot) / 2));
    transform: translateX(50%);
}

/* ── Crosshair marker — parked ────────────────────────────────────────────
 * The earlier marker: the box's corner straight on the point, a light
 * diagonal cross centred on it, no stalk and no dot. Kept here verbatim, not
 * live and not switchable — to go back, comment out the stalk rules above
 * (the ::before, the ::after and the four transforms) and uncomment this.
 * The box's own drop comes from --cx-pin-drop in the base rule: set it to 0
 * in the same edit, or the corner will sit a stalk's length above the point.
 *
 * .cx-pin                              { transform: translate(0, -100%); }
 * .cx-pin.cx-pin-flip-x                { transform: translate(-100%, -100%); }
 * .cx-pin.cx-pin-flip-y                { transform: translate(0, 0); }
 * .cx-pin.cx-pin-flip-x.cx-pin-flip-y  { transform: translate(-100%, 0); }
 *
 * .cx-pin::after { content: none; }
 *
 * .cx-pin::before {
 *     content:    '';
 *     position:   absolute;
 *     left:       0;
 *     bottom:     0;
 *     width:      0.6em;
 *     height:     0.6em;
 *     transform:  translate(-50%, 50%);
 *     opacity:    0.55;
 *     pointer-events: none;
 *     background:
 *         linear-gradient(45deg,  transparent calc(50% - 0.025em), currentColor calc(50% - 0.025em),
 *                                 currentColor calc(50% + 0.025em), transparent calc(50% + 0.025em)),
 *         linear-gradient(-45deg, transparent calc(50% - 0.025em), currentColor calc(50% - 0.025em),
 *                                 currentColor calc(50% + 0.025em), transparent calc(50% + 0.025em));
 * }
 *
 * .cx-pin.cx-pin-flip-x::before { left: auto; right: 0; transform: translate(50%, 50%); }
 * .cx-pin.cx-pin-flip-y::before { bottom: auto; top: 0; transform: translate(-50%, -50%); }
 * .cx-pin.cx-pin-flip-x.cx-pin-flip-y::before {
 *     left: auto; right: 0; bottom: auto; top: 0;
 *     transform: translate(50%, -50%);
 * }
 */

.cx-pin.cx-pin-plain {
    background: none;
    border: none;
    padding: 0;
    text-shadow:
         0.06em 0 #ffffff,  -0.06em 0 #ffffff,      /* cx-colour-ok */
         0 0.06em #ffffff,  0 -0.06em #ffffff,      /* cx-colour-ok */
         0.042em 0.042em #ffffff, -0.042em 0.042em #ffffff,   /* cx-colour-ok */
         0.042em -0.042em #ffffff, -0.042em -0.042em #ffffff; /* cx-colour-ok */
}
.cx-pin-image {
    padding: 0;
    border: none;
    background: none;
}
.cx-pin-image > img {
    display: block;
    width: 12cqw;
    height: auto;
}

/* ── Pinning (the `pinning` word) ────────────────────────────────────────
 * Placing a cue onto a page. The armed state has to be obvious: a describer
 * needs to see which cue they are about to place, and to see just as clearly
 * when nothing is armed, so a stray click that does nothing looks like it did
 * nothing. */
.cx-pinning-armed { cursor: crosshair; }
.cx-pinning-armed .cx-pin-host > img { cursor: crosshair; }
/* The layer is pointer-events: none so a click reaches the page beneath it —
 * that is how a pin is placed. A PIN itself has to take the pointer, though:
 * clicking one cues its line wherever the pane is `controlling`, and dragging
 * one moves it wherever the pane is placing that source. This used to be set
 * only by .cx-pin-draggable, so on a pane that was not authoring no pin could
 * be clicked at all and click-to-cue could never fire. */
.cx-pin { pointer-events: auto; }
[data-cx~="controlling"] .cx-pin { cursor: pointer; }
.cx-pin-draggable { cursor: grab; }

/* Removing one position. A small cross on the pin itself, revealed on hover:
 * no selection state to hold, and no keyboard handling to fight the cue keys
 * the cue list beside this pane is already listening for. */
.cx-pin-del {
    position: absolute;
    top: -0.55em;
    right: -0.55em;
    width: 1.1em;
    height: 1.1em;
    padding: 0;
    display: none;
    align-items: center;
    justify-content: center;
    font: inherit;
    font-size: 0.75em;
    line-height: 1;
    border: 0.06em solid var(--cx-black);
    border-radius: 50%;
    background: var(--cx-surface);
    color: var(--cx-text);
    cursor: pointer;
}
.cx-pin-draggable:hover > .cx-pin-del { display: flex; }
.cx-pin-del:hover { color: var(--cx-err); border-color: var(--cx-err); }
.cx-pin-draggable:active { cursor: grabbing; }

/* Sticks to the top of the scrolling pane, so it stays in view while the
 * describer scrolls to the right page.
 *
 * Height, not min-height: the bar is permanent on a pinning pane and the
 * off-view markers clear it by exactly --cx-pin-arm-height, so a bar that
 * grew with its contents would put the chips through its own bottom edge. */
.cx-pin-arm {
    position: sticky;
    top: 0;
    height: var(--cx-pin-arm-height);
    z-index: 3;
    display: flex;
    align-items: center;
    gap: 0.6rem;
    padding: 0.4rem 0.6rem;
    background: var(--cx-surface);
    border-bottom: 1px solid var(--cx-brand);
    font-size: 0.8rem;
}
.cx-pin-arm-line {
    font-family: var(--cx-font-mono);
    font-size: 0.7rem;
    color: var(--cx-text-mid);
    flex: none;
}
.cx-pin-arm-input {
    flex: 1;
    min-width: 6rem;
    /* Capped, so there is always visible space between the cue group and the
     * save group. At flex: 1 unbounded the input grew until the two met. */
    max-width: 22rem;
    background: var(--cx-bg);
    color: var(--cx-text);
    border: 1px solid var(--cx-border);
    border-radius: 3px;
    padding: 0.25rem 0.4rem;
    font-size: 0.8rem;
}
.cx-pin-arm-hint { color: var(--cx-text-mid); font-size: 0.72rem; flex: none; }
.cx-pin-arm-cancel {
    font: inherit;
    font-size: 0.72rem;
    flex: none;
    padding: 0.2rem 0.6rem;
    background: var(--cx-bg);
    color: var(--cx-text);
    border: 1px solid var(--cx-border);
    border-radius: 3px;
    cursor: pointer;
}
.cx-pin-arm-cancel:hover { border-color: var(--cx-err); color: var(--cx-err); }

/* The source being authored — a menu where the pane draws more than one
 * source, plain text where it draws one. Outside the armed-only group below:
 * which source you are about to author is a property of the pane, not of the
 * cue, so it stays visible while the bar is idle. */
.cx-pin-arm-source {
    flex: none;
    font-size: 0.8rem;
    color: var(--cx-text-strong);
}
.cx-pin-arm-select {
    font: inherit;
    color: inherit;
    background: transparent;
    border: 1px solid transparent;
    border-radius: 3px;
    padding: 0.1rem 0.2rem;
    cursor: pointer;
}
.cx-pin-arm-select:hover  { border-color: var(--cx-border); }
.cx-pin-arm-select:focus  { outline: none; border-color: var(--cx-brand); }

/* Idle and armed are one bar, not two. Everything naming the current cue is
 * hidden until there is one, and `Awaiting a cue` stands in its place. The
 * pane carries .cx-pinning-armed, so the whole row turns on one class. */
.cx-pin-arm-idle { color: var(--cx-text-mid); font-size: 0.72rem; flex: 1; }
.cx-pinning-armed .cx-pin-arm-idle { display: none; }
.cx-pin-arm-line,
.cx-pin-arm-input,
.cx-pin-arm-hint,
.cx-pin-arm-cancel  { display: none; }
.cx-pinning-armed .cx-pin-arm-line   { display: inline; }
.cx-pinning-armed .cx-pin-arm-input  { display: block; }
.cx-pinning-armed .cx-pin-arm-hint   { display: inline; }
.cx-pinning-armed .cx-pin-arm-cancel { display: inline-block; }

/* Where the save control homes. Hard right in both states, so it does not
 * move when a cue arrives and the rest of the row appears.
 *
 * The rule is the boundary between the two halves of the bar: this cue on the
 * left, the pack's saved state on the right. Without it Cancel and Save sat
 * side by side and read as one pair of choices. */
.cx-pin-arm-save {
    flex: none;
    margin-left: auto;
    display: flex;
    align-items: center;
    align-self: stretch;
    padding-left: 0.7rem;
    border-left: 1px solid var(--cx-border);
}

/* A position placed on a cue that has no words yet. Shown only while pinning,
 * and shown differently, so it reads as "placed, not yet named" rather than as
 * a finished label. */
/* Save control for a pinning page. Created by cx-annotate-save.js rather than
 * by the page, so a pinning page needs no markup of its own.
 *
 * It sits inside the arm bar, in the slot the renderer leaves for it. It was
 * fixed to the window and positioned by --cx-pin-arm-height, a measure of a
 * bar sticky inside the score pane — the two lined up by coincidence, and its
 * z-index put it over the pins it was meant to be saving. Saving is part of
 * pinning, so it belongs in the pinning chrome. No border or background of its
 * own here: inside the bar it is two items in a row, not a panel. */
.cx-pin-save {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    font-size: 0.72rem;
    color: var(--cx-text-mid);
}
.cx-pin-save-dirty { color: var(--cx-brand); }

/* No bar to home into — a page that says `pinning` where the renderer could
 * not give it one. The control is still reachable rather than lost at the
 * bottom of the document. */
.cx-pin-save-loose {
    position: fixed;
    top: 0.5rem;
    right: 1rem;
    z-index: 20;
    padding: 0.4rem 0.6rem;
    background: var(--cx-surface);
    border: 1px solid var(--cx-border);
    border-radius: 4px;
}
.cx-pin-save-loose.cx-pin-save-dirty { border-color: var(--cx-brand); }
.cx-pin-save-btn {
    font: inherit;
    padding: 0.2rem 0.7rem;
    background: var(--cx-bg);
    color: var(--cx-text);
    border: 1px solid var(--cx-border);
    border-radius: 3px;
    cursor: pointer;
}
.cx-pin-save-btn:disabled { opacity: 0.4; cursor: default; }

/* Resume from: [first] [last] — in the arm bar, before the save divider.
 * The buttons borrow the save button's look; a minimum width so a short
 * label such as `9` is still a comfortable target, and so the pair keeps its
 * shape as the numbers change. Visibility is set inline by the renderer. */
.cx-pin-arm-resume {
    flex: none;
    display: flex;
    align-items: center;
    gap: 0.35rem;
    font-size: 0.72rem;
    color: var(--cx-text-mid);
}
.cx-pin-arm-resume .cx-pin-save-btn { min-width: 3.5rem; }

/* Neither marker: an unlabelled pin IS the dot on its own point. */
.cx-pin-unlabelled::before,
.cx-pin-unlabelled::after { content: none; }
.cx-pin-unlabelled {
    transform: translate(-50%, -50%);
    width: 1.4cqw;
    height: 1.4cqw;
    min-width: 0;
    padding: 0;
    border-radius: 50%;
    background: var(--cx-pin-color, #222222);   /* cx-colour-ok — sits on a white score page */
    border: 0.12em solid #ffffff;               /* cx-colour-ok — separates it from staff lines */
}

/* Score full-screen overlay text */
.cx-overlay-text {
    position: absolute;
    bottom: 3%;
    left: 0; right: 0;
    text-align: center;
    font-size: 3vw;
    color: var(--cx-overlay-color);
    text-shadow: 0 0 8px var(--cx-overlay-halo), 1px 1px var(--cx-overlay-halo);
    pointer-events: none;
    padding: 0 2%;
}
/* ── Splash ──────────────────────────────────────────────────────────── */
[data-cx="splash"] {
    position: fixed;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    background: var(--cx-splash-bg);
    color: var(--cx-text-mid);
    font-size: 1.2rem;
    z-index: 100;
    cursor: pointer;
    gap: 1rem;
}
[data-cx="splash"].cx-done { display: none; }

/* ── Fullscreen prompt ───────────────────────────────────────────────── */
/* "Permit fullscreen?" — drawn by cx-render on a page whose body carries
 * `fullscreen`, where the browser can go full screen. Fixed and centred over
 * everything, splash included: it lasts three seconds, and anything over it
 * would use them up. Buttons 48px high for the touch-target minimum, and 16px
 * text or more so iOS never zooms. Themed, so it is dark in the auditorium. */
.cx-fs-prompt {
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    z-index: 10001;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 1rem;
    padding: 1.25rem 1.5rem;
    max-width: calc(100vw - 2rem);
    background: var(--cx-bg-high);
    color: var(--cx-text);
    border: 1px solid var(--cx-border);
    border-radius: var(--cx-radius);
    font-size: 1.125rem;
    text-align: center;
}
.cx-fs-prompt-buttons {
    display: flex;
    gap: 0.75rem;
}
.cx-fs-prompt button {
    min-height: 48px;
    min-width: 6rem;
    padding: 0.6rem 1.2rem;
    font-size: 1.125rem;
    border: 1px solid var(--cx-border);
    border-radius: var(--cx-radius);
    background: var(--cx-surface);
    color: var(--cx-text);
    cursor: pointer;
}
.cx-fs-prompt button:hover,
.cx-fs-prompt button:focus-visible { background: var(--cx-surface-high); }

[data-cx~="scroll"] [class*="cx-scroll-line-"]:hover { background: var(--cx-surface-mid); }
/* Current-line highlight. This rule now supplies DEFAULTS ONLY, through the
 * --cx-now-* channel the row rule above reads as the second half of each
 * var() chain. It deliberately sets no --cx-* variable and no direct colour:
 * either would compete with a :: word on the row at specificity, and the word
 * would lose. --cx-now-bg-default replaced --cx-hl-bg-default in B.3.60, the
 * last of the --cx-hl-* names. */
[data-cx~="scroll"] .cx-scroll-now {
    --cx-now-bg: var(--cx-now-bg-default);
    --cx-now-color: var(--cx-focus-color);
    border-left: 3px solid var(--cx-color, var(--cx-now-border, var(--cx-now-color)));
}
/* REMOVED (B.3.45): the gold-on-a-scroll-list variant.
 *
 *     [data-cx~="scroll"][data-cx~="gold"]                { --cx-color: initial; }
 *     [data-cx~="scroll"][data-cx~="gold"] .cx-scroll-now { …gold highlight… }
 *
 * `gold` meant one thing on a list and another everywhere else — the highlight
 * rather than the text — and the first rule existed only to stop the same word
 * doing both. A word with two meanings is bad enough; the real cost was that a
 * list written `gold` could then say nothing at all about its text colour,
 * from the page or from a URL, because two attribute selectors outranked every
 * colour word at one.
 *
 * A cued group says the same thing without overloading a colour word, which
 * is what the removed rule's own comment recommended. A page that wants the
 * gold current-row treatment writes (since stage 2 — it was highlight=gold):
 *
 *     st scroll index=ordinal ::gold
 *
 * The --cx-gold-hl-* variables are left defined: they are the palette, and the
 * themes set them. */
/* ── Buttons ─────────────────────────────────────────────────────────── */
.cx-btn {
    padding: 4px 10px;
    border: 1px solid var(--cx-border);
    border-radius: var(--cx-radius);
    background: var(--cx-surface);
    color: var(--cx-text);
    cursor: pointer;
    font-size: 0.82rem;
    transition: background var(--cx-transition-fast);
}
.cx-btn:hover { background: var(--cx-surface-high); }
.cx-btn-blank {
    background: var(--cx-negative-bg);
    color: var(--cx-negative-color);
    border: var(--cx-negative-border);
}
.cx-btn-blank:hover {
    background: var(--cx-negative-bg-hover);
}
/* ── Status indicator ────────────────────────────────────────────────── */
#cx-conn-status { color: var(--cx-text-muted); }
#cx-conn-status.ok {
    color: var(--cx-ok);
}
#cx-conn-status.err {
    color: var(--cx-err);
}
/* ── Cursor (waiting indicator) ──────────────────────────────────────── */
.cx-cursor::after {
    color: var(--cx-cursor-color);
    display: inline-block;
    vertical-align: bottom;
    animation: cx-ellipsis steps(3, start) 3000ms infinite;
    content: "\2026";
    width: 0;
    overflow: hidden;
}
@keyframes cx-ellipsis { to { width: 1em; } }
/* ── No-pack message ─────────────────────────────────────────────────── */
.cx-no-pack {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--cx-text-muted);
}
/* ── Fade utilities ──────────────────────────────────────────────────── */
.cx-fade-in { animation: cxFadeIn  var(--cx-transition-slow) forwards; }
.cx-fade-out { animation: cxFadeOut var(--cx-transition-slow) forwards; }
@keyframes cxFadeIn { from { opacity:0 } to { opacity:1 } }
@keyframes cxFadeOut { from { opacity:1 } to { opacity:0 } }
/* ── Nudge component ─────────────────────────────────────────────────── */
.cx-nudge-inner {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    flex-wrap: wrap;
}
.cx-nudge-value {
    font-family: var(--cx-font-mono);
    font-size: 1.1rem;
    color: var(--cx-text);
    min-width: 5em;
    text-align: center;
}
.cx-nudge-btn {
    padding: 0.4rem 0.75rem;
    border: 1px solid var(--cx-border);
    border-radius: var(--cx-radius);
    background: var(--cx-surface);
    color: var(--cx-text);
    cursor: pointer;
    font-size: 0.85rem;
    font-family: var(--cx-font-mono);
    transition: background var(--cx-transition-fast);
}
.cx-nudge-btn:hover { background: var(--cx-surface-high); }
.cx-nudge-reset {
    border-color: var(--cx-accent-dim);
    color: var(--cx-nudge-reset-color);
}

.cx-auto-left:last-child,
.cx-auto-right:last-child { border-bottom: none; }
/* ── Nudge track ─────────────────────────────────────────────────────── */
.cx-nudge-track-wrap {
    display: flex;
    flex-direction: column;
    gap: 2px;
}
.cx-nudge-track-labels {
    display: flex;
    justify-content: space-between;
    font-size: 0.7rem;
    color: var(--cx-text-dim);
    font-family: var(--cx-font-mono);
}
.cx-nudge-track {
    position: relative;
    height: 18px;
    background: var(--cx-surface);
    border: 1px solid var(--cx-border);
    border-radius: var(--cx-radius);
    cursor: pointer;
    overflow: hidden;
}
.cx-nudge-tick {
    position: absolute;
    top: 30%;
    width: 1px;
    height: 40%;
    background: var(--cx-border);
    transform: translateX(-50%);
}
.cx-nudge-tick.major {
    height: 70%;
    top: 15%;
    background: var(--cx-text-dim);
}
.cx-nudge-pointer {
    position: absolute;
    top: 0; bottom: 0;
    width: 2px;
    background: var(--cx-text);
    transform: translateX(-50%);
    left: 50%;
    transition: left 0.1s;
}
.cx-nudge-pointer.negative {
    background: var(--cx-nudge-neg);
}
.cx-nudge-pointer.positive {
    background: var(--cx-nudge-pos);
}
/* ── Play/Pause button ───────────────────────────────────────────────── */
.cx-btn-play-pause {
    padding: 0.3rem 1rem;
    border-radius: var(--cx-radius);
    border: 1px solid var(--cx-border);
    background: var(--cx-play-bg);
    color: var(--cx-text);
    font-size: 0.9rem;
    cursor: pointer;
    white-space: nowrap;
    transition: background 0.15s;
    min-width: 6rem;
}
.cx-btn-play-pause:hover {
    background: var(--cx-play-bg-hover);
}
.cx-btn-play-pause.playing {
    background: var(--cx-play-active-bg);
    border-color: var(--cx-play-active-border);
    color: var(--cx-play-active-color);
}
/* ── Nudge controls ──────────────────────────────────────────────────── */
.cx-nudge-controls {
    display: flex;
    align-items: center;
    gap: 0.3rem;
    background: var(--cx-nudge-ctrl-bg);
    border: 1px solid var(--cx-nudge-ctrl-border);
    border-radius: var(--cx-radius);
    padding: 0.2rem 0.5rem;
}
.cx-nudge-controls .cx-nudge-btn {
    padding: 0.2rem 0.5rem;
    border-radius: var(--cx-radius);
    border: 1px solid var(--cx-nudge-ctrl-border);
    background: var(--cx-nudge-btn-bg);
    color: var(--cx-text);
    font-size: 0.82rem;
    cursor: pointer;
    white-space: nowrap;
    font-family: var(--cx-font-mono);
}
.cx-nudge-controls .cx-nudge-btn:hover {
    background: var(--cx-nudge-btn-hover);
}
.cx-nudge-val {
    font-size: 0.88rem;
    font-family: var(--cx-font-mono);
    min-width: 5em;
    text-align: center;
    color: var(--cx-text);
}
.cx-nudge-val.negative {
    color: var(--cx-nudge-neg);
}
.cx-nudge-val.positive {
    color: var(--cx-nudge-pos);
}
/* ── Glossary scroll display ─────────────────────────────────────────── */
.cx-gloss-scroll {
    flex: 1;
    overflow-y: auto;
    padding: 1.5rem 2rem;
}
/* No rule between chunks and no colour of its own (B.3.62): each chunk is a
 * piece of running speech, not a separate item, and its colour comes from the
 * element like any other text. */
.cx-gloss-line {
    font-size: var(--cx-size, calc(var(--cx-scale, 1) * clamp(1.4rem, 5vw, 3.5rem)));
    line-height: 1.16;
    transition: color 0.3s;
}
[data-cx*="glossary"] .cx-gloss-line:last-child { color: var(--cx-text-strong); }
[data-cx*="glossary"] .cx-gloss-line:not(:last-child) { opacity: 0.55; }
.cx-gloss-match {
    /* terms= words, or the pane's :: words, are written onto this span, so
     * --cx-* is read here directly. A declaration on the span beats the pane's
     * inherited value; with neither the chain falls through to the surrounding
     * text, which is what --cx-hl-color did before. */
    color: var(--cx-color, inherit);
    text-decoration: var(--cx-decoration, none);
    cursor: default;
}
/* Glossary panel — theme colours and border.
 *
 * Deliberately NOT merged into the [data-cx~="glossary"] rule earlier in this
 * file, unlike the other nine selectors that were duplicated after the theme
 * de-duplication. [data-cx^="pane"] sits between the two and also sets
 * background at the same specificity, and an element written
 * data-cx="pane glossary" matches both — so moving these declarations earlier
 * would hand that element's background to the pane rule. Nothing writes that
 * combination today, but the cx convention is never to assume a modifier
 * combination is impossible. */
[data-cx~="glossary"] {
    border-top: 1px solid var(--cx-glossary-border);
    background: var(--cx-surface);
}
[data-cx~="glossary"]:empty::before {
    content: 'Glossary terms will appear here';
    color: var(--cx-text-muted);
    font-size: 0.82rem;
    font-style: italic;
}

.cx-gloss-entry {
    display: block;
    margin: 0.2rem 0;
    font-size: var(--cx-size, calc(var(--cx-scale, 1) * clamp(1.4rem, 5vw, 3.5rem)));
}
.cx-gloss-term { color: var(--cx-color, inherit); }
.cx-gloss-def { color: var(--cx-text-mid); }
.cx-gloss-hint { font-size: 0.75rem; color: var(--cx-text-dim); }

/* ── append: paragraph and reveal (cx-render appendChunk, B.3.140) ───── */
/* paragraph — chunks run on inside .cx-paragraph blocks, and the latest
 * text is scrolled to mid-pane. The space below is what lets the last line
 * come up to the middle: half a viewport is always at least half the pane.
 * Declared after [data-cx~="append"] so it wins the padding-bottom. */
[data-cx~="paragraph"],
[data-cx*="paragraph="] {
    padding-bottom: 50vh;
}
.cx-paragraph {
    margin: 0 0 0.6em;
}
/* reveal — each word fades in after the one before it; the delays are set
 * inline per word. A chunk still revealing when the next arrives is
 * finished at once (cx-reveal-done). */
.cx-reveal-word {
    animation: cx-reveal var(--cx-transition-slow, 0.4s) ease-out both;
}
.cx-reveal-done .cx-reveal-word {
    animation: none;
}
@keyframes cx-reveal {
    from { opacity: 0; }
    to   { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
    .cx-reveal-word { animation: none; }
}
/* A chunk carrying words of its own — a speaker entry, st:@2:red (cx-render
 * chunkStyle, B.3.143). The words set --cx-* on the chunk; these read them,
 * each falling back to what the chunk inherits, so a chunk with no words of
 * its own looks exactly like the element around it. Replaces the eight fixed
 * colours of the `speakers` word (B.3.63): nothing is coloured by speaker
 * unless an entry says so.
 *
 * Colour is worked out as the shared text rule does, bright included, so a
 * chunk in a `bright` element stays bright. Size and alignment are not read:
 * a chunk is a run of text inside its element, not a block of its own. */
.cx-chunk[data-cx-style] {
    font-weight: var(--cx-weight, inherit);
    font-style: var(--cx-fstyle, inherit);
    font-variant: var(--cx-caps, inherit);
    letter-spacing: var(--cx-tracking, inherit);
    text-decoration: var(--cx-decoration, inherit);
    color: var(--cx-color, var(--cx-text-strong));
    background: var(--cx-bgmod, transparent);
}
@supports (color: color-mix(in oklch, red, blue)) {
.cx-chunk[data-cx-style] {
    color: color-mix(in oklch, var(--cx-color, var(--cx-text-strong)), var(--cx-contrast) var(--cx-bright, 0%));
}
}
/* At no specificity, so any word on the chunk beats both. The background the
 * element already paints is not painted again on top (--cx-bgmod inherits, and
 * a darken transcript would darken styled chunks twice). The family gives way
 * to .cx-gloss-line's display serif, so a styled chunk on a terms pane keeps
 * the same face as the rest. */
:where(.cx-chunk[data-cx-style]) {
    --cx-bgmod: initial;
    font-family: var(--cx-family, inherit);
}
/* ── Score image transition ──────────────────────────────────────────── */
[data-cx="score.image"] {
    transition: opacity 0.3s ease;
}

/* ═════════════════════════════════════════════════════════════════════════════
 * PALETTES
 *
 * Light applies when <html> carries data-cx-theme="light"; dark is the default
 * and carries no attribute at all.
 *
 * A page may set the attribute in its own markup to choose its default — the
 * docs site does, since it is written for light. A stored preference overrides
 * that either way, applied before first paint by the inline script in every
 * page head:
 *
 *     <script>const t=localStorage.getItem('cx-theme'),r=document.documentElement;
 *             if(t==='light')r.dataset.cxTheme='light';
 *             else if(t==='dark')delete r.dataset.cxTheme;</script>
 *
 * The else-if is what lets a stored dark beat a markup light, and vice versa.
 *
 * and toggled at runtime by the invert button (cx-render renderInvertButton)
 * or ?style=dark|light (cx-connect applyTheme). Both go through the attribute,
 * so there is one mechanism and one pair of palettes.
 *
 * This replaces cx-theme-dark.css and cx-theme-light.css, and the .cx-inverted
 * class they each carried a third and fourth palette for.
 * ═════════════════════════════════════════════════════════════════════════════ */

:root {

    --cx-bg:           #000000;
    --cx-bg-mid:       #111111;
    --cx-bg-high:      #1a1a1a;
    --cx-surface:      #1e1e1e;
    --cx-surface-mid:  #2a2a2a;
    --cx-surface-high: #333333;

    --cx-text:         #c8b89a;
    --cx-text-strong:  var(--cx-cream);
    --cx-text-mid:     #aaaaaa;
    --cx-text-dim:     #777777;
    --cx-text-muted:   #666666;

    --cx-border:       #484848;
    --cx-border-dim:   #2a2a2a;

    --cx-brand:        #CC4B4B;

    /* Docs code highlighting — lighter shades of the light theme's pair,
     * which are unreadably dark on black. */
    --cx-code-datacx:  #4ade80;
    --cx-code-class:   #c084fc;
    --cx-link:         #CC4B4B;

    --cx-accent:       #c9a227;
    --cx-accent-dim:   #8a7018;

    --cx-text-shadow:  1px 1px #000000;
    --cx-focus-color:  #fde68a;
    --cx-focus-bg:     #2a1a00;

    --cx-score-color:  #222222;
    --cx-red-bg:       #8b0000;
    --cx-red-color:    var(--cx-cream);

    --cx-nudge-neg:    #e88888;
    --cx-nudge-pos:    #8fd6aa;

    /* ── Style-modifier palette — dark values ── */
    --cx-white:  var(--cx-cream);
    --cx-black:  black;
    --cx-grey:   #aaaaaa;
    --cx-gold:   gold;
    --cx-green:  #8fd6aa;
    --cx-orange: #e8a84a;
    --cx-red:    #e88888;
    --cx-blue:   #7d9dc4;
    --cx-purple: #b39ddb;
    --cx-pink:   #e198a4;
    --cx-yellow: #fff176;
    --cx-brown:  #c49a6c;
    --cx-cyan:   #7fd6e0;
    --cx-contrast: #ffffff;
    --cx-bg-lighten: rgba(255,255,255,0.12);
    --cx-bg-darken:  rgba(0,0,0,0.45);

    --cx-play-bg:      #2a3a2a;
    --cx-play-bg-hover:#3a4e3a;
    --cx-play-active-bg: #1e3a1e;
    --cx-play-active-border: #4a8a4a;
    --cx-play-active-color:  #8fd6aa;
    --cx-nudge-ctrl-bg:      #1a2030;
    --cx-nudge-ctrl-border:  #2a3a50;
    --cx-nudge-btn-bg:       #222c3c;
    --cx-nudge-btn-hover:    #2e3e52;

    /* The theme that is actually rendering. renderInvertButton reads this from
     * the computed style of <html>, so the toggle flips whatever is on screen
     * regardless of how it got there — attribute, default, or ?style=. */
    --cx-theme-name: dark;

    /* Tells the engine to render form controls, scrollbars and carets to match,
     * and stops Chrome on Android auto-darkening a page we already handle. */
    color-scheme: dark;
}

:root[data-cx-theme="light"] {

    --cx-bg:           #faf9f6;
    --cx-bg-mid:       #f0ede8;
    --cx-bg-high:      #e8e4de;
    --cx-surface:      #ffffff;
    --cx-surface-mid:  #f5f2ee;
    --cx-surface-high: #ede9e3;

    --cx-text:         #333333;
    --cx-text-strong:  #111111;
    --cx-text-mid:     #666666;
    --cx-text-dim:     #999999;
    --cx-text-muted:   #cccccc;

    --cx-border:       #d0cbc4;
    --cx-border-dim:   #e8e4de;

    --cx-brand:        #CC4B4B;

    /* Docs code highlighting. */
    --cx-code-datacx:  #16a34a;
    --cx-code-class:   #9333ea;
    --cx-link:         #CC4B4B;

    --cx-accent:       #a68a00;
    --cx-accent-dim:   #6b5a00;

    --cx-text-shadow:  none;

    /* ── Style-modifier palette — light values (ink on paper) ── */
    --cx-white:  #222222;
    --cx-black:  white;
    --cx-grey:   #666666;
    --cx-gold:   #9a7b00;
    --cx-green:  #2e7d32;
    --cx-orange: #8a5a00;
    --cx-red:    #c62828;
    --cx-blue:   #355a8d;
    --cx-purple: #6a3ab0;
    --cx-pink:   #b93146;
    --cx-yellow: #7a6f00;   /* close to gold here — dark enough to read on paper */
    --cx-brown:  #6d4c2f;
    --cx-cyan:   #00838f;
    --cx-contrast: #000000;
    --cx-bg-lighten: rgba(0,0,0,0.05);
    --cx-bg-darken:  rgba(0,0,0,0.15);
    --cx-focus-color:  #7c3aed;
    --cx-focus-bg:     #f5f3ff;

    /* ── Component colours — these were hardcoded in the rules below until the
     * component layer moved to cx-base.css. Values unchanged, now named. */
    --cx-nudge-neg:    #c62828;
    --cx-nudge-pos:    #2e7d32;

    --cx-red-bg:       #8b0000;
    --cx-red-color:    var(--cx-cream);

    --cx-play-bg:            #e8f5e9;
    --cx-play-bg-hover:      #c8e6c9;
    --cx-play-active-bg:     #c8e6c9;
    --cx-play-active-border: #388e3c;
    --cx-play-active-color:  #1b5e20;

    --cx-nudge-ctrl-bg:     var(--cx-surface-mid);
    --cx-nudge-ctrl-border: var(--cx-border);
    --cx-nudge-btn-bg:      var(--cx-surface);
    --cx-nudge-btn-hover:   var(--cx-surface-high);

    --cx-ok:  #2e7d32;
    --cx-err: #c62828;

    --cx-negative-bg:        #ffebee;
    --cx-negative-bg-hover:  #ffcdd2;
    --cx-negative-color:     #c62828;
    --cx-negative-border:    1px solid #e57373;

    --cx-overlay-color:      #111;
    --cx-overlay-halo:       #fff;
    --cx-splash-bg:          rgba(250,249,246,0.94);

    --cx-now-bg-default:     var(--cx-focus-bg);
    --cx-gold-hl-bg:         #fffbe6;
    --cx-gold-hl-color:      #7a5800;
    --cx-gold-hl-border:     #c8a000;

    --cx-cursor-color:       #c8a000;
    --cx-header-border:      var(--cx-border);
    --cx-glossary-border:    var(--cx-border-dim);
    --cx-nudge-reset-color:  var(--cx-accent-dim);
    --cx-brand-span-opacity: 0.6;

    --cx-theme-name: light;
    color-scheme: light;
}

/* ── Per-theme rules ──────────────────────────────────────────────────────────
 * These name an absolute appearance rather than a themed one (a dark pane is
 * dark in either theme), or bake a colour into a data URI that cannot read a
 * custom property. Dark first, then the light override at one higher
 * specificity via the attribute. */

/* ── Panel variants ──────────────────────────────────────────────────── */
/* Dark — default performance display */
.cx-pane-dark {
    background: var(--cx-bg);
    color: var(--cx-text-strong);
}
:root[data-cx-theme="light"] .cx-pane-dark {
    background: #222222; /* cx-colour-ok */
    color: var(--cx-cream);
}

/* Dim — subdued, for secondary content */
.cx-pane-dim {
    background: var(--cx-bg-mid);
    color: var(--cx-text-mid);
}
:root[data-cx-theme="light"] .cx-pane-dim {
    background: #444444; /* cx-colour-ok */
    color: #cccccc;      /* cx-colour-ok */
}

/* Light — for score pages, white content */
.cx-pane-light {
    background: var(--cx-text-strong);
    color: var(--cx-score-color);
}
:root[data-cx-theme="light"] .cx-pane-light {
    background: var(--cx-bg);
    color: var(--cx-text-strong);
}

/* ── Snippet cursor — circle+? over click-to-play text (see
 * initSnippetPlayback in cx-render.js). Colour baked into the SVG per
 * theme/invert state, same as --cx-gold below — data URIs can't read
 * CSS custom properties, so this must be redefined wherever --cx-gold is. */
[data-cx~="snippet"].cx-snippet-has-text {
    cursor: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='18'%20height='18'%20viewBox='0%200%2032%2032'%3E%3Ccircle%20cx='16'%20cy='16'%20r='14'%20fill='none'%20stroke='gold'%20stroke-width='2.5'/%3E%3Ctext%20x='16'%20y='23'%20font-family='Arial,sans-serif'%20font-size='20'%20font-weight='bold'%20text-anchor='middle'%20fill='gold'%3E%3F%3C/text%3E%3C/svg%3E") 9 9, pointer;
}
:root[data-cx-theme="light"] [data-cx~="snippet"].cx-snippet-has-text {
    cursor: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='18'%20height='18'%20viewBox='0%200%2032%2032'%3E%3Ccircle%20cx='16'%20cy='16'%20r='14'%20fill='none'%20stroke='%239a7b00'%20stroke-width='2.5'/%3E%3Ctext%20x='16'%20y='23'%20font-family='Arial,sans-serif'%20font-size='20'%20font-weight='bold'%20text-anchor='middle'%20fill='%239a7b00'%3E%3F%3C/text%3E%3C/svg%3E") 9 9, pointer;
}

body {
    text-shadow: var(--cx-text-shadow);
}


/* ────────────────────────────────────────────────────────────────────────
 * check — layout diagnostic
 *
 * Add "check" to an element's data-cx, or ?check to any page URL, and every
 * data-cx element inside it is outlined, colour-coded by role. Off unless
 * asked for.
 *
 * OUTLINE ONLY, and that is the whole design constraint rather than a
 * stylistic preference. outline is drawn outside the border box: it occupies
 * no space, changes no box dimension, creates no containing block and no
 * stacking context. It is the one property that cannot move anything.
 *
 * There is deliberately no on-screen label showing the element's data-cx,
 * though that is the single most useful thing a diagnostic could show — the
 * resolved attribute after the URL merge, which is invisible in the page
 * source. A label needs position: absolute, which needs a positioned host,
 * and putting position: relative on every data-cx element would:
 *
 *   - re-anchor [data-cx~="language-menu"] and [data-cx~="buttons"] (both
 *     position: absolute, bottom: 1rem) from body to the nearest outlined
 *     ancestor, moving them on screen; same for .cx-overlay-text inside a
 *     score.image;
 *   - promote every outlined element into the positioned-descendants paint
 *     layer, so it paints above in-flow siblings it previously sat behind —
 *     which can change appearance on an overlapping layout without any box
 *     moving at all.
 *
 * A :not(:has(…)) escape for the first is a list someone must maintain, and
 * does nothing about the second. So the label lives in the console instead:
 * cx-render logs a table of every data-cx element and its resolved attribute
 * when check is present. Complete, copyable, and free of all of the above.
 *
 * Colours are literal, not themed — a diagnostic that changed appearance
 * with the theme would be a poor diagnostic. Each rule carries its own
 * cx-colour-ok marker, since check-colours looks inside the rule block and
 * not at a section header like this one.
 * ──────────────────────────────────────────────────────────────────────── */

/* Everything with a data-cx, including the element carrying check itself.
 * Faint and dashed: the baseline "this element exists" mark, overridden
 * below for the roles worth telling apart. Negative offset keeps the line
 * inside the box, so an outline on a full-viewport element is not clipped
 * by the viewport edge. */
[data-cx~="check"],
[data-cx~="check"] [data-cx] {
    /* cx-colour-ok — diagnostic. Fixed literals, not themed: an outline whose
     * colour shifted with the theme would be a poor instrument, and these must
     * stay legible against both a black cyclorama and a white handset. */
    outline: 1px dashed #888888;
    outline-offset: -1px;
}

/* Containers — the boxes that carry layout. Heaviest line, because a
 * layout surprise is nearly always a container being a different size or
 * in a different place than assumed. Second selector catches body itself,
 * which carries check and row/col on the same attribute. */
[data-cx~="check"] [data-cx~="pane"],
[data-cx~="check"] [data-cx~="row"],
[data-cx~="check"] [data-cx~="col"],
[data-cx~="check"][data-cx~="row"],
[data-cx~="check"][data-cx~="col"] {
    outline: 3px solid #ff00ff; /* cx-colour-ok — diagnostic, containers */
    outline-offset: -3px;
}

/* Text renders — st / ad / sd / sp and the .text, .ipa, .short forms.
 * Same selector list as the shared text rule above, so the two stay in
 * step: if that list gains a type, so does this. */
[data-cx~="check"] [data-cx~="st"],
[data-cx~="check"] [data-cx~="ad"],
[data-cx~="check"] [data-cx~="sd"],
[data-cx~="check"] [data-cx~="sp"],
[data-cx~="check"] [data-cx*=".text"],
[data-cx~="check"] [data-cx*=".ipa"],
[data-cx~="check"] [data-cx*=".short"] {
    outline: 2px solid #00ffff; /* cx-colour-ok — diagnostic, text renders */
    outline-offset: -2px;
}

/* meta fields — page furniture rather than cue content. Dotted, so a glance
 * separates "this is the show title" from "this is a line of dialogue". */
[data-cx~="check"] [data-cx~="meta"] {
    outline: 2px dotted #ffaa00; /* cx-colour-ok — diagnostic, meta fields */
    outline-offset: -2px;
}

/* Media — score images, video, snippets. These have their own sizing rules
 * and are the usual suspects when a pane is taller than expected. */
[data-cx~="check"] [data-cx*=".image"],
[data-cx~="check"] [data-cx*=".video"],
[data-cx~="check"] [data-cx~="snippet"] {
    outline: 2px solid #00ff88; /* cx-colour-ok — diagnostic, media */
    outline-offset: -2px;
}
