@import url('open-iconic/font/css/open-iconic-bootstrap.min.css');

/* Layer order, lowest first. Anything NOT in a layer outranks both, whatever its specificity or
   order: that is what lets a slot rule or a caller's class override a component's default. */
@layer reset, component;

/* ─── Design Tokens ──────────────────────────────────────────────────────── */
:root {
    --bg-base:       #0f0e12;
        --bg-nav:        #141320;
        --bg-sidebar:    #13121a;
        --bg-panel:      #151422;
        --bg-card:       #1a1928;
        --bg-raised:     #232132;
        --bg-raised-hi:  #2c2a3f;
        --bg-hover:      rgba(255,255,255,0.04);
        --bg-active:     rgba(139,132,232,0.09);
        --bg-input:      rgba(255,255,255,0.06);
        --border:        rgba(255,255,255,0.07);
        --border-med:    rgba(255,255,255,0.11);
        /* Heaviest border step; also the era-palette fallback in ArtistBiogTab. */
        --border-strong: #3a3560;
        /* Readability tune-up: the original --text-dim (#585670, ~35% luminance) was
           legible on titles-adjacent bullets but dropped into hard-to-read territory for
           body-length hint text like the account settings copy. Bumped the two lower
           tiers up one notch each while keeping the 4-level hierarchy intact:
             primary   — titles, active nav                (unchanged)
             secondary — body copy                         (unchanged)
             muted     — nav-btn default, metadata         (+12 luminance)
             dim       — hints, placeholders, faint lines  (+19 luminance) */
        --text-primary:  #e8e5f0;
        --text-secondary:#bbb8cc;
        --text-muted:    #a5a2b8;
        /* The ramp bottoms out here: --text-dim is 4.99:1 on --bg-card, so a dimmer tier
           cannot clear WCAG 1.4.3's 4.5:1. Recede by size, not by adding one. */
        --text-dim:      #8a87a0;
        --accent:        #8B84E8;
        --accent-light:  #b0aaf2;
        /* The one purple that carries WHITE text: 5.83:1, where --accent manages 3.2:1 and fails
           1.4.3. Any filled accent control with light text on it must use this, not --accent. */
        --accent-deep:   #6948e0;
        --accent-bg:     rgba(139,132,232,0.12);
        /* The interactive rung. --accent-bg is the "quiet" alpha and reads as chrome; a control
           has to look pressable, so the ladder's middle rung sits above it. */
        --accent-tint:        rgba(139,132,232,0.20);
        --accent-tint-border: rgba(139,132,232,0.45);
        --accent-border: rgba(139,132,232,0.28);
        --owned:         #52a832;
        --owned-bg:      rgba(82,168,50,0.15);
        --owned-border:  rgba(82,168,50,0.3);
        --danger:        #c04828;
        --danger-bg:     rgba(192,72,40,0.10);
        --danger-border: rgba(192,72,40,0.28);
        --warn:          #b87820;
        /* Dark enough to carry white text on a warning banner; --warn cannot. */
        --warn-bg:       #7a4a00;
        /* The tint/border/text trio every other role has. Their absence is why three "Closed YYYY"
           badges each mixed their own amber. Brighter than --warn because these carry TEXT on a
           tinted surface rather than white on a solid one. */
        --warn-light:    #ffb000;
        --warn-tint:     rgba(255,176,0,0.12);
        --warn-border:   rgba(255,176,0,0.30);
    
        /* Sizing — typography base bumped 13→15px for tablet readability; chrome
           kept tight (48px) so the topnav doesn't dominate the viewport. The 44px
           --min-touch token applies to a tap target's clickable area (achieved via
           padding around a smaller visual button), NOT the chrome height itself. */
        --topnav-height: 48px;
        --mobile-header-height: 48px;
        --mobile-footer-height: 56px;
        --min-touch: 44px;
        /* Hotspot hit-target; was only ever expressed as its own fallback. */
        --hotspot-size: 44px;
    
        /* Concerts attended — a different claim from --accent (collection) and --owned
           (track ownership), and shown beside both on the Biography page. */
        --concert:        #4cafa0;
        --concert-bg:     rgba(76,175,160,0.12);
        --concert-border: rgba(76,175,160,0.28);
        --concert-light:  #6dd5c4;
    
        /* Wanted — on the wishlist. Gold, and deliberately NOT an alias of --accent any more.
           The old reasoning was that a warm hue between amber and rust reads as a warning whatever
           it is called. That holds for an abstract dot, where colour is the only signal; it fails
           for a bookmark, which names itself in its shape. Two things forced the divergence the
           old comment anticipated: --accent also paints .btn-primary, so a violet wishlist control
           sat beside a violet Add to Library as one undifferentiated mass; and gold is the one hue
           here that is not spoken for elsewhere.

           Carries NO text. The wishlist pill is library-green (see .tag-wanted) because the tag
           says "this is library state" and the gold mark says which state — so there is no
           --wanted-light, and nothing needs to clear 1.4.3 against this. */
        --wanted:        #d9a441;
        /* The mark's outline, and a CONTRASTING one — never the fill colour, or there is nothing
           to see. Over a light sleeve or a selected row's tinted ground the fill alone has nothing
           separating it from what sits behind it. */
        --wanted-edge:   var(--bg-base);

        /* Aliases rather than a second green: re-theming --owned must move success with it. */
        --success:        var(--owned);
        --success-bg:     var(--owned-bg);
        --success-border: var(--owned-border);
    
        /* ─── Primitives (Docs/ui-design-system-plan.md) ──────────────────────────
           Colour above was already tokenised; spacing, radius and type were not —
           every padding, gap and font-size in this file is a literal, which is why
           ~25 button classes drifted apart on values nobody chose.
    
           These are deliberately PRIMITIVES, not button tokens: tables and lists
           (tracklist, artist page, concert edit — nearly every main screen) need
           the same scales, and a button-only set would force a parallel one.
           Component tokens below are built FROM these. */
    
        /* 4px base. Existing values cluster on 4/6/8/12/14/16 — 6 and 14 are absorbed
           into 8 and 16 rather than given their own steps; that drift is the point. */
        --space-1: 4px;
        --space-2: 8px;
        --space-3: 12px;
        --space-4: 16px;
        --space-5: 24px;
        --space-6: 32px;
    
        /* 6px is what most classes already use; 3/5/7 are drift, not intent. */
        --radius-sm:   4px;
        --radius-md:   6px;
        --radius-lg:   8px;
        --radius-pill: 999px;
    
        /* Type in REM, not px, so it scales with the user's browser font setting. px type
           ignores that preference entirely — see the html/body rule below for why that
           matters here specifically. Values are calibrated to a 16px root (the browser
           default), so --text-lg is the 15px this app currently renders body copy at.
    
           DEVIATION, deliberate: a 16px base is the usual recommendation, and this app
           runs a step below it. That is a density choice for a catalogue with wide data
           tables, not an accident — recorded in the plan's deviations register. Because
           these are rem, a user who wants larger text still gets it. */
        --text-xs:  0.6875rem;  /* 11px @16 — A-Z rail, smallest labels */
        --text-sm:  0.75rem;    /* 12px    — inline/field buttons, dense metadata */
        --text-md:  0.8125rem;  /* 13px    — default control text */
        --text-lg:  0.875rem;   /* 14px    — nav, emphasis */
        --text-xl:  0.9375rem;  /* 15px    — body copy */
        /* 17px — card and panel titles. Added when .card-title needed it and the scale had nothing
           above body copy: the ladder above is a 1px-per-step CONTROL scale, which is the right shape
           for dense UI text and the wrong shape for headings. Sized to the 17px the artist and venue
           cards already used, rather than inventing a value.
           OPEN for the token pass: headings want their own ratio-based scale rather than more rungs
           bolted onto this one. Not designed here, because a heading scale chosen against two card
           titles would be a guess dressed as a system. */
        --text-2xl: 1.0625rem;
    
        /* DISPLAY scale — for numbers and headings that are the content rather than
           the chrome. --text-xs…2xl runs 11–17px in ~1px steps, which is a CONTROL
           scale: right for dense UI, useless for anything meant to be large. Sizing
           .stat-value from it produced a 17px figure where the app uses 28–30, which
           is what "the stat panels are smaller than currently" was seeing.
           Derived from the five values already in the app (22, 28, 30, 34 and the
           17px card title) rather than invented: a ~1.25 ratio fits them with 30
           resolving to 28. Recorded now that there is enough evidence for a scale —
           earlier there were only two card titles, which would have been a guess. */
        --display-sm:  1.375rem;   /* 22px — venue stats, secondary figures */
        --display-md:  1.75rem;    /* 28px — the default stat figure */
        --display-lg:  2.125rem;   /* 34px — price amounts, hero numbers */
    
        /* ─── Button component tokens ─────────────────────────────────────────────
           One height, varied by INPUT rather than by width: a tablet is wide and
           touch, a small laptop is narrow and mouse, so a width breakpoint cannot
           decide a target size. Default is the touch-safe value; fine pointers opt
           down. See plan §3.3. */
        --btn-height:  var(--min-touch);
        /* The dense step. Follows the same pointer rule as --btn-height rather than being a flat
           32px: a chip-sized control on a phone is still a finger target. */
        --btn-height-sm: var(--min-touch);
        --btn-pad-x:   var(--space-4);
        --btn-gap:     var(--space-2);
        --btn-radius:  var(--radius-md);
        --btn-font:    var(--text-md);
    
        /* 0.08, not the 0.03 this started at. 3% white on #151422 is below the perceptual threshold
           on this palette — which made "dividers on by default" (agreed for bucket A) a decision with
           no visible effect, and made the bucket C data table read as unstyled floating text, because
           in a table the grid IS the structure rather than a hint. Sits between --border (0.07) and
           --border-med (0.11) so the hierarchy container > divider still holds.
           Open for the token pass: the whole neutral scale reads faint against Material's 12%
           dark-theme divider. Raised only as far as this palette tolerates, not to the standard. */
        --divider:     rgba(255,255,255,0.08);
    
        /* Component EDGE, as opposed to a divider. The app's neutral scale tops out at --border-med
           (11% white), which is divider weight — fine between rows, too weak to define the boundary of
           an object sitting on a surface, so cards read as borderless. Material draws the same
           distinction (outline vs outline-variant) and we only had the variant.
           Thickness is not the lever: 1px is standard for both, and 2px reads as a selection state
           rather than an edge. Contrast is what separates them. */
        --outline: rgba(255,255,255,0.22);
    
        --rating: #e3a93c;

        /* A barcode is printed matter: dark bars on a pale quiet zone, whatever the theme. Light
           bars on the page ground are an inverse symbol, which no scanner reads. */
        --barcode-paper: #f4f2f8;
        --barcode-ink:   #141320;
    
        /* --danger is 3.46:1 against --bg-card. That clears SC 1.4.11 Non-text
           Contrast (3:1) so it stays correct for borders, fills and icons — but it
           FAILS SC 1.4.3 (4.5:1) for normal text, which is what error messages,
           destructive button labels and the failed-upload line actually are.
           Additive rather than a change to --danger, so nothing live shifts: the new
           primitives use this for text, --danger for the box around it.
           #d4603f measures 4.57 on --bg-card — the closest hue to the existing red
           that clears the bar, rather than the most comfortable margin, so the two
           still read as the same colour. */
        --danger-text: #d4603f;
    
        /* The opaque equivalent of --bg-hover (4% white) composited over --bg-card. Required, not a
           convenience: a position:sticky cell paints over the row beside it, so a translucent hover
           on the row would show THROUGH the sticky column and the two would not match. Sticky cells
           cannot be translucent, so the row hover has to be a real colour. */
        --bg-row-hover: #232231;
    
        --row-min-h:   var(--min-touch);
        --row-pad-y:   var(--space-3);
        --row-pad-x:   var(--space-4);
        /* One entry of a windowed list, rows and headers alike: a seek is index × this, so the
           stylesheet and the list's ItemSize must be the same number. ListSelector overrides it
           inline on its scroller from ItemSize; this is only the default, for a row that holds
           a 40px avatar (the artist rail: padding twice, avatar, divider). */
        --list-item-size: 65px;

        --shadow-popover: 0 6px 20px rgba(0, 0, 0, 0.35);
        /* Scrim under text laid over a photograph. A token because the alternative is judging the
           opacity again per surface, and text over an image is only legible if that judgement is
           made once — a white sleeve or a bright sky defeats anything lighter. */
        --scrim: linear-gradient(to top, rgba(0, 0, 0, 0.6), transparent);
        /* Layer scale. app.css holds ELEVEN ad-hoc z-index values — 99, 100, 200, 250,
           260, 265, 270, 280, 281, 1000, 1001 — so "what sits above what" is decided by
           whoever last picked a bigger number.
           Found through a real defect: .dialog-backdrop was 100, the same as the sticky
           nav, so on mobile the nav took the top strip of an open drawer — precisely
           where its header and close button sit. The button was never missing; it was
           underneath. Named steps with gaps, so a new layer can be inserted without
           renumbering anything. */
        --z-popover:      50;
        --z-sticky:      100;
        /* A popover that must clear the topnav (100) and the mobile layers at 200 while staying
           under the drawer. A pair sets --z-popover to this on BOTH its elements, and
           .popover-backdrop derives from it. */
        --z-popover-raised: 270;
        --z-drawer:      300;
        --z-dialog:     1100;
        --z-toast:      1200;
}

/* Mouse and trackpad get a tighter control. Touch keeps the full 44px target.
   Deliberately a POINTER query, not a width one — see the comment above. */
@media (pointer: fine) {
    :root {
        --btn-height: 36px;
        --btn-height-sm: 32px;
    }
}

/* ─── Reset / Base ───────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }

html {
    font-size: 100%;
}

html, body {
    height: 100%;
    margin: 0;
    padding: 0;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
    background: var(--bg-base);
    color: var(--text-primary);
    -webkit-font-smoothing: antialiased;
    overflow: hidden;
}

body {
    font-size: var(--text-xl);
}

::-webkit-scrollbar { width: 3px; height: 3px; }

::-webkit-scrollbar-track { background: transparent; }

::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }

/* In the lowest layer, not loose: a component's defaults live in @layer component, and an
   unlayered `button` rule would outrank every one of them that styles a <button>. */
@layer reset {
    a { color: var(--accent-light); }

    button { cursor: pointer; border: none; background: none; font: inherit; color: inherit; }
}

/* ─── Button primitive ───────────────────────────────────────────────────────
   The single button. Everything else should become .btn + a modifier — see
   Docs/ui-design-system-plan.md.

   On the visual/hit split: --min-touch's comment and .nav-btn's both describe a
   small visual inside a padded 44px target, and neither is implemented. The
   pointer query makes it unnecessary for accessibility — touch already gets a
   full 44px BOX, which is the target, and only fine pointers shrink. The split
   is still worth having for genuinely small controls (the A-Z rail, inline
   field buttons) where a 44px visual is unaffordable; it is not worth a
   pseudo-element on every button to reach a target the box already provides. */
.btn,
.chip {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    border: 1px solid var(--outline);
    color: var(--text-primary);
    /* Reset for the anchor case: the global link underline would otherwise show through a control
       that is not meant to read as text. */
    text-decoration: none;
    cursor: pointer;
    transition: color 0.15s, background 0.15s, border-color 0.15s;
}

/* Rank is carried by COLOUR, not by whether a fill exists: solid, tint or danger for a button
   that matters, this neutral surface for one that does not. Owner, 30/08/2026: "the tags and the
   buttons all looking the same" — while .tag, .chip and .btn were all a filled bordered rectangle,
   only corner radius separated a label from a control, so the fill cannot be what marks one out.
   .btn-ghost is the rung that drops surface AND border, for nav and inline actions where even an
   outline would compete. */
.btn {
    justify-content: center;
    min-height: var(--btn-height);
    padding: 0 var(--btn-pad-x);
    border-radius: var(--btn-radius);
    /* The surface .input carries. A transparent control over --bg-base reads as a hole cut in the
       page rather than something pressable. */
    background: var(--bg-input);
    font-size: var(--btn-font);
    font-weight: 500;
    line-height: 1;
}

.btn-cta {
    background: var(--accent-deep);
    border-color: transparent;
    color: #fff;
    font-weight: 600;
}

/* Fills its card: the pricing CTA is the card footer action, not an inline button. */

/* Larger than the control ramp because it is the landing page's single action. */

.btn:hover:not(:disabled) {
    color: var(--text-primary);
    background: var(--bg-active);
}

.btn-cta:hover:not(:disabled) { background: color-mix(in srgb, var(--accent-deep) 85%, white); }

/* Opt-out for genuinely chrome-like controls — nav items, inline text actions — where a resting
   fill would compete with the content around it. */
.btn-ghost {
    background: none;
    border-color: transparent;
    color: var(--text-muted);
}

.btn-ghost:hover:not(:disabled) {
    background: var(--bg-hover);
    border-color: var(--outline);
}

.btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }

/* Explicit ring rather than the UA default: on a dark surface Chrome's default outline is a thin
   dark line with poor contrast against --bg-base, and WCAG 2.2 SC 2.4.13 wants a focus indicator
   that is actually perceivable. :focus-visible, not :focus, so a mouse click does not ring it —
   matching the h1/h2 convention above. */
.btn:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

/* The ladder's middle rung: secondary actions and destinations. Raised off --accent-bg (12%),
   which is the app's alpha for "quiet" — a secondary action rendered quiet is what the owner
   called dull, and what left Share reading as chrome between two coloured buttons. */
.btn-primary {
    color: var(--accent-light);
    background: var(--accent-tint);
    border-color: var(--accent-tint-border);
}

.btn-primary:hover:not(:disabled) {
    background: rgba(139,132,232,0.18);
    border-color: var(--accent);
}

.btn-danger {
    color: var(--danger-text);
    background: var(--danger-bg);
    border-color: var(--danger-border);
}

.btn-danger:hover:not(:disabled) {
    background: rgba(192,72,40,0.18);
    border-color: var(--danger);
}

/* Icon-only: square, so the target stays as tall as it is wide. */
.btn-icon {
    width: var(--btn-height);
    min-height: var(--btn-height);
    padding: 0;
}

/* The genuinely small controls — inline in a field row, or the A-Z rail. Opts
   OUT of the target size deliberately, which is the case the visual/hit split
   exists for if it is ever built. */
.btn-sm {
    min-height: var(--btn-height-sm);
    padding: var(--space-1) var(--space-2);
    font-size: var(--text-sm);
}

@media (pointer: fine) {
    .btn-sm { --btn-height: 28px; }
}

.btn-icon.btn-sm {
    width: 32px;
    min-height: 32px;
    padding: 0;
}

.btn.is-active {
    color: var(--text-primary);
    background: var(--accent-bg);
    border-color: var(--accent);
    box-shadow: inset 0 0 0 1px var(--accent);
}

.btn.is-active:hover:not(:disabled) {
    background: rgba(139,132,232,0.18);
}

/* ─── Collapsing labels ──────────────────────────────────────────────────────
   A toolbar button that drops to its icon where the row will not fit. Opt-in per
   button, because it is only correct where the icon is unambiguous.

   One of the few breakpoints in this system that is earned rather than assumed,
   and the measurement is the argument: on a 412px phone the concert detail bar
   needs ~460px for two tabs plus three labelled actions, so it wraps to a second
   row; icon-only the actions need ~330px and it fits on one. A whole row back on
   the device with the least of them.

   Collapse ALL the actions in a group or none — a row of two icons and one
   labelled button reads as an accident rather than a decision.

   The label is hidden the .sr-only way, NOT display:none: it stays the button's
   accessible name, so nothing needs an aria-label bolted on and the name cannot
   drift from the visible text when the viewport changes. */

.btn > svg,
.chip > svg {
    width: 1.15em;
    height: 1.15em;
    flex-shrink: 0;
}

/* Text-glyph icons. Sized rather than boxed: a glyph centres on its own baseline,
   and giving it a fixed box as well would fight the font's metrics. */
.btn > .ic {
    display: inline-flex;
    align-items: center;
    flex-shrink: 0;
    font-size: 1.15em;
    line-height: 1;
}

/* AN ICON-ONLY BUTTON SIZES ITS ICON ABSOLUTELY, not from the button's text.
   `em` is right when the icon sits BESIDE a label — it should match the words.
   With no label there is nothing to match, and .btn-sm's font-size (12px, for
   text that is not there) dragged the icon down to ~14px: the setlist row's
   move/remove controls read as tiny next to everything else. Owner, 10/08:
   "the icons still look way too small".
   1.125rem = 18px, which is what ShareButton's SVG was hand-set to before the
   slot existed — so the size that already looked right becomes the shared one.
   These follow .btn > svg deliberately: same specificity, so source order wins. */
.btn-icon > svg {
    width: 1.125rem;
    height: 1.125rem;
}

.btn-icon > .ic {
    font-size: 1.125rem;
}

@media (max-width: 600px) {
    .btn-collapse {
                width: var(--btn-height);
                padding: 0;
                justify-content: center;
            }
    .btn-collapse .btn-label {
                position: absolute;
                width: 1px;
                height: 1px;
                margin: -1px;
                padding: 0;
                overflow: hidden;
                clip-path: inset(50%);
                white-space: nowrap;
            }
}

/* A row of buttons. Replaces the per-child margin-left that made gaps
   inconsistent (4px here, 10px there) and could not be overridden per row. */
.btn-row {
    display: inline-flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--btn-gap);
}

/* .result-row is an alias, not a second row: it is this rule plus the button.list-row reset
   below. Keep the selectors together so they cannot drift apart. */
.list-row,
.result-row {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    min-height: var(--row-min-h);
    padding: var(--row-pad-y) var(--row-pad-x);
    border-bottom: 1px solid var(--divider);
    cursor: pointer;
    transition: background 0.12s;
}

.list-row:last-child,
.result-row:last-child { border-bottom: none; }

.list-row:hover,
.result-row:hover { background: var(--bg-hover); }

.list-row.is-active {
    background: var(--bg-active);
    box-shadow: inset 2px 0 0 var(--accent);
}

/* Opt out of the divider for lists that separate by spacing or colour instead. */
.list-row-flush { border-bottom: none; }

/* The navigating variant — BiographyVenues' sidebar rows are <a href> and carry no selection
   state, because picking one leaves the page. Same row, different element; this just neutralises
   anchor styling so the two are visually identical. */
a.list-row {
    text-decoration: none;
    color: inherit;
}

/* The acting variant. A row that selects rather than navigates is a <button>, for the same reason
   a pick-list row is (see .result-row): making the row the control means the browser supplies the
   click target, the keyboard operability and the focus ring, instead of a div with @onclick that
   supplies none of them. Needed wherever a list is NOT a ListSelector — inside one, the roving
   tabindex owns the keyboard and the rows stay divs carrying role="option". */
button.list-row,
.result-row {
    width: 100%;
    border-left: 0;
    border-right: 0;
    border-top: 0;
    background: none;
    color: inherit;
    font: inherit;
    text-align: left;
}

/* Trailing count on a row — "10" albums, "12 songs". Observed on artist and concert rows as
   bespoke spans; named so it stops being re-invented per list. */
.list-count {
    margin-left: auto;
    flex-shrink: 0;
}

.popover-action {
    display: block;
    width: 100%;
    padding: var(--space-2) var(--space-3);
    border: 0;
    background: none;
    color: var(--accent-light);
    font: inherit;
    font-size: var(--text-md);
    text-align: left;
    cursor: pointer;
}

.popover-action:hover { background: var(--bg-hover); }

.popover-action:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: -2px;
}

/* Non-selectable rows — "no matches", section labels. Not a target. */
.popover-empty {
    padding: var(--space-2) var(--space-3);
    cursor: default;
}

/* ─── Bucket B: result row ───────────────────────────────────────────────────
   The pick list — search results, scan matches, the artist picker modal. Chosen
   from once, then gone; nothing stays selected, which is what separates it from
   the selector list above.

   THE RULE THAT SHAPES IT: one control per row. The row IS the button (or the
   anchor), not a div containing one. That is not a style preference — it decides
   the keyboard contract. A row carrying its own trailing "Select" button cannot
   be role="option" (an option must not contain focusable children), so such a
   list can never be a listbox, and arrow-key navigation stops being available to
   it. Make the row the control and the browser's native tab order is already
   correct with no roving tabindex, no JS and no ARIA (§8b1, third case).

   Corollary: if a row genuinely needs two actions, it is not a pick list. It is
   a data table row — bucket C — and should be built as one. */
.row-group {
    display: flex;
    align-items: center;
    border-bottom: 1px solid var(--divider);
}

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

.row-group:hover { background: var(--bg-hover); }

/* The row keeps the padding and the text layout; the group took the edge, so the
   row must not draw a second one, and its own hover would win over the group's. */
.row-group > .result-row,
.row-group > .list-row {
    flex: 1;
    min-width: 0;
    border-bottom: none;
}

.row-group > .result-row:hover,
.row-group > .list-row:hover { background: none; }

.row-group > .row-group-action {
    flex-shrink: 0;
    display: grid;
    place-items: center;
    min-width: var(--min-touch);
    min-height: var(--min-touch);
    padding: 0 var(--space-2);
    color: var(--text-dim);
}

.row-group > .row-group-action:hover { color: var(--text-primary); }

/* min-width: 0 is load-bearing. A flex item's default min-width is auto, so the
   text stack refuses to shrink below its content and pushes the trailing meta
   out of the row instead of ellipsising — the "long album titles overflow"
   failure, which is this one line every time. */
.result-text {
    display: flex;
    flex-direction: column;
    gap: 2px;
    min-width: 0;
}

.result-title,
.result-sub {
}

.result-sub {
    color: var(--text-dim);
    font-size: var(--text-sm);
}

/* border-collapse must be SEPARATE, not collapse. With collapse the borders
   belong to the table rather than the cells, so a sticky <th> scrolls away from
   its own bottom border and the header ends up floating over the rows with no
   rule under it. border-spacing: 0 restores the collapsed look. */
.data-table {
    width: 100%;
    border-collapse: separate;
    border-spacing: 0;
}

.data-table th {
    position: sticky;
    top: 0;
    z-index: 2;
    padding: var(--space-2) var(--row-pad-x);
    background: var(--bg-panel);
    /* Stronger than a cell divider — the header is a different KIND of boundary, and at --divider
       it disappeared into the rows it is supposed to be labelling. */
    border-bottom: 1px solid var(--border-med);
    color: var(--text-dim);
    font-size: var(--text-sm);
    font-weight: 600;
    letter-spacing: 0.5px;
    text-transform: uppercase;
    text-align: left;
    white-space: nowrap;
}

.data-table td {
    height: var(--row-min-h);
    padding: var(--space-2) var(--row-pad-x);
    border-bottom: 1px solid var(--divider);
    vertical-align: middle;
}

.data-table tbody tr:last-child td { border-bottom: none; }

/* Background lives on the ROW, not the cell, so a pinned column can inherit it (below) and stay in
   step through hover. Setting it per-cell would leave the sticky column a fixed colour while the
   rest of its own row highlighted. */
.data-table tbody tr { background: var(--bg-card); }

.data-table tbody tr:hover { background: var(--bg-row-hover); }

/* width: 1% is the shrink-to-fit trick — a table resolves it to the cell's MIN-CONTENT width.
   Semantics only: "this column holds controls". The frozen-column half of this vocabulary
   (.col-num, .col-pin-end) is scoped to ConcertEditComponent, so it does NOT reach a table
   rendered by any other component — put those rules there too rather than reaching for the class. */
.data-table .col-actions {
    width: 1%;
    white-space: nowrap;
}

.data-table .col-actions .btn-row {
    flex-wrap: nowrap;
}

/* ─── Visually hidden ────────────────────────────────────────────────────────
   Text for assistive tech that takes no space on screen. The system had no such
   utility, which is why information ended up in `title` attributes instead —
   and a title is mouse-hover only: it never appears on touch, rarely on keyboard
   focus, and its screen-reader support is inconsistent.

   NOT display:none or visibility:hidden — both remove the element from the
   accessibility tree along with the layout, which defeats the purpose. The
   clip-path/1px technique keeps it rendered and readable while occupying
   nothing. */
.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
}

.field {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    min-width: 0;
}

.input {
    width: 100%;
    min-height: var(--min-touch);
    padding: 0 var(--space-3);
    border: 1px solid var(--border-med);
    border-radius: var(--radius-md);
    background: var(--bg-input);
    color: var(--text-primary);
    font: inherit;
    /* 1rem floor — see the iOS note above. Not var(--text-xl): that is 0.9375rem
       (15px), which is under the zoom threshold and would reintroduce the bug. */
    font-size: max(1rem, var(--text-xl));
    transition: border-color 0.15s, box-shadow 0.15s;
}

textarea.input {
    min-height: calc(var(--min-touch) * 2);
    padding: var(--space-2) var(--space-3);
    line-height: 1.4;
    resize: vertical;
}

/* color-scheme on the primitive, not on each caller. Native widgets a page cannot
   style — a <select>'s dropdown panel, a date input's picker indicator — are
   painted by the OS, and this is the one hint that tells it which way round. The
   app scattered it: hoisted onto .hero-collection for the collection panel's date
   fields, repeated on .search-filter-select for its dropdown. Both were treating a
   property of "this is a dark input" as a property of the container it sat in. */
.input { color-scheme: dark; }

/* A select draws its own arrow, in the OS's style, at the OS's size — so the one
   control the app cannot make look like the others is the one that appears in
   every filter row. appearance: none takes the arrow off and the chevron below
   puts a consistent one back. Lifted from .search-filter-select, which had worked
   this out for the search page alone; the other selects in the app kept the
   native arrow, which is precisely the inconsistency worth removing.
   Extra right padding reserves the chevron's space so a long option label cannot
   run underneath it. */
select.input {
    cursor: pointer;
    appearance: none;
    -webkit-appearance: none;
    /* 28px, not a space token: it is the chevron's 10px plus its 10px offset plus
       a gap, so it is derived from the graphic above, not from the spacing scale. */
    padding-right: 28px;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%238a86b0' stroke-width='1.6'%3E%3Cpath d='M1 1l5 5 5-5'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 10px center;
    background-size: 10px 7px;
}

.input[type="date"] {
    font-variant-numeric: tabular-nums;
}

.input[type="date"]::-webkit-calendar-picker-indicator {
    width: 24px;
    height: 24px;
    padding: 2px;
    border-radius: var(--radius-sm);
    cursor: pointer;
    /* The glyph is a fixed dark bitmap with no colour control, so inverting is
       the only way to make it legible on a dark surface. */
    filter: invert(1) opacity(0.55);
    transition: opacity 0.12s, background 0.12s;
}

/* Opacity only. A background here is inverted along with the glyph — `filter`
   applies to the element as rendered, background included — so the light hover
   fill became near-black and covered the icon it was meant to highlight. */
.input[type="date"]::-webkit-calendar-picker-indicator:hover {
    filter: invert(1) opacity(1);
}

/* Placeholder segments sit at --text-dim until filled, so an empty date reads as
   empty rather than as a value someone chose. */
.input[type="date"]::-webkit-datetime-edit-text,
.input[type="date"]::-webkit-datetime-edit-month-field,
.input[type="date"]::-webkit-datetime-edit-day-field,
.input[type="date"]::-webkit-datetime-edit-year-field {
    color: var(--text-primary);
}

.input[type="date"]:invalid::-webkit-datetime-edit {
    color: var(--text-dim);
}

.input::placeholder { color: var(--text-dim); }

/* Ring AND border, unlike the live class which moves the border colour alone. */
.input:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 1px;
    border-color: var(--accent);
}

.input:disabled {
    opacity: 0.5;
    cursor: not-allowed;
}

/* Styled off aria-invalid rather than a class: that attribute is what assistive tech reads, so
   the visual and the announced state cannot drift apart. Do not reach for `outline` here — it
   collides with the focus ring, and a field that is both invalid and focused loses one of the two. */
.input[aria-invalid="true"] {
    border-color: var(--danger);
}

.input-group {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    min-height: var(--min-touch);
    padding: 0 var(--space-3);
    border: 1px solid var(--border-med);
    border-radius: var(--radius-md);
    background: var(--bg-input);
    transition: border-color 0.15s, box-shadow 0.15s;
}

.input-group:focus-within {
    outline: 2px solid var(--accent);
    outline-offset: 1px;
    border-color: var(--accent);
}

.input-group > .ic {
    display: inline-flex;
    flex-shrink: 0;
    color: var(--text-dim);
}

/* The input gives up the box and keeps the type ramp — notably the 1rem floor,
   which is the whole reason not to let each caller restyle this itself. */
.input-group > .input {
    flex: 1;
    min-width: 0;
    min-height: 0;
    padding: 0;
    border: 0;
    border-radius: 0;
    background: none;
    outline: none;
}

.thumb {
    --thumb-size: 48px;
    display: grid;
    place-items: center;
    flex-shrink: 0;
    width: var(--thumb-size);
    height: var(--thumb-size);
    overflow: hidden;
    border: 1px solid var(--outline);
    border-radius: var(--radius-sm);
    background: var(--bg-card);
    object-fit: cover;
    color: var(--text-dim);
    font-weight: 600;
}

/* SELECTION. The grid's whole job is picking something, so the tile needs a
   selected state and the row primitives' inset accent bar does not work here —
   a square tile has no leading edge to mark, and an inset ring would be hidden
   by the artwork it sits on top of.
   An OUTSIDE ring instead, drawn with box-shadow rather than border-width so the
   tile does not change size and the grid does not reflow when a selection moves.
   outline-offset would fight the grid gap; a spread shadow sits in the gap. */
.thumb.is-selected {
    border-color: var(--accent);
    box-shadow: 0 0 0 2px var(--accent);
}

/* Selectable tiles are BUTTONS — the tile is the control, same rule as the pick
   list. That supplies the target, the tab stop and the ring for free, and pairs
   with aria-pressed so selection is announced rather than merely coloured. */
button.thumb {
    padding: 0;
    font: inherit;
    cursor: pointer;
}

button.thumb:hover { border-color: var(--outline); }

button.thumb:focus-visible {
    outline: 2px solid var(--accent-light);
    outline-offset: 2px;
}

/* Ring AND check. Colour alone never carries state (WCAG 1.4.1), and on a grid
   of album art a coloured edge is easy to miss against a busy cover. */
.thumb-check {
    position: absolute;
    top: var(--space-1);
    right: var(--space-1);
    display: grid;
    place-items: center;
    width: 18px;
    height: 18px;
    border-radius: 50%;
    background: var(--owned);
    color: var(--bg-base);
    font-size: var(--text-xs);
    line-height: 1;
}

/* The check is absolutely positioned, so a tile carrying one needs to be its
   containing block. */
.thumb:has(.thumb-check) { position: relative; }

.thumb-2xs { --thumb-size: 22px; font-size: var(--text-xs); }

.thumb-xs { --thumb-size: 24px; font-size: var(--text-xs); }

.thumb-sm { --thumb-size: 40px; }

/* ARTWORK IS SQUARE, PEOPLE ARE ROUND. Release art is a photograph of a square
   object and gets --radius-sm; an artist avatar is a portrait and is round
   everywhere in this app. That is a real distinction, not decoration, so it is a
   modifier rather than a second thumbnail class — every other property (sizing,
   the placeholder centring, object-fit, the border) is identical. */
.thumb-round { border-radius: 50%; }

.thumb > img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

/* The hero cover. Keeps a larger radius because at this size --radius-sm reads
   as a square with damaged corners rather than a rounded tile. */
.thumb-xl {
    --thumb-size: 140px;
    border-radius: var(--radius-md);
    border-color: var(--border-med);
    box-shadow: 0 8px 24px rgba(0,0,0,0.5);
}

/* Fills its grid cell instead of taking a fixed size — the cover-wall case,
   where the column defines the width and the tile only has to stay square. */
.thumb-fluid {
    width: 100%;
    height: auto;
    aspect-ratio: 1;
}

/* ─── Entity card ──────────────────────────────────────────────────────────── */
/* One surface. .panel, .stat and .insight are the same bordered box with different padding,
   direction and tone — keep the shared triple on one rule so a change to the surface reaches
   all four. Only the deltas belong below. */
.card,
.panel,
.stat,
.insight {
    display: flex;
    flex-direction: column;
    border: 1px solid var(--outline);
    border-radius: var(--radius-lg);
    background: var(--bg-card);
}

.card { overflow: hidden; }

a.card,
button.card {
    color: inherit;
    text-decoration: none;
    transition: border-color 0.15s, background 0.15s;
}

button.card {
    padding: 0;
    font: inherit;
    text-align: left;
    cursor: pointer;
}

/* Selected card — the cover wall's picked release. Ring OUTSIDE the border like
   .thumb.is-selected, drawn with box-shadow so selection never resizes a card
   and the grid cannot reflow as the selection moves. */
.card.is-selected {
    border-color: var(--accent);
    box-shadow: 0 0 0 2px var(--accent);
}

/* Accent, not --border-med — that is now WEAKER than the resting --outline, so hover would have
   dimmed the edge instead of lifting it. */
a.card:hover {
    border-color: var(--accent);
    background: var(--bg-row-hover);
}

.card:focus-visible,
.card a:focus-visible,
.card button:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

/* A full-bleed child's box IS the card's inner edge, so .card's overflow: hidden clips an outset
   ring to nothing and the control reads as having no tab stop at all. Inset instead. */
.card > a:focus-visible,
.card > button:focus-visible {
    outline-offset: -3px;
}

/* Absolute, not in-flow: an aspect-ratio box has a content-based MINIMUM, so an
   in-flow photo taller than 16/9 grows the box past the ratio — which is how the
   venue card kept rendering 4:3 next to a 16/9 artist card after the ratio was
   already unified. Taking the image out of flow makes the ratio binding. */
.card-media img {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.card-title-nowrap { flex-wrap: nowrap; }

/* The trailing mark on a card title that navigates. */
.card-chevron {
    font-size: var(--text-2xl);
    opacity: 0.6;
}

/* The leading mark on a row that expands in place: the shared chevron, turned to point down
   while the row is open. Rotation is all CSS has to say — the glyph is the Icon — and it keys
   off the aria state so the mark can never disagree with what a screen reader is told. */
.twisty {
    flex: none;
    color: var(--text-dim);
    transition: transform 140ms ease;
}

[aria-expanded="true"] > .twisty,
details[open] > summary > .twisty { transform: rotate(90deg); }

/* A <summary> wearing the row: the flex row already drops the marker box, except in WebKit. */
summary.list-row::-webkit-details-marker { display: none; }

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

a.card-title { text-decoration: none; }

/* A card title with nothing behind it to open. It must not read as a link. */
.card-title-quiet { color: var(--text-dim); }

a.card:hover .card-title,
a.card-title:hover {
    color: var(--accent);
}

/* A sentence about the thing on the card, with its figure bold. */
.card-note {
    font-size: var(--text-md);
    color: var(--text-secondary);
}

.card-note strong { font-weight: 600; }

.card-title {
    display: flex;
    align-items: flex-start;
    align-content: flex-start;
    gap: var(--space-2);
    flex-wrap: wrap;
    min-height: calc(2 * 1.25 * var(--text-2xl));
    color: var(--text-primary);
    font-size: var(--text-2xl);
    font-weight: 600;
    line-height: 1.25;
}

.card-compact {
    /* Cover art is square. The media slot defaults to 16/9, which crops the top and bottom
       off every sleeve on the wall. A tile IS the artwork, so it takes the artwork's shape. */
    --card-media-ratio: 1;
    border: 0;
    background: none;
    border-radius: var(--radius-sm);
}

.card-compact .card-title {
    display: block;
    /* One line HIGH as well as long, like the meta line below: every wall row the same height. */
    height: 1lh;
    /* The base reserves TWO lines so titles line up across a row of full cards. A tile
       caption is one ellipsised line, so that reservation is just a gap above the meta. */
    min-height: 0;
    font-size: var(--text-sm);
    font-weight: 500;
    /* Ellipsis needs a block box; the base is inline-flex for its chip row. */
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* ONE LINE. These chips wrap by default, which is right on a card the user is reading; on a
   3-across tile "1977 · UK & Europe · CD" plus a Live tag then takes two lines and the rows
   stop lining up with each other. */
.card-compact .card-meta {
    font-size: var(--text-xs);
    flex-wrap: nowrap;
    white-space: nowrap;
    overflow: hidden;
    /* One line HIGH as well as one line long, whatever it holds. The wall places row N at
       N × one row height; a video pill made its row 172px among 165s, and the scroll drifted
       off the highlighted tile by that much per such row (owner's reading, 21/09/2026). */
    align-items: center;
    height: 1lh;
}

.card-compact .card-meta > span:not(.tag) {
    overflow: hidden;
    text-overflow: ellipsis;
}

/* A pill in a one-line caption rides at the caption's size, not the full-card size. */
.card-compact .card-meta > .tag {
    font-size: inherit;
    line-height: 1;
    padding-block: 0;
}

.field-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(var(--field-min, 200px), 1fr));
    column-gap: var(--space-4);
    row-gap: var(--space-3);
}

/* One label/value pair. Stays a column at every width — a label beside its value
   halves the room for the value, which is the wrong trade on a phone.

   position: relative is load-bearing, not tidiness: it makes the PAIR the
   anchor for a Hotspot. .hotspot-dot is absolutely positioned at the top-right
   of its nearest positioned ancestor, so whatever that ancestor happens to be
   decides where the hint appears. In the live panel the ancestor is the whole
   field row, and the app's own comment records the consequence — the dot was
   moved off the header because it landed on the "Edit all" button, onto the
   row because "the rating row is wider and more sparse, so top-right of the row
   is empty space". A hint that floats in empty space beside the control it
   describes is anchored to the layout rather than to its subject.
   Making each pair the positioning context means "top-right" is the corner of
   THAT field, which is narrow, predictable, and moves with it. */
.field-pair {
    position: relative;
    display: flex;
    flex-direction: column;
    gap: 2px;
    min-width: 0;
}

/* A pair that takes the whole grid width — a comments box or a long note, where
   half a row would make the value unreadably narrow. Also what .field-adds needs,
   since the unset-field links belong below the grid, not in a cell of it. */
.field-pair-full,
.field-grid > .field-adds { grid-column: 1 / -1; }

/* The adds row is a footer: it rules off the recorded fields above it. Every one of those fields
   is conditional, so on a release with nothing recorded yet it is the only child and the rule
   divides it from nothing — leaving a second line and a dead band under the panel heading. */
.field-grid > .field-adds:only-child {
    margin-top: 0;
    padding-top: 0;
    border-top: 0;
}

.field-pair > dt,
.field-pair > dd {
    margin: 0;
}

.field-pair-value {
    color: var(--text-primary);
    min-width: 0;
}

/* A value that opens its own editor. A button, not a div with @onclick, so it
   gets the target, the tab stop and the ring — and so "this is editable" is
   discoverable by keyboard rather than by hovering hopefully. */
button.field-pair-value {
    /* Hug the value, do not fill the cell. .field-pair is a flex COLUMN, so the
       default align-self: stretch made this button as wide as the grid cell — and
       since the affordance is a dashed underline, that drew a long dashed line
       running from a 5-star strip out to the panel edge. It read as one very wide
       control, and anything anchored to the cell's top-right corner (the rating
       hotspot) then appeared at the far end of it rather than beside the stars. */
    align-self: flex-start;
    max-width: 100%;
    padding: 0;
    border: 0;
    background: none;
    font: inherit;
    text-align: left;
    cursor: pointer;
    border-bottom: 1px dashed var(--border-med);
}

button.field-pair-value:hover { border-bottom-color: var(--accent); }

button.field-pair-value:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

/* Placeholder for a field with no value yet. --text-dim and an em dash, so an
   unset field reads as unset rather than as a value someone chose. */
.field-pair-empty { color: var(--text-dim); }

.field-adds {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-1) var(--space-3);
    margin-top: var(--space-2);
    padding-top: var(--space-2);
    border-top: 1px solid var(--divider);
}

.field-add-link {
    padding: 6px 0;
    margin: -6px 0;
    border: 0;
    background: none;
    color: var(--accent-light);
    font-size: var(--text-sm);
    text-decoration: underline;
    text-underline-offset: 2px;
    cursor: pointer;
}

.field-add-link:hover { color: var(--accent); }

.field-add-link:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
    border-radius: var(--radius-sm);
}

/* DISPLAY AND INPUT ARE THE SAME CONTROL MINUS THE HIT ZONES.
   .rating-stars + .rating-fill on their own are a read-only rating: the dim five
   underneath, the gold overlay clipped to the value. Add the ten .rating-hit
   radios and it becomes the editor. So there is no separate "star strip" class to
   drift from this one — a 3.5 reads identically wherever it appears.
   --rating-size is the only thing the two cases genuinely differ on: the editor is
   a 24px target, an inline strip in a list row is text-sized. */
.rating-stars {
    flex-shrink: 0;
    position: relative;
    display: inline-block;
    color: var(--border-med);
    font-size: var(--rating-size, 24px);
    line-height: 1;
    /* The hit zones are absolutely positioned against this. */
    isolation: isolate;
}

.rating-stars::before {
    content: "\2605\2605\2605\2605\2605";
    letter-spacing: 1px;
}

.rating-fill {
    position: absolute;
    top: 0;
    left: 0;
    height: 100%;
    overflow: hidden;
    white-space: nowrap;
    /* Gold, not accent — see --rating. The FOCUS ring stays accent, so the theme
       shows in the interaction while the value keeps its convention. */
    color: var(--rating);
    pointer-events: none;
    transition: width 0.12s;
}

.rating-fill::before {
    content: "\2605\2605\2605\2605\2605";
    letter-spacing: 1px;
}

.rating-hit input {
    position: absolute;
    inset: 0;
    margin: 0;
    opacity: 0;
    cursor: pointer;
}

/* The ring goes on the whole control, not one 10% sliver — a ring around a
   fraction of a star reads as a rendering fault rather than as focus. */
.rating-stars:has(input:focus-visible) {
    outline: 2px solid var(--accent-light);
    outline-offset: 3px;
    border-radius: var(--radius-sm);
}

.segmented {
    display: inline-flex;
    padding: 2px;
    border: 1px solid var(--border-med);
    border-radius: var(--radius-pill);
    background: var(--bg-card);
}

.segmented-item {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 32px;
    padding: 0 var(--space-4);
    /* The UA reset for the <button>-based segments below; harmless on a <label>. */
    border: 0;
    background: none;
    font-family: inherit;
    border-radius: var(--radius-pill);
    color: var(--text-muted);
    font-size: var(--text-lg);
    white-space: nowrap;
    cursor: pointer;
    transition: background 0.12s, color 0.12s;
}

.segmented-item input {
    position: absolute;
    inset: 0;
    margin: 0;
    opacity: 0;
    cursor: pointer;
}

.segmented-item:hover { color: var(--text-primary); }

/* THE SAME CONTROL, THREE CORRECT SEMANTICS.
   A segmented control is a shape, not a role, and which role is right depends on
   what the segments do:
     - radios      choosing a value that a form will submit or a filter will read
     - role=tab    switching which panel is shown (arrow keys move, panel follows)
     - aria-pressed a single independent toggle
   Styling only :has(input:checked) meant the tab-based ones could not use this
   and grew their own look-alike instead. Selected is selected — the appearance is
   shared and each caller keeps the semantics its job actually calls for.
   Note this is deliberately NOT :has(input:checked), [aria-selected] — the
   attribute is also present as "false", which would style every unselected
   segment as selected. */
.segmented-item:has(input:checked),
.segmented-item[aria-selected="true"],
.segmented-item[aria-checked="true"],
.segmented-item[aria-pressed="true"] {
    background: var(--accent-bg);
    color: var(--text-primary);
    box-shadow: inset 0 0 0 1px var(--accent);
}

/* The ring belongs on the visible segment, not the invisible input. */
.segmented-item:has(input:focus-visible),
.segmented-item:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

.menu {
    position: absolute;
    z-index: var(--z-popover);
    min-width: 180px;
    padding: var(--space-1);
    border: 1px solid var(--outline);
    border-radius: var(--radius-md);
    background: var(--bg-card);
    box-shadow: var(--shadow-popover);
}

.menu-below-end { top: calc(100% + var(--space-2)); right: 0; }

/* Fills the anchor's width — for pickers whose menu is a continuation of the
   input above it rather than a list of commands beside it. */

.menu-item {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    width: 100%;
    min-height: var(--min-touch);
    padding: 0 var(--space-3);
    border: 0;
    border-radius: var(--radius-sm);
    background: none;
    color: var(--text-secondary);
    font: inherit;
    text-align: left;
    text-decoration: none;
    cursor: pointer;
}

.menu-item:hover {
    background: var(--bg-hover);
    color: var(--text-primary);
}

.menu-item:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: -2px;
}

.menu-item-danger { color: var(--danger-text); }

.menu-item:has(.menu-item-body) {
    align-items: flex-start;
    padding-top: var(--space-2);
    padding-bottom: var(--space-2);
}

.menu-divider {
    height: 1px;
    margin: var(--space-1) 0;
    background: var(--divider);
}

/* ─── Busy state ─────────────────────────────────────────────────────────────
   A control awaiting an async result. Three places disable and relabel a button
   by hand today (share, setlist load, notes save), each differently.

   Pair with aria-busy="true" — the visual says "wait", the attribute is what a
   screen reader announces, and neither is optional. Keep the button's WIDTH
   stable while the label changes, or a row of controls jumps as one resolves. */
.btn.is-busy,
.chip.is-busy {
    cursor: progress;
    opacity: 0.7;
    pointer-events: none;
}

.btn.is-busy::before {
    content: '';
    width: 0.8em;
    height: 0.8em;
    border: 2px solid currentColor;
    border-top-color: transparent;
    border-radius: 50%;
    animation: btn-spin 0.7s linear infinite;
}

@keyframes btn-spin {
    to { transform: rotate(360deg); }
}

@media (prefers-reduced-motion: reduce) {
    .btn.is-busy::before { animation: none; }
}

.stat {
    gap: 2px;
    padding: var(--space-3) var(--space-4);
}

/* The figure carries the accent, not the card's edge or fill — colour lands on the thing the card
   exists to show, and a row of them stays quiet. 5.46:1 on --bg-card. */
.stat-value {
    color: var(--accent);
    font-size: var(--display-md);
    font-weight: 600;
    /* Tabular figures so a row of stats does not jitter as values change, and so
       digits line up between adjacent cards. */
    font-variant-numeric: tabular-nums;
    line-height: 1.1;
}

/* The small label that NAMES the block below it — the caption over a figure, the group heading
   inside a menu, the title over a chart. Capitals and letter spacing are what make it read as a
   label rather than as prose, which matters most on a stat card, where the supporting line under
   the figure is the same grey and would otherwise be indistinguishable from it. */
.section-label {
    color: var(--text-muted);
    font-size: var(--text-xs);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
}

.section-label-accent { color: var(--accent); }

.stat-sub {
    color: var(--text-muted);
    font-size: var(--text-sm);
}

a.stat,
button.stat {
    cursor: pointer;
    color: inherit;
    font: inherit;
    text-align: left;
    text-decoration: none;
    transition: border-color 0.15s, background 0.15s;
}

a.stat:hover,
button.stat:hover {
    border-color: var(--accent);
    background: var(--bg-row-hover);
}

a.stat:focus-visible,
button.stat:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

.stat-grid {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: var(--space-3);
}

@media (min-width: 850px) {
    .stat-grid { grid-template-columns: repeat(var(--stat-cols, 4), 1fr); }
}

/* ─── Empty state ────────────────────────────────────────────────────────────
   21 usages of .empty-state already share a name, so this ratifies rather than
   consolidates — it just gains a spec and a place in the system. */
.empty {
    /* flex: 1 so it centres in the pane it fills rather than sitting at the top
       of it — the live .empty-state does this and it is why the "pick an artist"
       message lands in the middle of an empty pane. */
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--space-3);
    padding: var(--space-6) var(--space-4);
    color: var(--text-dim);
    font-size: var(--text-xl);
    line-height: 1.5;
    text-align: center;
}

.empty-icon {
    font-size: 2.5rem;
    line-height: 1;
    opacity: 0.4;
}

.empty-title {
    color: var(--text-secondary);
    font-size: var(--text-lg);
    font-weight: 600;
}

/* Optional next step out of the empty state. An empty pane that only explains
   itself leaves the user to find the way forward; one with a CTA does not. */
.empty-cta { margin-top: var(--space-1); }

/* ─── Dialog motion ──────────────────────────────────────────────────────────
   Enter animations run on mount with nothing to coordinate. Exit runs when
   .is-closing is added, and the component waits for animationend before
   unmounting — so the DOM follows the animation instead of a timer guessing its
   length.
   Each variant animates from the edge it belongs to, which is the whole reason
   a drawer reads as a drawer rather than a box that appeared. */
@keyframes dialog-fade-in { from { opacity: 0; } }

@keyframes dialog-fade-out {
    to { opacity: 0; }
}

@keyframes dialog-rise-in {
    from { opacity: 0; transform: translateY(8px) scale(0.98); }
}

@keyframes dialog-rise-out {
    to { opacity: 0; transform: translateY(8px) scale(0.98); }
}

@keyframes dialog-slide-end-in {
    from { transform: translateX(100%); }
}

@keyframes dialog-slide-end-out {
    to { transform: translateX(100%); }
}

@keyframes dialog-slide-start-in {
    from { transform: translateX(-100%); }
}

@keyframes dialog-slide-start-out {
    to { transform: translateX(-100%); }
}

@keyframes dialog-sheet-in {
    from { transform: translateY(100%); }
}

@keyframes dialog-sheet-out {
    to { transform: translateY(100%); }
}

.dialog-backdrop {
    animation: dialog-fade-in 0.18s ease-out;
    position: fixed;
    inset: 0;
    /* Above the sticky nav AND above the existing 1000/1001 overlays. A modal that
       app chrome can sit on top of is not modal. */
    z-index: var(--z-dialog);
    display: flex;
    background: rgba(0,0,0,0.6);
}

.dialog {
    display: flex;
    flex-direction: column;
    max-height: 100%;
    overflow: hidden;
    background: var(--bg-card);
    border: 1px solid var(--outline);
    box-shadow: 0 12px 40px rgba(0,0,0,0.5);
}

/* No visible ring on the panel itself: it is focused programmatically on open so
   the title is announced, and a ring around the whole dialog reads as an error
   rather than as "you are here". Controls inside keep their own. */
.dialog:focus-visible { outline: none; }

.dialog-backdrop.is-closing { animation: dialog-fade-out 0.18s ease-in forwards; }

.dialog-centre {
    margin: auto;
    width: min(560px, 100% - var(--space-5) * 2);
    border-radius: var(--radius-lg);
    animation: dialog-rise-in 0.18s ease-out;
}

.dialog-centre.is-closing { animation: dialog-rise-out 0.18s ease-in forwards; }

.dialog-drawer { animation: dialog-slide-end-in 0.22s cubic-bezier(0.4, 0, 0.2, 1); }

.dialog-drawer.is-closing { animation: dialog-slide-end-out 0.22s cubic-bezier(0.4, 0, 0.2, 1) forwards; }

/* THE LIGHTBOX MUST ANIMATE, even though visually it barely needs to.
   Dialog unmounts on animationend. This variant had no animation of any kind, so
   for a lightbox that event never fired: _closing stayed true, the panel stayed
   mounted, and the backdrop — which DOES animate, to opacity 0 with `forwards` —
   faded to invisible while remaining in the DOM at --z-dialog. An invisible
   full-screen element over the page ate every click, which is what "the app can't
   be clicked until refresh" was.
   A component that unmounts on animationend makes "has an exit animation" part of
   the variant's contract, not a decoration. The C# now has a fallback so a future
   variant cannot lock the app this way again, but the animation is still the
   mechanism and every variant needs one. */
.dialog-lightbox { animation: dialog-fade-in 0.18s ease-out; }

.dialog-lightbox.is-closing { animation: dialog-fade-out 0.18s ease-in forwards; }

.dialog-sheet { animation: dialog-sheet-in 0.22s cubic-bezier(0.4, 0, 0.2, 1); }

.dialog-sheet.is-closing { animation: dialog-sheet-out 0.22s cubic-bezier(0.4, 0, 0.2, 1) forwards; }

/* Reduced motion: keep the animations but collapse them to ~0, rather than removing them. The
   component unmounts on animationend, so an animation that never runs is one that never ENDS —
   the dialog would stay on screen forever. A 1ms animation still fires the event. */
@media (prefers-reduced-motion: reduce) {
    .dialog-backdrop,
    .dialog-backdrop.is-closing,
    .dialog,
    .dialog.is-closing {
        animation-duration: 1ms !important;
    }
}

/* Slides from the inline end. margin-left: auto rather than a transform so it
   works in both writing directions and needs no fixed width to position. */
.dialog-drawer{
    /* Width lives on BOTH drawer variants. It was on .dialog-drawer alone, so a
       drawer-start had no width at all and shrink-wrapped its content — which
       would have hit the artists-rail replacement the moment it was adopted. */
    width: min(420px, 100%);
    height: 100%;
    border-radius: 0;
    border-top: 0;
    border-bottom: 0;
}

.dialog-drawer {
    margin-left: auto;
    border-right: 0;
}

/* Slides from the INLINE START — the answer to the artists rail. A collapsed
   icon rail that opens a drawer on click replaces hover-to-expand, and takes the
   focus trap, Escape and focus restore with it, none of which hover can have. */

.dialog-drawer.help-panel { width: min(760px, 92vw); }

/* The person drawer carries a tenure timeline, which needs room for a year axis beside the band
   names; the generic 420px drawer folds the bars to nothing. */
.dialog-drawer.person-panel { width: min(640px, 92vw); }

@media (max-width: 849px) {
    .dialog-drawer.help-panel,
    .dialog-drawer.person-panel { width: 100%; }
}

/* Lightbox — an image viewer. Modal in BEHAVIOUR (it must trap focus, close on
   Escape and restore focus, which is why it belongs to Dialog at all) but not in
   appearance: the content is the artwork, so the panel itself has no surface,
   border or shadow to compete with it. The close button floats over the image
   rather than sitting in a header bar. */
.dialog-lightbox {
    position: relative;
    margin: auto;
    max-width: 92vw;
    max-height: 92vh;
    overflow: visible;
    border: 0;
    border-radius: 0;
    background: none;
    box-shadow: none;
}

/* The close sits ON the artwork, so it cannot take .btn-ghost's --text-muted the
   way a close on a panel surface can — over a light image (owner, 11/08: a white
   AC/DC sleeve) it disappears entirely. Give it its own scrim so it is legible
   against any image rather than against the dialog surface it does not have. */
.dialog-lightbox .dialog-header .btn {
    background: rgba(0, 0, 0, 0.55);
    border-color: rgba(255, 255, 255, 0.25);
    color: #fff;
}

.dialog-lightbox .dialog-header .btn:hover {
    background: rgba(0, 0, 0, 0.75);
    border-color: rgba(255, 255, 255, 0.45);
}

/* Rises from the bottom — the mobile pattern, where a centred dialog fights the
   keyboard and the thumb. */
.dialog-sheet {
    margin-top: auto;
    width: 100%;
    max-height: 85%;
    border-radius: var(--radius-lg) var(--radius-lg) 0 0;
    border-bottom: 0;
}

.tag {
    display: inline-flex;
    align-items: center;
    gap: var(--space-1);
    padding: 2px var(--space-2);
    border: 1px solid var(--border-med);
    border-radius: var(--radius-sm);
    background: var(--bg-card);
    color: var(--text-muted);
    font-size: var(--text-sm);
    font-weight: 500;
    /* No cursor, no hover: a label that looks clickable is a worse lie than an
       ugly one. If it needs to respond, it is a control. */
    white-space: nowrap;
}

/* Rides inline after a run of text. */
.inline-pill {
    margin-left: var(--space-2);
    vertical-align: middle;
}

/* A date, won as a label. Heavier and wider-tracked than a plain .tag because a date is read
   as a unit rather than as a word. */
.date-badge {
    font-weight: 600;
    letter-spacing: 0.04em;
    white-space: nowrap;
}

.fill {
    flex: 1;
    min-width: 0;
}

/* One line, clipped with an ellipsis. Needs .fill (or another min-width: 0 ancestor) to do
   anything inside a flex row. */
.truncate {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Hold a label on one line. .truncate is the wrong tool where there is nothing to ellipsise —
   a filter label should push the control along, not shorten itself. */
.nowrap { white-space: nowrap; }

/* Hug the start of a flex column instead of stretching across it. */
.self-start { align-self: flex-start; }

/* A single-pane page: one centred column that hosts its own scroll, because .app-body has
   overflow: hidden. The width and the padding are the page's: set --page-column-max and
   --page-column-padding, never the properties: at equal specificity the sheet's order would
   decide. --text-secondary, not --text-primary, as the base colour: headings use currentColor,
   so one notch down softens every bolded line on the page without per-element overrides. */
.page-column {
    max-width: var(--page-column-max, 720px);
    margin: 0 auto;
    padding: var(--page-column-padding, 24px 16px 48px);
    color: var(--text-secondary);
    height: 100%;
    overflow-y: auto;
    overflow-x: hidden;
}

.admin-page { --page-column-max: 1100px; }

/* A list that is a column of rows, not bullets. The gap between rows is the caller's. */
.plain-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
}

/* An image or a video that fills its box and crops rather than letterboxes. */
.media-cover {
    width: 100%;
    height: 100%;
    display: block;
    object-fit: cover;
}

/* The invisible full-screen catcher behind an open menu or tooltip: a click anywhere closes it.
   It sits one below the thing it dismisses — a tie there left the winner to DOM order. Derived
   from --z-popover HERE, on the element, and not as a :root token: a token resolves at :root and
   hands every backdrop 49, so a pair that raises --z-popover would leave its backdrop behind.
   Derived rather than written as a number so the two cannot drift, because they did: a menu
   backdrop at 99 against a panel re-pointed at .menu (50) sat OVER it, and every mouse press
   dismissed the menu while the keyboard, which never hits the backdrop, still worked.
   "Keyboard works, mouse doesn't" is the signature. */
.popover-backdrop {
    position: fixed;
    inset: 0;
    background: transparent;
    z-index: calc(var(--z-popover) - 1);
}

.tag-micro {
    padding: 1px 6px;
    font-size: var(--text-xs);
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
}

.tag-accent {
    border-color: var(--accent-border);
    background: var(--accent-bg);
    color: var(--accent-light);
}

/* The wishlist mark's paint, shared by every surface that draws it — WantedDot renders one, and
   stack-flick.js builds another because StackView pins ShouldRender false and owns its own DOM.
   Size stays with each caller (a row, a pill and a cover tile want different boxes); only the
   paint is shared, so the two can never drift into different golds. */
.wanted-mark path {
    fill: var(--wanted);
    stroke: var(--wanted-edge);
    stroke-width: 2;
    stroke-linejoin: round;
}

/* Owner ruling 30/08/2026: green is the LIBRARY colour, not the owned colour, so both pills wear
   it and the gold bookmark inside the wanted one is the only thing that says which state. A shared
   selector rather than a second rule — .tag-wanted restating .tag-owned's three declarations is
   exactly what G8 exists to stop. The pills also carry different words, so neither leans on colour
   alone. */
.tag-owned,
.tag-wanted {
    border-color: var(--owned-border);
    background: var(--owned-bg);
    color: var(--owned);
}

.tag-warn {
    border-color: var(--warn-border);
    background: var(--warn-tint);
    color: var(--warn-light);
}

.tag-concert {
    border-color: var(--concert-border);
    background: var(--concert-bg);
    color: var(--concert-light);
}

.chip {
    min-height: var(--btn-height-sm);
    padding: 0 var(--space-3);
    border-color: var(--accent-tint-border);
    border-radius: var(--radius-pill);
    background: var(--accent-tint);
    color: var(--accent-light);
    /* font shorthand first: it resets font-size, so the size below must follow it. */
    font: inherit;
    font-size: var(--text-lg);
    text-decoration: none;
    white-space: nowrap;
}

.chip:hover {
    border-color: var(--accent-border);
    background: var(--bg-raised-hi);
    color: var(--text-primary);
}

.chip:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

/* Same treatment as .btn.is-active, and for the same reason: the inset shadow
   thickens the edge without resizing the box, so selecting a chip in a filter row
   never shifts its neighbours. */
.chip.is-selected {
    border-color: var(--accent);
    background: var(--accent-bg);
    color: var(--text-primary);
    box-shadow: inset 0 0 0 1px var(--accent);
}

.chip:disabled {
    opacity: 0.4;
    cursor: not-allowed;
}

/* Density step for chips that ride inline with text or carry a thumb — the concert
   heros 32px full chips wrapped a four-act bill early (owner, 13/08). 26px still
   clears WCAG 2.5.8s 24px minimum. */

.chip-sm {
    min-height: 26px;
    padding: 0 var(--space-2);
    font-size: var(--text-md);
    gap: var(--space-1);
}

/* ─── Notice ─────────────────────────────────────────────────────────────────
   A tinted message box: error, warning, success, empty and loading states.
   Each tone sets border, background and text together — they are one decision, and
   splitting them is how a tinted box ends up with unreadable text. */
.notice {
    display: flex;
    align-items: flex-start;
    gap: var(--space-2);
    padding: var(--space-3) var(--space-4);
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    background: var(--bg-panel);
    color: var(--text-dim);
    font-size: var(--text-lg);
    line-height: 1.45;
}

/* For states that occupy a whole panel rather than sitting above content. */
.notice-block {
    display: block;
    padding: var(--space-6) var(--space-4);
    text-align: center;
}

.notice-danger {
    border-color: var(--danger-border);
    background: var(--danger-bg);
    color: var(--danger-text);
}

.notice-warn {
    border-color: var(--warn-border);
    background: var(--warn-tint);
    color: var(--warn-light);
}

.notice-success {
    border-color: var(--success-border);
    background: var(--success-bg);
    color: var(--success);
}

.notice-accent {
    border-color: var(--accent-border);
    background: var(--accent-bg);
    color: var(--accent-light);
}

/* Dashed reads as a placeholder rather than as a state — drop targets, "add one here". */
.notice-hint {
    border-style: dashed;
    border-color: var(--border-med);
    background: none;
}

.meter {
    --meter-height: 8px;
    overflow: hidden;
    width: 100%;
    height: var(--meter-height);
    border-radius: var(--radius-pill);
    background: var(--bg-input);
}

.meter-fill {
    display: block;
    height: 100%;
    border-radius: inherit;
    background: var(--accent);
    transition: width 0.25s ease;
}

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

.bar {
    display: flex;
    flex-shrink: 0;
    align-items: center;
    gap: var(--space-2);
    padding: var(--space-2) var(--space-4);
    color: var(--text-dim);
    font-size: var(--text-lg);
}

/* The edge is a modifier because which side carries it depends on whether the bar is a
   header or a footer. */
.bar-top { border-top: 1px solid var(--border); }

.bar-bottom { border-bottom: 1px solid var(--border); }

.bar-panel { background: var(--bg-panel); }

.bar-spread { justify-content: space-between; }

.bar-wrap { flex-wrap: wrap; }

.chip-row {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-2);
}

/* Focus ring for the primitives that are focusable but are not <button>s, so
   they get no UA ring of their own: ListSelector's rows (roving tabindex) and
   the popover items. Offset inward — an outward ring on a full-bleed row is
   clipped by the scroll container and only three of its four sides survive. */
.list-row:focus-visible,
.result-row:focus-visible,
.popover-item:focus-visible {
    outline: 2px solid var(--accent-light);
    outline-offset: -2px;
}

h1:focus:not(:focus-visible),
h2:focus:not(:focus-visible) { outline: none; }

/* The landing hero H1 is a three-line display headline. Chrome draws
   the :focus outline as the union of inline boxes, producing a
   parenthesis/bracket artefact at top and bottom. The :focus-visible
   rule above doesn't cover this case: FocusOnNavigate programmatically
   focuses the H1, and Chromium's :focus-visible heuristic still matches
   when the prior interaction was keyboard-initiated. Suppress
   unconditionally on this H1 specifically — the next Tab stop is the
   hero CTA, which retains its own focus-visible ring, so keyboard
   users keep their orientation. */

/* The metadata voice: anything secondary to the thing it sits beside -- counts, dates, durations,
   sub-labels. ONE size, because three sizes of the same role was drift rather than a decision. */
.meta {
    color: var(--text-dim);
    font-weight: 400;
    font-size: var(--text-sm);
}

/* A run of data values — format, country, catalogue number. Copy, not controls: .tag, .chip and
   .btn all render as the same grey box, and only corner radius tells a label from a button.

   Declared immediately AFTER .meta, deliberately. Callers carry both classes and the two set the
   same properties at the same specificity, so source order alone decides — and while this sat
   earlier in the file every value came out --text-dim at weight 400, which is .meta's flat grey
   rather than the emphasis this exists for.

   The VALUE carries weight and the separator recedes. That contrast is what replaces the box;
   uniform grey text is not a de-boxed run, it is a sentence nobody reads. */
.meta-list {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 0 var(--space-2);
    color: var(--text-primary);
    font-weight: 500;
    font-size: var(--text-sm);
}

/* A pseudo-element, not markup: a caller cannot forget one, or leave a trailing one when the last
   value is absent — and every value in these runs can be absent. */
.meta-list > * + *::before {
    content: '·';
    margin-right: var(--space-2);
    color: var(--text-dim);
    font-weight: 400;
}

/* The primary line of a row, card or list entry. */
.row-title {
    color: var(--text-secondary);
    font-size: var(--text-lg);
}

/* Metadata, sized by how dense the surface is. One grey across all of them: --text-muted was a
   second grey doing --text-dim's job. .meta is the default; these are the ends of the range. */
.meta-xs {
    color: var(--text-dim);
    font-size: var(--text-xs);
}

.meta-lg {
    color: var(--text-dim);
    font-size: var(--text-md);
}

/* A value that is absent or inferred rather than stated: 'no email recorded', 'using what you
   typed'. Italic carries that, so it is a role rather than a size. */
.meta-quiet {
    color: var(--text-dim);
    font-size: var(--text-sm);
    font-style: italic;
}

.pane-layout {
    display: flex;
    flex-direction: column;
    height: 100%;
    overflow: hidden;
}

.panel-header {
    padding: 12px 16px 10px;
}

/* A line of marks inside a row: a date, a title, then trailing metadata. Baseline rather than
   centre, or a tag sits a pixel off the text it labels. */
.row-line-wrap { flex-wrap: wrap; }

.row-line {
    display: flex;
    align-items: baseline;
    gap: var(--space-2);
}

/* A boxed mark needs room the line stack does not give it: two of them on consecutive lines sit
   2px apart and read as one block. Text lines keep the tighter rhythm. */
.row-line > .chip,
.row-line > .tag,
.row-line > .rating-stars {
    margin-block: 2px;
}

/* Shared by more than one screen, so they belong above the line rather than reading as
   one page's own. */

/* ─── Detail Content ─────────────────────────────────────────────────────── */
.detail-scroll {
    flex: 1;
    min-height: 0;
    overflow-y: auto;
}

.detail-content {
    flex: 1;
    overflow-y: auto;
    /* padding-top is intentionally 0: Chromium clips overflow at the padding
       box (outer padding edge) but anchors position:sticky top:0 to the
       content box (inner padding edge). A non-zero padding-top creates a
       band that is inside the scrollport but above the sticky-header anchor,
       so scrolled rows show through it above the column header. The hero's
       padding-bottom already provides visual breathing room above us.
       Where .detail-scroll is the scrollport the same argument applies to it,
       which is why it carries no padding either. */
    padding: 0 20px 24px;
}

/* Concert detail hero */
/* The concert hero is the shared .detail-hero wearing a skin: this is only what it adds. */
.concert-hero {
    padding: 28px 28px 18px;
    background: linear-gradient(135deg, rgba(80,50,180,0.15) 0%, transparent 60%);
    flex-shrink: 0;
}

/* Panel can shrink between 360–460px under flex pressure (info wants more room),
   then stacks below 970px container width. The shrink range gives a smooth transition
   instead of jumping straight from 460-fixed to full-width.
   `color-scheme: dark` here is inherited by descendant native widgets — date pickers,
   selects, scrollbars — so they render in dark theme automatically. Both per-field
   and all-edit date inputs pick this up; no need for filter hacks on the indicator. */
.hero-collection {
    flex: 0 1 460px;
    min-width: 360px;
    color-scheme: dark;
}

/* Compound selector on purpose: .panel carries the shared padding and is declared LATER in this
   file, so a bare .hero-collection would tie on specificity and lose on source order.

   Less room at the top than the shared panel gives, because this one opens with a small-caps
   caption centred against a button twice its height, which already sits it ~9px down. The sides
   keep the shared 16px. */
.hero-collection.panel {
    padding: var(--space-1) var(--space-4) var(--space-3);
}

.hero-collection-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 10px;
    padding-bottom: 8px;
    border-bottom: 1px solid var(--border);
}

.hero-collection-header--actions { justify-content: flex-end; }

.coll-field-comments { white-space: pre-wrap; }

.coll-field-edit {
    display: flex;
    align-items: center;
    gap: 6px;
    min-width: 0;
}

.coll-field-edit-block {
    flex-direction: column;
    align-items: stretch;
    gap: 4px;
}

/* Tier 3 hotspot — pulsing dot anchored to a UI element whose interactivity
   isn't otherwise obvious. The visible dot is small (10–14px), but the button
   itself is a 44×44 hit target (per --min-touch) so touch users can land on
   it reliably. The dot + pulse render via pseudo-elements centred inside the
   transparent button.

   Position via the host element (relative parent → absolute hotspot). Default
   anchor is top-right of the host; placements that need a different anchor
   can override via inline style. */

.hotspot-anchor {
    position: relative;
    display: inline-flex;
    align-self: flex-start;
    max-width: 100%;
}

/* ─── Track List ─────────────────────────────────────────────────────────── */
.tracklist { width: 100%; border-collapse: collapse; }

/* Sticky column headers — keep "# / TITLE / TIME" pinned as the user scrolls
   through long tracklists, so the columns stay readable. The .detail-content
   container scrolls independently of the page; sticky position resolves
   relative to that. */
.tracklist th {
    text-align: left;
    font-size: var(--text-sm);
    font-weight: 600;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--text-dim);
    padding: 10px 10px;
    border-bottom: 1px solid var(--border);
    position: sticky;
    top: 0;
    background: var(--bg-base);
    z-index: 2;
}

.tracklist th.track-dur { text-align: right; }

.tracklist td { padding: 9px 10px; border-bottom: 1px solid rgba(255,255,255,0.03); }

.tracklist tr:hover td { background: var(--bg-hover); }

.track-num { width: 32px; text-align: right; padding-right: 12px; }

/* Total row (<tfoot>) — right-aligned inside the track-dur column so the
   aggregate sits directly under the per-track times.  No border (it's the
   last row), slightly taller top padding for visual separation from the
   final track row. */
.tracklist tfoot .track-total-row td { border-bottom: none; padding-top: 10px; }

.tracklist tfoot .track-total-row .track-dur { font-size: var(--text-sm); color: var(--text-dim); }

/* In a concert ROW it is trailing metadata and goes right; on the concert detail
   HERO the same pill sits inline beside the artist title and must not move. Scoped
   to the row rather than changed on the pill itself, so the two uses stay
   independent. */

/* Clickable text inside a sentence or a list of names — not a button, not a nav link.
   The TEXT keeps its colour and the accent goes on the underline: these sit in runs
   (a setlist, a credit list), and colouring the words themselves turns the run into a
   wall of accent that reads as the point of the panel rather than as a way in.
   padding:0 so it behaves the same whether it is an <a> or a <button>, which the UA
   would otherwise pad out of the line it belongs to. */
.text-link {
    color: inherit;
    padding: 0;
    text-decoration: underline dotted var(--accent);
    text-underline-offset: 3px;
    text-decoration-thickness: 1px;
}

.text-link:hover,
.text-link:focus-visible {
    color: var(--accent-light);
    text-decoration: underline solid var(--accent);
}

/* ─── Concert Images ─────────────────────────────────────────────────────── */
.concert-image-strip { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }

.linked-concerts {
    margin-bottom: 14px;
    font-size: var(--text-md);
}

.concert-hero-date {
    margin-bottom: 6px;
}

/* ─── Detail Tab Bar ─────────────────────────────────────────────────────── */
.detail-tabs {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    border-bottom: 1px solid var(--border);
    padding: var(--space-1) var(--space-5);
    flex-shrink: 0;
    gap: var(--space-1) var(--space-2);
    background: var(--bg-base);
}

.detail-tabs .tab-spacer { flex: 1; }

/* For the concert pane, whose order is hero → tabs → content: the tabs cannot sit
   outside the scrollport as they do on the release pane, so sticky keeps them
   reachable. z-index clears the tracklist header (2) and disc-pills row (3). */
.detail-scroll > .detail-tabs {
    position: sticky;
    top: 0;
    z-index: 4;
}

/* ─── View Layouts ───────────────────────────────────────────────────────── */
.view-1pane,
.view-2pane,
.view-3pane {
    display: flex;
    height: 100%;
    overflow: hidden;
}

/* Clamped, not fixed, because .pane-detail is flex: 1 and so absorbed the whole of
   any narrowing while the lists kept full size — below ~1000px that left it too
   narrow to render at all (11/08). The floor on each is where the LIST stops being
   readable; below that the one-pane layout takes over. */
.pane-sidebar {
    width: clamp(190px, 20%, 240px);
    flex-shrink: 0;
    background: var(--bg-sidebar);
    border-right: 1px solid var(--border);
    display: flex;
    flex-direction: column;
    overflow: hidden;
}

/* ─── Panes ──────────────────────────────────────────────────────────────── */

.pane-sidebar--search { width: clamp(240px, 26%, 320px); }

/* Concerts list rows carry a 28px artist/concert thumbnail before the date badge,
   eating into the artist-name budget at the default 240px. Matches the Artists
   page (.pane-sidebar-full max-width 480px) so long names like "Suicidal Tendencies"
   / "Rage Against The Machine" fit without ellipsis truncation. */
.pane-sidebar--concerts { width: clamp(300px, 38%, 480px); }

.pane-mid {
    width: clamp(200px, 22%, 260px);
    flex-shrink: 0;
    background: var(--bg-panel);
    border-right: 1px solid var(--border);
    display: flex;
    flex-direction: column;
    overflow: hidden;
}

/* container-type establishes a container-query context so .detail-hero can react to the PANE's
   width rather than the viewport — a viewport query is wrong when rails beside it consume
   horizontal space. */
.pane-detail,
.pane-detail-wide {
    flex: 1;
    background: var(--bg-base);
    display: flex;
    flex-direction: column;
    overflow: hidden;
    min-width: 0;
    container-type: inline-size;
}

/* On a phone one pane shows at a time; the page marks the others. Shared by every paned page. */
@media (max-width: 849px) {
    .mobile-hidden { display: none !important; }
}

/* ─── Panel Header ───────────────────────────────────────────────────────── */
.panel-title {
    font-size: var(--text-md);
    font-weight: 600;
    color: var(--text-primary);
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 8px;
}

.panel-title .count {
    font-size: var(--text-sm);
    color: var(--text-dim);
    font-weight: 500;
    padding: 2px 10px;
    border-radius: 12px;
    background: var(--bg-card);
    border: 1px solid var(--border);
    margin-left: 8px;
    flex-shrink: 0;
    /* Ensure a tab-like number sits centred regardless of digit width. */
    min-width: 22px;
    text-align: center;
}

/* ─── Mobile Pane Navigation ────────────────────────────────────────────── */
.mobile-pane-nav { display: none; }

/* Chrome-less and flush to the pane edge: the back link is navigation within the view, not a
   command, so it carries no button box. .btn-ghost supplies the rest. */
.mobile-back {
    padding-left: 0;
    padding-right: 0;
    color: var(--accent-light);
}

/* Three-column layout: covers (auto-width) | info (flex-grow) | optional Your
   Collection panel (~320px). The collection column is rendered conditionally; when
   absent, the layout is effectively two-column. Below 970px the row collapses to a
   stacked column so the panel sits below the info on narrow screens. */
.detail-hero {
    padding: 24px 24px 16px;
    display: flex;
    gap: 20px;
    align-items: flex-start;
    flex-shrink: 0;
    position: relative;
    background: linear-gradient(160deg,
        color-mix(in srgb, var(--cover-color, var(--accent)) 28%, var(--bg-panel)) 0%,
        var(--bg-panel) 52%);
    border-bottom: 1px solid var(--border);
}

.hero-info { flex: 1 1 320px; min-width: 280px; padding-top: 2px; }

.hero-title { font-size: var(--display-md); font-weight: 700; color: var(--text-primary); letter-spacing: -0.3px; line-height: 1.2; margin-bottom: 12px; }

/* .btn .btn-ghost .btn-sm supplies the shape, the reset and the touch target —
   which this never had, and it is a primary navigation control on nine pages.
   What stays is the breadcrumb's own alignment: the negative margin lets a 44px
   target sit inside a 32px bar without stretching it, and the accent hover marks
   this as a link rather than a command. */
.breadcrumb-back-btn {
    margin: -8px 0;
    padding-left: var(--space-2);
    color: var(--text-dim);
}

.btn.breadcrumb-back-btn:hover:not(:disabled) { color: var(--accent-light); }

.breadcrumb-current { font-size: var(--text-md); font-weight: 600; color: var(--text-secondary); }

.album-mixed-badge {
    font-size: var(--text-xs);
    flex-shrink: 0;
    opacity: 0.7;
}

.setlist-track-element .track-title,
.setlist-track.setlist-track-element {
    font-style: italic;
    color: var(--text-muted);
}

.breadcrumb-bar {
    height: 32px;
    padding: 0 var(--space-5);
}

.list-scroll {
    /* The A-Z strip and the artist search both scroll this container programmatically.
       Declaring it here animates every such scroll rather than relying on each call site
       remembering to ask. */
    scroll-behavior: smooth;
    flex: 1;
    overflow-y: auto;
    overflow-x: hidden;
    position: relative;
}

.artist-list-body > .list-scroll { flex: 1; min-width: 0; }

.letter-jump-btn {
    font-size: var(--text-xs);
    padding: 0 6px;
    color: var(--accent);
    line-height: 1.4;
    min-width: 22px;
    text-align: center;
    /* Anchor for the hit-area pseudo below. */
    position: relative;
}

/* WCAG 2.5.8 Target Size (AA). The visible glyph is ~16px tall and these stack in a
   flex column with no gap, so neither the 24x24 size rule nor the spacing exception
   was met. A transparent centred pseudo-element gives the full target without
   changing anything visually — the same trick .biog-timeline-dot::after already uses,
   which is why the A-Z strip can stay this small and this dense. */
.letter-jump-btn::after {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    width: 24px;
    height: 24px;
    transform: translate(-50%, -50%);
}

.letter-jump-btn:active { background: var(--accent-bg); border-radius: 3px; }

.owned-dot {
    width: 7px;
    height: 7px;
    border-radius: 50%;
    background: var(--owned);
    flex-shrink: 0;
}

.scroll-fade {
    --scroll-fade-depth: 44px;
    position: relative;
    /* Keeps an item scrolled into view (arrow keys, focus) clear of the fade below. Without it
       the browser scrolls the item only until its bottom edge meets the scroller's, which puts
       its title and focus ring under the fade: owner, 21/09/2026, "can lose the selection". */
    scroll-padding-bottom: var(--scroll-fade-depth);
}

.scroll-fade::after {
    content: '';
    display: block;
    position: sticky;
    bottom: 0;
    left: 0;
    right: 0;
    /* Deeper and more aggressive than the original 28px @ 0.85 — that pair
       was too subtle on the dark theme. 44px overall depth gives enough
       runway to read as a fade; the gradient hits full --bg-panel by 75%
       so the dim is decisive in the lower half rather than spread thinly
       across the whole region. Full opacity removes the residual lightness
       that made the original look like a mistake on this colour scheme. */
    height: var(--scroll-fade-depth);
    margin-top: calc(-1 * var(--scroll-fade-depth));
    background: linear-gradient(to bottom, transparent 0%, var(--bg-panel) 75%);
    pointer-events: none;
    transition: opacity 0.2s ease-out;
}

.scroll-fade.scroll-fade-at-end::after {
    opacity: 0;
}

.search-btn { align-self: flex-end; }

.search-filters {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 14px;
    border-bottom: 1px solid var(--border);
    flex-wrap: wrap;
}

.search-filter-select {
    flex: 1 1 140px;
    max-width: 220px;
}

/* padding-left is deliberately tight so the strip sits close to the duration in the column on its
   left; the default cell padding put a 20px gap there that read as floating. */
.tracklist th.track-listen,
.tracklist td.track-listen {
    width: 1%;
    white-space: nowrap;
    text-align: right;
    padding-left: 4px;
}

/* Shared panel chrome — referenced as `.panel` so future panels match without further work. */
.panel-recessed {
    background: var(--bg-panel);
    box-shadow: none;
}

.legal-page {
    --page-column-max: 760px;
    /* Wider than the column's own: long prose reads better inset. */
    --page-column-padding: 32px 24px 64px;
    line-height: 1.55;
}

.legal-page h1 {
    font-size: 1.5rem;
    font-weight: 600;
    color: var(--text-primary);
    margin: 0 0 4px;
}

.legal-page h2 {
    font-size: var(--text-2xl);
    font-weight: 600;
    color: var(--text-primary);
    margin: 28px 0 8px;
}

.legal-page .legal-meta {
    color: var(--text-dim);
    font-size: var(--text-sm);
    margin: 0 0 24px;
}

.legal-page p,
.legal-page li {
    margin: 0 0 10px;
}

.legal-page ul {
    margin: 0 0 14px;
    padding-left: 22px;
}

.legal-page a {
    color: var(--accent-light);
}

.admin-header {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: 16px;
    margin-bottom: 18px;
}

.admin-header h1 {
    font-size: var(--display-sm);
    font-weight: 600;
    margin: 0;
    color: var(--text-primary);
}

/* Lockout card for non-admins who somehow reach /admin/* — also rendered
   briefly during the SubscriptionStatus-loading window before the page
   knows whether the caller is admin. */
.admin-locked {
    max-width: 480px;
    margin: 60px auto;
    text-align: center;
}

.admin-locked h1 {
    font-size: var(--text-2xl);
    font-weight: 600;
    margin: 0 0 8px;
    color: var(--text-primary);
}

.admin-locked p {
    color: var(--text-dim);
    font-size: var(--text-md);
    margin: 0;
}

.app-banner {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    padding: 10px 16px;
    margin: 0;
    font-size: var(--text-lg);
    position: sticky;
    top: 0;
    z-index: 60;
    border-bottom: 1px solid;
}

/* NOT flex: 1. Letting the text take the slack pushed the action to the far right of a
   full-width strip, where it reads as unrelated to the sentence and is easy to miss. */
.app-banner-text { flex: 0 1 auto; }

.app-banner > svg { flex-shrink: 0; }

.app-banner-cta {
    color: inherit;
    text-decoration: underline;
    white-space: nowrap;
    font-weight: 600;
}

/* Outbox queue-depth panel (admin/backfills). Compact data table; the page is desktop-first
   but must not overflow on mobile, so the wrapper scrolls horizontally if it ever needs to. */
.admin-table { width: 100%; border-collapse: collapse; font-size: var(--text-md); }

.admin-table th { text-align: left; padding: 4px 10px 4px 0; color: var(--text-secondary); font-weight: 600; }

.admin-table td { padding: 3px 10px 3px 0; color: var(--text-primary); }

.admin-card .admin-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
    align-items: center;
    margin-top: 4px;
}

body:has(.landing) #blazor-error-ui,
body:has(.pricing-page) #blazor-error-ui,
body:has(.legal-page) #blazor-error-ui {
    display: none !important;
}

/* ─── END PRIMITIVES ─────────────────────────────────────────────────────────
   Everything above is shared vocabulary and may be used anywhere. Everything below
   belongs to one screen or component. G7 and G8 read this marker to tell the two
   apart, so moving it silently changes what they permit — a rule that needs to live
   above it needs to EARN that, by being a primitive. */

@media (min-width: 850px) {
    .dialog-backdrop:has(.add-hub-sheet) { display: none; }
}

@keyframes bottom-sheet-fade-in {
    from { opacity: 0; }
    to { opacity: 1; }
}

@keyframes bottom-sheet-slide-up {
    from { transform: translateY(100%); }
    to { transform: translateY(0); }
}

@keyframes onboarding-banner-in {
    from { opacity: 0; transform: translateY(-6px); }
    to { opacity: 1; transform: translateY(0); }
}

@keyframes onboarding-banner-to-help {
    0%   { opacity: 1; transform: translate(0, 0) scale(1); max-height: 200px; }
    20%  { opacity: 1; transform: translate(2%, -8%) scale(0.94); max-height: 200px; }
    80%  { opacity: 0.4; transform: translate(40%, -200%) scale(0.18); max-height: 200px; }
    100% {
                opacity: 0;
                transform: translate(46%, -240%) scale(0.12);
                max-height: 0;
                margin-top: 0;
                margin-bottom: 0;
                padding-top: 0;
                padding-bottom: 0;
                border-color: transparent;
            }
}

@keyframes hotspot-pulse {
    0% { transform: scale(1); opacity: 0.7; }
    100% { transform: scale(3); opacity: 0; }
}

/* ─── Hotspot tooltip ────────────────────────────────────────────────────── */

@keyframes hotspot-tooltip-in {
    from { opacity: 0; transform: translateY(-4px); }
    to { opacity: 1; transform: translateY(0); }
}

/* ─── Scrollable List ────────────────────────────────────────────────────── */

/* ─── Letter Jump Strip ─────────────────────────────────────────────────── */

/* ─── Album / Release Row ────────────────────────────────────────────────── */

.list-row.is-active .row-title { color: var(--accent-light); }

/* ─── Release Group Row ──────────────────────────────────────────────────── */

/* ─── Empty State ────────────────────────────────────────────────────────── */

.empty-state .empty-icon { font-size: 2.5rem; opacity: 0.4; }

.empty-state .empty-cta {
    margin-top: 8px;
    padding: 10px 18px;
    background: var(--accent-bg);
    color: var(--accent-light);
    border: 1px solid var(--accent-border);
    border-radius: 6px;
    font-size: var(--text-lg);
    font-weight: 500;
    cursor: pointer;
    text-decoration: none;
    display: inline-flex;
    align-items: center;
    gap: 6px;
    min-height: var(--min-touch);
}

.empty-state .empty-cta:hover { background: rgba(139,132,232,0.18); }

/* ─── Detail Hero ────────────────────────────────────────────────────────── */

/* Stack hero columns when the pane (NOT the viewport) is too narrow to fit all three
   side by side even with the panel at its 360px minimum. Container query: the
   breakpoint is on the pane's width, set up via `container-type: inline-size` on
   .pane-detail*. Matches the .hero-collection breakpoint below. */
@container (max-width: 969px) {
    .detail-hero {
        flex-wrap: wrap;
    }

    /* Same icon-only collapse as the 600px viewport block above, fired by the PANE: the tab bar's
       trailing actions (Remove/Share/Refresh) sit in a pane roughly half the viewport wide, so by
       the time the viewport query fired they had long since wrapped onto a second bar. Same
       declarations, second trigger — a media and a container condition cannot share one rule. */
    .detail-tabs .btn-collapse {
        width: var(--btn-height);
        padding: 0;
        justify-content: center;
    }

    .detail-tabs .btn-collapse .btn-label {
        position: absolute;
        width: 1px;
        height: 1px;
        margin: -1px;
        padding: 0;
        overflow: hidden;
        clip-path: inset(50%);
        white-space: nowrap;
    }
}

/* Inside a .detail-scroll the content is NOT its own scrollport — the wrapper is.
   Nesting one would trap the tracklist in an inner scroll again and defeat the
   point. Written as an override rather than by stripping the rule above, because
   the panes that have not been migrated to .detail-scroll yet (concerts, scan,
   search) still rely on .detail-content scrolling and must keep working
   unchanged. */
.detail-scroll > .detail-content {
    flex: none;
    overflow-y: visible;
}

/* ─── Responsive (≤849px) ────────────────────────────────────────────────── */
/* 850px because at 800 the Concerts list truncates "Status Quo" and Discography's
   sidebar renders two different releases identically — measured across all three
   pages, 11/08. 900 tested indistinguishable from 850, so it bought nothing.

   This is the mobile CHROME switch, not a pane switch: it hides .topnav. Anything
   gated on "is the desktop nav present" must therefore use this same threshold — the
   add-hub gate is at 849/850 for that reason, and the two `min-width: 850px` gates
   below must move with it or their two-class selectors outrank the mobile width.

   If 800 ever needs multi-pane, fix the concert ROW, not this number: it spends
   ~135px of a 304px pane on thumb, date badge and trailing pill. */
@media (max-width: 849px) {

    /* Popup-menu styling matches .user-menu-panel: a small floating panel
       anchored above the More button (rightmost item in the footer), rounded
       on all corners with a drop shadow. Same affordance as the avatar
       dropdown so the interaction is familiar. */
    /* Transparent backdrop — its only job is tap-outside-to-dismiss. Visible
       backdrop dimming would be heavier than the popup itself warrants. */

    body {
        display: flex;
        flex-direction: column;
        height: 100svh;
        padding-top: var(--mobile-header-height);
        padding-bottom: var(--mobile-footer-height);
    }
    .app-banner {
        flex: none;
        position: static;
    }
    /* Pane layout */
    .view-3pane, .view-2pane { flex-direction: column; overflow: hidden; }
    .pane-sidebar { width: 100%; height: 100%; border-right: none; }
    .pane-mid { width: 100%; height: 100%; border-right: none; }
    .pane-detail, .pane-detail-wide { width: 100%; height: 100%; }
    /* Mobile back button in pane headers */
    .mobile-pane-nav {
        display: flex;
        align-items: center;
        padding: 8px 14px;
        border-bottom: 1px solid var(--border);
        flex-shrink: 0;
        background: var(--bg-panel);
    }
    .pane-detail, .pane-detail-wide {
        overflow-y: auto;
        overflow-x: hidden;
    }
    .detail-content {
        flex: none;
        overflow-y: visible;
    }
    .detail-tabs {
        position: sticky;
        top: 0;
        z-index: 5;
    }
}

/* Vertical timeline container */

/* Footer note while the masters list is materialising in the background (cold miss). The owned
   rows render above it; this just flags that the unowned fill is still on its way. */

/* "Reissue YYYY" marker on an owned row whose pressing post-dates the original release. Neutral
   outline (informational) — deliberately NOT the green Owned pill, which carries ownership meaning. */

/* Links tab */

/* Mobile: stack bio and discog */

/* ─── Blazor Error UI ────────────────────────────────────────────────────── */
#blazor-error-ui {
    background: #3a1010;
    border-top: 1px solid var(--danger-border);
    color: var(--text-secondary);
    bottom: 0;
    box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.4);
    display: none;
    left: 0;
    padding: 0.6rem 1.25rem 0.7rem 1.25rem;
    position: fixed;
    width: 100%;
    z-index: 1000;
}

.blazor-error-boundary {
    background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbmsiPjwvc3ZnPg==) no-repeat 1rem/1.8rem, var(--danger-bg);
    padding: 1rem 1rem 1rem 3.7rem;
    color: var(--text-primary);
    border: 1px solid var(--danger-border);
    border-radius: 6px;
}

.blazor-error-boundary::after { content: "An error has occurred."; }

.concert-gallery-nav .btn-ghost:not(:disabled) {
    color: inherit;
}

/* ─── Concerts ───────────────────────────────────────────────────────────── */

/* Concert detail data grid */

/* Concert edit form */

select.input option { background: var(--bg-panel); color: var(--text-primary); }

/* Delete confirmation overlay */

/* ─── Artist Picker Modal ─────────────────────────────────────────────────── */

/* ─── Artists list page (full-width sidebar) ─────────────────────────────── */

@keyframes deep-link-flash {
    0%   { background: var(--accent); color: #fff; outline: 2px solid var(--accent); }
    80%  { background: var(--accent); color: #fff; outline: 2px solid var(--accent); }
    100% { background: transparent; color: inherit; outline-color: transparent; }
}

/* ─── Slide-over drawer ──────────────────────────────────────────────────── */

/* ================================================================== */
/* ================================================================== */

/* Album hero container for service link chips (Apple Music / Amazon Music).
   Mirrors .artist-page-hero-links but on the album detail hero. */

/* Album-type pill (EP / Compilation / Live / Soundtrack / etc.).
   Uses product accent; never a service brand colour. */

/* TrackType badge (Video / Data). Small, muted, product accent — draws attention
   to the exceptional case without competing with the title. */

/* Tighten the duration column's right padding so its text abuts the listen strip. */
.tracklist th.track-dur,
.tracklist td.track-dur {
    padding-right: 4px;
}

/* <summary> has display:list-item by default — the default triangle marker
   shows through no matter what. list-style:none + ::marker{content:""}

+
   ::-webkit-details-marker{display:none}

together cover every browser.
   display:flex forces the children to line up horizontally; without that,
   some browsers still render the summary as list-item with children on new
   lines. */

/* ── "Your collection" hero column ──────────────────────────────────────────
   Two render branches:
     • All-edit                 → every field as input simultaneously + Save / Cancel
     • Read / per-field edit    → panel with populated rows in a 2-col grid + the
                                   "+ Field" buttons row for missing fields. Clicking
                                   a value swaps that one row into an input + Done.
   The panel always renders for owned albums even when fully empty — the "+ Field"
   buttons are the discovery affordance for first-time entry.
   Mobile (<970px container): the column stacks below the info via flex-wrap on .detail-hero. */

/* Matched to .detail-hero's wrap breakpoint above — these two describe ONE state
   ("the panel has dropped below the info"), so a disagreement between them means a
   band where the row has wrapped but the panel is still sized as though it had
   not. Change them together. */
@container (max-width: 969px) {
    .hero-collection {
        flex-basis: 100%;
        min-width: 0;
        margin-top: 4px;
    }
}

.panel {
    padding: var(--space-3) var(--space-4) var(--space-4);
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
}

/* Read mode: 2-col grid (Rating | Condition, Amount paid | Where bought, Date purchased
   alone, Comments full-width). Auto-flow handles partial-population sensibly — fields
   that are missing simply don't render. Comments explicitly spans both columns. */

/* Date input: sized by its own content, NOT a pixel width. A fixed width has to be re-measured
   every time the font changes, and the previous 120px was set before `.input` gained its 1rem
   floor for iOS — after which dd/mm/yyyy plus the picker indicator no longer fit and the value
   clipped mid-field. `auto` lets the control ask for what it needs at any font size or locale
   date format; max-width keeps it inside its grid column.
   The `color-scheme: dark` declaration is hoisted up to .hero-collection (below) so both
   per-field and all-edit date inputs render their picker indicator in dark theme.
   Don't double up with `filter: invert(1)` here — color-scheme already lights the
   indicator and inversion would flip it back to dark. */
.coll-field-edit input[type="date"] {
    width: auto;
    max-width: 100%;
}

/* ─── Legal pages (Privacy, Terms) ───────────────────────────────────────── */

@media (max-width: 480px) {
    .legal-page {
        --page-column-padding: 20px 16px 80px;
    }
    .legal-page h1 { font-size: 1.25rem; }
    .legal-page h2 { font-size: var(--text-xl); margin-top: 22px; }
}

/* ─── Hero ─────────────────────────────────────────────────────────────── */

/* Soft purple bloom behind the hero — single absolutely-positioned ::before
   so we don't introduce a separate wrapper. Matches the existing app palette;
   subtle enough to read as ambient glow, not a gradient surface. */

/* ─── Stats band (three-cell feature summary, sits below hero) ──────────
   Subtle elevation panel so the band visually separates from the hero
   above and the Feature 1 section below. Icons are lavender, not the
   saturated CTA purple, per the §11 contrast rule (deep purple is
   reserved for white-text-on-purple surfaces). */

/* ─── Feature sections (1 & 2: two-column alternating; 3: paired demos) ── */

/* Feature 2 reverses the layout: demo first in source order would still
   render text-left, demo-right on desktop because grid columns are fixed.
   We use `order` to swap them in-place. Mobile resets via the responsive
   block below. */

/* Composite collection slot — still cover wall with the mobile scan-to-added
   clip set into a phone bezel in the corner (launch-demo media plan). Bezel
   colours are literal device-chrome greys, not theme tokens. */

/* ─── Pricing band ──────────────────────────────────────────────────────── */

/* ─── Responsive: single breakpoint at ~720px ───────────────────────────── */
@media (max-width: 720px) {
    /* Stats band: single column with icon left of sentence, vertically
       centred, per spec §5.3 last bullet. */
    /* Mobile: text first, demo second for every feature row — reset
       the reversed rows' desktop `order: -1` (which puts the demo on the
       left) so source order (text → demo) wins on narrow viewports. Keeps
       the eyebrow + headline + body visible above the fold; the demo
       follows as visual confirmation of what was just described. */
}

/* ─── Pricing page (/pricing) ────────────────────────────────────────────── */

/* ─── Pricing cards (shared by /, /pricing, /upgrade) ────────────────────── */

/* Disabled / launching-soon variant. Rendered as a <span> (non-clickable)
   when the IsStripeCheckoutEnabled flag is off, so authenticated users see
   the pricing detail but no active subscribe button. */

/* ─── Site footer (public pages: /, /pricing, /terms, /privacy) ──────────── */

/* Inline storage hint that sits above the uploader on the concert-edit
   form. Single muted line — the rich panel lives on /account/settings. */

/* ── Stats grid ─────────────────────────────────────────────────────────── */

/* ── Mobile ─────────────────────────────────────────────────────────────── */

@media (max-width: 849px) {
    .admin-page {
        --page-column-padding: 16px 8px 32px;
    }
    .admin-header {
        flex-direction: column;
        align-items: stretch;
        gap: 8px;
    }
}

/* Hero metric cards. auto-fit + minmax keeps the grid responsive — collapses
   from 6 columns at desktop width down to 2 columns at mobile width. */

.biog-metric-card-host > .stat { display: block; }

.biog-format-fill.ramp-1 { background: var(--accent); }

.biog-format-fill.ramp-2 { background: #7b73d4; }

.biog-format-fill.ramp-3 { background: var(--accent-light); }

.biog-format-fill.ramp-4 { background: #d6d2f5; }

/* ─── Library view toggle (PR1) ──────────────────────────────────────────── */

.panel-title-row .panel-title {
    margin-bottom: 0;
}

/* ─── Cover Wall (PR1) ───────────────────────────────────────────────────── */

/* ─── Rail-collapsed mode for Wall / Stack views (desktop/tablet only, ≥850px) ─── */

@media (min-width: 850px) {
    /* Smooth the layout shift when toggling between view modes. */
            .pane-sidebar,
            .pane-mid {
                transition: width 0.25s ease;
            }
}

/* Two-column grid wrapping ArtistCard | VenueCard on Concert detail.
   Stacks on mobile. Sits inside the Details tab content. */

/* Read-only rating strips in concert lists. Gold and the half-step geometry come
   from .rating-fill; these only say how big and that it must not shrink in the
   flex row. Size is set through the primitive's own --rating-size rather than a
   font-size that happens to win on source order — the variable is the supported
   knob, and it keeps the fill in step because the fill is sized from the same em. */

/* When a sidebar row shows a track count, the rating drops to its own line right-aligned
   under the track-count pill (the inline line-1 slot is taken). See ConcertList markup. */

/* Slight breathing room between the concert action bar (Share / Edit / Delete) and the tab
   content below it. Scoped to the concert detail; the album tracklist sits flush by design. */

/* ── ArtistCard ── */
/* Centres the initials fallback. */

/* The blurred copy behind it, so the frame is filled by the photo's own colours rather than
   letterbox bars. Scaled up so the blur's soft edge never shows the original image bounds. */

/* ── VenueCard ── */

/* Venue page (/venues/{id}) */

/* Multi-act linked-group bracket — same accent bar pattern as the main
   /concerts list. Margin-left + left padding shift the row contents
   right; the 3px border-left renders as the accent bar. First / last
   members of the group get rounded corners + a small margin gap so two
   adjacent groups don't appear merged into one. */
.venue-page-concert-row.linked-group-row,
.list-row.linked-group-row {
    border-left: 3px solid var(--accent);
    margin-left: 4px;
    padding-left: 11px;
    position: relative;
}

.venue-page-concert-row.linked-group-first,
.list-row.linked-group-first {
    margin-top: 4px;
    border-top-left-radius: 4px;
}

.venue-page-concert-row.linked-group-last,
.list-row.linked-group-last {
    margin-bottom: 4px;
    border-bottom-left-radius: 4px;
}

