/* ---------------------------------------------------------------------------
   TourHub — base styles
   Design tokens come from the Ringnes-Ronny design book §9.1 (System B / Document
   palette). Per-tenant theming will override these CSS custom properties at the
   :root level when a tenant has a non-default theme.

   Before adding new styles, read MOBILE_RECIPES.md at the project root
   (code/src/TourHub.Web/MOBILE_RECIPES.md). It documents the conventions for
   breakpoints, forms, tables, buttons, popovers, modals, typography, and touch
   targets. If you find yourself reaching for a raw pixel value or a new
   breakpoint, the answer is almost always in a recipe.
   --------------------------------------------------------------------------- */

:root {
    /* Light is the default. Declaring color-scheme here keeps native form
       chrome (checkbox/radio/scrollbar/dropdown arrows) light even when the
       OS prefers dark and the user has explicitly picked Light in the theme
       toggle (data-theme="light"). Without this, picking Light on a dark OS
       leaves color-scheme unset, the browser falls back to the OS, and any
       view that reveals native widgets (e.g. the tour Crew inline editor
       with its <details> role-picker + native checkboxes) suddenly looks
       dark again. The dark-mode rules below override this. */
    color-scheme: light;

    --th-purple:        #7B4B8B;
    --th-purple-dark:   #664077;
    /* Saturated brand purple used for active-nav indicators (tour tab strip).
       The base --th-purple is a muted dusty tone that reads as "almost grey"
       on a white surface — the dark-mode active indicator lands at the
       luminous #C28BE0 and visibly pops, so light mode needs a parallel
       higher-energy purple to keep the active tab discoverable. Dark mode
       override below collapses this back to --th-purple. */
    --th-purple-vivid:  #7B1FA2;
    --th-lavender:      #F3E5F5;
    --th-grey-light:    #EEEEEE;
    --th-grey-border:   #BFBFBF;
    --th-text:          #333333;
    --th-text-muted:    #808080;
    --th-white:         #FFFFFF;

    /* Page background sits one step below --th-white so card/modal/popover
       surfaces (which use --th-white) read as raised against it. Subtle
       lavender tint nods to the brand purple. Kept close to white so the
       --th-grey-light borders/surfaces still have room to read as grey
       against it — dropping the lightness further makes the page compete
       with grey UI chrome. In dark mode the override below sets it to
       --th-white (the dark surface) so the page and cards share the
       same dark base, matching the existing dark-mode look. */
    --th-page-bg:       #F7F6F9;

    --th-success:       #2E7D32;
    --th-warning:       #F2A93B;
    --th-error:         #C62828;
    --th-info:          #1565C0;

    /* Deeper amber for warning text/glyphs that sit on the tinted
       --th-alert-warning-bg surface. The accent --th-warning is tuned
       for solid hover fills and alert icons; placed as text on the
       cream background it lands at ~1.9:1 contrast (fails AA). This
       darker variant hits ~4.8:1 against the current --th-alert-warning-bg
       and still reads as amber rather than brown. Dark mode resolves to
       the bright --th-warning since the dark amber surface there has
       no contrast issue with the bold accent. */
    --th-warning-text:  #9C5908;

    /* Alert backgrounds. Light-mode values match the design book; dark-mode
       overrides further down keep the same hue but at low lightness so they
       read as tinted surfaces instead of bright pastel panels. */
    --th-alert-success-bg: #E8F5E9;
    --th-alert-warning-bg: #FFF0CF;
    --th-alert-error-bg:   #FDECEA;
    --th-alert-info-bg:    #E3F2FD;

    /* Flagged-row tint — soft cyan so highlighted schedule items read as
       "marked" without blending with the lavender used by soft buttons
       (Assign crew, + End time). Cyan sits in a different hue family
       from the purple/lavender palette. */
    --th-highlight-bg:     #B2EBF2;

    /* Icon-button hover skins — in light mode the small 32×32 pill reads
       as a heavy block when filled with the bold accent, so we dilute the
       fill heavily toward the page surface (white) and switch the glyph
       to dark text so the icon stays legible on the now-pale pill. Dark
       mode keeps the solid accent + light glyph (--th-white resolves to
       a near-black in that mode) since the palette is already light
       against the dark surface; overrides in the dark-mode blocks below
       restore the bold values. */
    --th-icon-btn-hover-purple:  color-mix(in srgb, var(--th-purple)  40%, var(--th-white));
    --th-icon-btn-hover-error:   color-mix(in srgb, var(--th-error)   40%, var(--th-white));
    /* Amber has a much higher native lightness than purple/red, so the
       same 40% blend would land as a near-white pill that reads as
       "fading on hover" instead of "intensifying". Push warning to 65%
       so cancel hover sits at similar visual depth to save / trash. */
    --th-icon-btn-hover-warning: color-mix(in srgb, var(--th-warning) 65%, var(--th-white));
    --th-icon-btn-cancel-rest:   var(--th-warning-text);
    --th-icon-btn-hover-glyph:   var(--th-text);

    --th-font-body:     Arial, "Helvetica Neue", Helvetica, sans-serif;
    --th-font-display:  "Bebas Neue", "Oswald", Impact, sans-serif;

    --th-space-1:       0.25rem;
    --th-space-2:       0.5rem;
    --th-space-3:       1rem;
    --th-space-4:       1.5rem;
    --th-space-5:       2rem;
    --th-space-6:       3rem;

    --th-radius:        6px;
    --th-tap-target:    44px;

    /* Rendered height of .th-app__header — used as the sticky offset for
       .th-event-chrome so the event/tour chrome sticks just below the
       always-visible page header instead of underneath it. Must match the
       actual header height exactly: undershooting hides the top edge of
       the sticky chrome behind the page header (the title visibly slides
       up on scroll); overshooting opens a gap. Header content is 44px tap
       target + 16px padding × 2 + 1px border-bottom = 77px minimum, but
       the account-menu trigger pushes it to ~81px (5.0625rem) in practice. */
    --th-header-h:      5.0625rem;

    /* Standard responsive breakpoints. Documentary only — CSS variables
       can't be referenced from inside @media queries (the spec forbids it),
       so the underlying media rules below use the same pixel values
       directly. Keep these tokens in sync with the literal queries.
         --th-bp-sm  phones (iPhone Plus class)
         --th-bp-md  large phones / small tablets — primary mobile cutoff
         --th-bp-lg  tablets / small laptops
       The 540px breakpoint stays specific to the public daysheet/crewlist. */
    --th-bp-sm:         480px;
    --th-bp-md:         640px;
    --th-bp-lg:         960px;
}

/* ---------- Dark mode ----------
   Names of the design tokens were chosen for their LIGHT-MODE color
   (--th-white, --th-grey-light). In dark mode they keep their role but invert
   value: --th-white becomes the page surface (dark), --th-text becomes light.
   Purple is shifted lighter so the contrast on a dark surface stays AA-friendly.

   Two activation paths:
     1. data-theme="dark" on <html>  → explicit user choice (set by ThemeToggle)
     2. No data-theme attribute + prefers-color-scheme: dark
        → respect the OS preference when the user hasn't picked anything
   The :not([data-theme]) clause inside the @media block makes sure an
   explicit "light" choice wins over the media query. Token block is
   duplicated rather than introducing a preprocessor.
*/
:root[data-theme="dark"] {
    /* color-scheme tells the browser to render native form controls
       (date-picker calendar icon, scrollbars, dropdown arrows, etc.) in a
       dark variant. Without this the icons stay black and disappear into the
       dark background. */
    color-scheme: dark;

    --th-purple:        #C28BE0;
    --th-purple-dark:   #D9B0EE;
    /* Dark mode's --th-purple is already luminous against the dark surface,
       so the active-nav accent collapses back to it. */
    --th-purple-vivid:  var(--th-purple);
    --th-lavender:      #3A2540;
    --th-grey-light:    #2A2A2D;
    --th-grey-border:   #4A4A4D;
    --th-text:          #E6E6E6;
    --th-text-muted:    #A0A0A0;
    --th-white:         #161618;

    /* Page and card surface share the same dark base — no point lightening
       the page above --th-white the way light mode does, because that would
       push the page toward black instead of away from it. */
    --th-page-bg:       var(--th-white);

    --th-success:       #6FCF7A;
    --th-warning:       #F2A93B;
    --th-error:         #F47A77;
    --th-info:          #66B2FF;

    /* Bright amber works on the dark alert-warning surface in dark
       mode, so the "text on tinted bg" variant collapses back to the
       accent. */
    --th-warning-text:  var(--th-warning);

    --th-alert-success-bg: #1E3322;
    --th-alert-warning-bg: #3A2D1A;
    --th-alert-error-bg:   #3A1F1F;
    --th-alert-info-bg:    #1F2A3A;

    --th-highlight-bg:     #1F3540;

    /* Restore solid accent hover skins — dark-mode tokens are already
       light against the dark surface, so the diluted light-mode recipe
       would muddy them. Hover glyph stays --th-white (which resolves to
       the near-black surface in dark mode) so it reads on the bold fill. */
    --th-icon-btn-hover-purple:  var(--th-purple);
    --th-icon-btn-hover-error:   var(--th-error);
    --th-icon-btn-hover-warning: var(--th-warning);
    --th-icon-btn-cancel-rest:   var(--th-warning);
    --th-icon-btn-hover-glyph:   var(--th-white);
}
@media (prefers-color-scheme: dark) {
    :root:not([data-theme]) {
        color-scheme: dark;

        --th-purple:        #C28BE0;
        --th-purple-dark:   #D9B0EE;
        --th-purple-vivid:  var(--th-purple);
        --th-lavender:      #3A2540;
        --th-grey-light:    #2A2A2D;
        --th-grey-border:   #4A4A4D;
        --th-text:          #E6E6E6;
        --th-text-muted:    #A0A0A0;
        --th-white:         #161618;

        --th-page-bg:       var(--th-white);

        --th-success:       #6FCF7A;
        --th-warning:       #F2A93B;
        --th-error:         #F47A77;
        --th-info:          #66B2FF;

        --th-warning-text:  var(--th-warning);

        --th-alert-success-bg: #1E3322;
        --th-alert-warning-bg: #3A2D1A;
        --th-alert-error-bg:   #3A1F1F;

        --th-highlight-bg:     #1F3540;

        /* Mirror the :root[data-theme="dark"] block — solid accents on
           hover instead of the diluted light-mode recipe. */
        --th-icon-btn-hover-purple:  var(--th-purple);
        --th-icon-btn-hover-error:   var(--th-error);
        --th-icon-btn-hover-warning: var(--th-warning);
        --th-icon-btn-cancel-rest:   var(--th-warning);
        --th-icon-btn-hover-glyph:   var(--th-white);
    }
}

* { box-sizing: border-box; }

/* Reserve scrollbar space even when not needed so short pages don't
   shift left when switching to long pages that need a scrollbar. */
html {
    scrollbar-gutter: stable;
}

html, body {
    margin: 0;
    padding: 0;
    background-color: var(--th-page-bg);
    color: var(--th-text);
    font-family: var(--th-font-body);
    font-size: 16px;
    line-height: 1.5;
}

a {
    color: var(--th-purple);
    text-decoration: none;
    text-underline-offset: 2px;
}
a:hover, a:focus { color: var(--th-purple-dark); }
/* External links (open in a new tab) stay underlined at rest so users can
   tell at a glance they leave the app — covers daysheet/crewlist public
   URLs, the event website link, etc. Anchors styled as buttons opt out:
   the button chrome already signals "actionable", and an underline through
   button text reads as broken. */
a[target="_blank"]:not(.th-button) { text-decoration: underline; }

h1, h2, h3, h4 {
    color: var(--th-purple);
    margin: 0 0 var(--th-space-3);
    line-height: 1.2;
}

/* FocusOnNavigate adds tabindex="-1" to the H1 and focuses it after every
   route change so screen readers announce the new page. The browser default
   then paints a focus ring around the heading, which appears every time the
   filter form submits and looks like the title is "selected". Headings
   aren't navigation targets users tab into deliberately, so suppress the
   ring entirely on both :focus and :focus-visible. Screen-reader announce
   behaviour is unaffected — the focus still moves, it's just invisible. */
h1:focus, h2:focus,
h1:focus-visible, h2:focus-visible {
    outline: none;
}

/* Fluid headings — clamp() lets the size scale with viewport width
   between a phone-friendly minimum and the desktop maximum, so we don't
   need a separate mobile media query to shrink them. Body text stays at
   16px to preserve reading rhythm and the iOS focus-zoom guard. */
h1 { font-size: clamp(1.5rem,   4vw + 1rem,     2.25rem); }
h2 { font-size: clamp(1.25rem,  2vw + 0.75rem,  1.75rem); }
h3 { font-size: clamp(1.125rem, 1.5vw + 0.75rem, 1.375rem); }

/* ---------- "System text" no-select rule ----------
   Decoration, not content. Anything in this list is UI chrome that the user
   doesn't need to highlight / copy: nav, headings, section labels, badges,
   form labels, empty-state messages. User-typed data
   (event titles, addresses, table rows, alert message bodies, code blocks)
   stays selectable — those aren't here.

   Buttons (.th-button) already carry user-select:none from their own rules
   below — left in place for clarity even though this block would catch them
   too. */
h1, h2, h3, h4,
.th-app__header,
.th-app__footer,
.th-admin-nav,
.th-page-header__lede,
.th-events__section-heading,
.th-events__past-toggle,
.th-collapse__summary,
.th-empty,
.th-field > label,
.th-field__hint,
.th-field legend,
.th-badge,
.th-table thead {
    user-select: none;
    -webkit-user-select: none;
}

code {
    background: var(--th-lavender);
    padding: 0 var(--th-space-1);
    border-radius: 3px;
    font-size: 0.95em;
}

/* Every <select> is a dropdown — advertise that with a pointer regardless of
   which form scope it sits in (.th-field, .th-form-page, .th-schedule-edit,
   .th-schedule__filter-select, ...). Browser default is the text caret. */
select { cursor: pointer; }

/* Custom dropdown caret for the native-select appearance reset near the end of
   this file. One mid-grey SVG chevron that reads on both the light (--th-white
   #FFF) and dark (#161618) select surfaces, so it needs no per-theme variant. */
:root {
    --th-select-caret: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%23808080' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M1 1.5 6 6.5 11 1.5'/%3E%3C/svg%3E");
}

/* App-wide focus ring for form controls. Every focusable input in TourHub
   gets the same purple outline so the focused field is unambiguous — no
   white/blue browser-default chrome anywhere. Scoped rules elsewhere
   (.th-field, .th-schedule-edit, .th-contacts-row--editing, ...) restate the
   same colour at higher specificity; this rule is the safety net so a new
   form pattern can't accidentally regress to the browser default. Wrapped in
   :where() to keep specificity at (0,1,0) — any existing rule wins, but
   "no rule" still produces the purple ring. */
:where(input, select, textarea):focus {
    outline: 2px solid var(--th-purple);
    outline-offset: 1px;
    border-color: var(--th-purple);
}

/* Same purple-ring safety net for buttons, links, and similar interactive
   elements. Uses :focus-visible so the ring only appears for keyboard
   focus, not mouse clicks — matches browser convention while killing the
   white/blue default ring keyboard users would otherwise see. Outline only
   (no border-color) so the ring doesn't disturb the variant-specific
   borders on .th-button--primary/--secondary/--cancel/--danger. */
:where(button, a, summary, [role="button"]):focus-visible {
    outline: 2px solid var(--th-purple);
    outline-offset: 2px;
}

/* ---------- App shell ---------- */

.th-app {
    min-height: 100vh;
    display: flex;
    flex-direction: column;
}

.th-app__header {
    /* Locked to the top of the viewport — the global nav (Tours, Sign out,
       ThemeToggle, ViewAs) must stay reachable no matter how far the user
       has scrolled. z-index sits above .th-event-chrome (z-index:60) so the
       event/tour chrome scrolls *under* the page header before sticking
       just below it via --th-header-h. */
    position: sticky;
    top: 0;
    z-index: 100;
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: var(--th-space-3) var(--th-space-4);
    border-bottom: 1px solid var(--th-grey-border);
    background: var(--th-white);
}

.th-app__brand {
    text-decoration: none;
    color: var(--th-purple);
}

.th-app__brand-mark {
    font-weight: 700;
    font-size: 1.25rem;
    letter-spacing: 0.02em;
}

.th-app__nav {
    display: flex;
    gap: var(--th-space-3);
    align-items: center;
}

.th-app__nav a {
    text-decoration: none;
    min-height: var(--th-tap-target);
    display: inline-flex;
    align-items: center;
}

/* ---------- Hamburger drawer wrapper ----------
   .th-app__menu groups the toggle button and the primary <nav>. On
   desktop the button is hidden and the nav renders inline. Below
   --th-bp-md the button becomes a 44×44 trigger and JS toggles
   .th-app__nav--open to dock the nav as a full-width drawer below
   the header. App.razor's enhancedload listener closes any open
   drawer after route changes so the menu doesn't follow the user
   across pages. */
.th-app__menu {
    display: flex;
    align-items: center;
    gap: var(--th-space-3);
}
.th-app__menu-trigger {
    /* Desktop: hidden. The mobile media query bumps this to a
       visible icon button. */
    display: none;
    background: var(--th-white);
    border: 1px solid var(--th-grey-border);
    border-radius: 14px;
    width: var(--th-tap-target);
    height: var(--th-tap-target);
    align-items: center;
    justify-content: center;
    color: var(--th-text);
    cursor: pointer;
    padding: 0;
}
.th-app__menu-svg { width: 22px; height: 22px; display: block; }

/* ---------- Theme dropdown (system / light / dark) ---------- */
.th-theme { position: relative; }

.th-theme__current::-webkit-details-marker { display: none; }
.th-theme__current::marker { content: ""; }

.th-theme__current {
    list-style: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--th-tap-target);
    height: var(--th-tap-target);
    border: 1px solid var(--th-grey-border);
    border-radius: 14px;
    background: var(--th-white);
    color: var(--th-text);
    cursor: pointer;
}
.th-theme__current:hover { border-color: var(--th-purple); color: var(--th-purple); }
.th-theme__current:focus {
    outline: 2px solid var(--th-purple);
    outline-offset: 1px;
    border-color: var(--th-purple);
}
.th-theme[open] > .th-theme__current { border-color: var(--th-purple); }

.th-theme__svg { width: 22px; height: 22px; display: block; }

/* Trigger icon matches the EFFECTIVE theme, not the configured one. Default
   state (no data-theme attribute = System mode) falls through to whatever the
   OS prefers via the media query below. Explicit data-theme overrides. */
.th-theme__current .th-theme__icon { display: none; }
.th-theme__current .th-theme__icon--light { display: inline-flex; }
@media (prefers-color-scheme: dark) {
    :root:not([data-theme]) .th-theme__current .th-theme__icon--light { display: none; }
    :root:not([data-theme]) .th-theme__current .th-theme__icon--dark  { display: inline-flex; }
}
:root[data-theme="light"] .th-theme__current .th-theme__icon--light { display: inline-flex; }
:root[data-theme="light"] .th-theme__current .th-theme__icon--dark  { display: none; }
:root[data-theme="dark"]  .th-theme__current .th-theme__icon--light { display: none; }
:root[data-theme="dark"]  .th-theme__current .th-theme__icon--dark  { display: inline-flex; }

.th-theme__menu {
    position: absolute;
    top: calc(100% + 4px);
    right: 0;
    z-index: 50;
    display: flex;
    flex-direction: column;
    min-width: 11rem;
    background: var(--th-white);
    border: 1px solid var(--th-grey-border);
    border-radius: 16px;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
    padding: var(--th-space-1);
}

.th-theme__option {
    display: flex;
    align-items: center;
    gap: var(--th-space-3);
    min-height: var(--th-tap-target);
    padding: 0 var(--th-space-3);
    background: transparent;
    border: 0;
    border-radius: 10px;
    color: var(--th-text);
    font: inherit;
    text-align: left;
    cursor: pointer;
}
.th-theme__option:hover, .th-theme__option:focus {
    background: var(--th-lavender);
    outline: none;
}

/* Highlight the option matching the currently-active theme (same logic as
   trigger icons: no attribute => system). */
:root:not([data-theme]) .th-theme__option--system { background: var(--th-lavender); font-weight: 600; }
:root[data-theme="light"] .th-theme__option--light { background: var(--th-lavender); font-weight: 600; }
:root[data-theme="dark"]  .th-theme__option--dark  { background: var(--th-lavender); font-weight: 600; }

.th-theme__option-icon { display: inline-flex; flex: 0 0 auto; }
.th-theme__label { white-space: nowrap; }

/* ---------- User-menu dropdown (profile / admin / sign out) ----------
   Mirrors the .th-theme popover: <details> with a pill summary on the
   right of the header, and an absolutely-positioned menu below. App.razor's
   document-level click + Escape handlers close any open <details>, so this
   menu inherits dismiss behaviour without per-component JS. */
.th-usermenu { position: relative; }

.th-usermenu__current::-webkit-details-marker { display: none; }
.th-usermenu__current::marker { content: ""; }

.th-usermenu__current {
    list-style: none;
    display: inline-flex;
    align-items: center;
    gap: var(--th-space-2);
    min-height: var(--th-tap-target);
    padding: 0 var(--th-space-3);
    border: 1px solid var(--th-grey-border);
    border-radius: 14px;
    background: var(--th-white);
    color: var(--th-text);
    cursor: pointer;
}
.th-usermenu__current:hover { border-color: var(--th-purple); color: var(--th-purple); }
.th-usermenu__current:focus {
    outline: 2px solid var(--th-purple);
    outline-offset: 1px;
    border-color: var(--th-purple);
}
.th-usermenu[open] > .th-usermenu__current { border-color: var(--th-purple); }

.th-usermenu__name {
    font-weight: 500;
    white-space: nowrap;
}

.th-usermenu__chevron {
    display: inline-flex;
    align-items: center;
    color: var(--th-text-muted);
    transition: transform 120ms ease;
}
.th-usermenu[open] .th-usermenu__chevron { transform: rotate(180deg); }
.th-usermenu__svg { width: 14px; height: 14px; display: block; }

.th-usermenu__menu {
    position: absolute;
    top: calc(100% + 4px);
    right: 0;
    z-index: 50;
    display: flex;
    flex-direction: column;
    min-width: 11rem;
    background: var(--th-white);
    border: 1px solid var(--th-grey-border);
    border-radius: 16px;
    /* Quiet popover shadow — these per-component menus sit next to
       inline nav items, so a heavy drop reads as visually mismatched
       against the rest of the app's restrained treatment. The mobile
       drawer (.th-app__nav--open) is where the heavier lift lives. */
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.10);
    padding: var(--th-space-1);
}

.th-usermenu__option {
    display: flex;
    align-items: center;
    gap: var(--th-space-3);
    min-height: var(--th-tap-target);
    padding: 0 var(--th-space-3);
    background: transparent;
    border: 0;
    border-radius: 10px;
    color: var(--th-text);
    font: inherit;
    text-align: left;
    text-decoration: none;
    cursor: pointer;
}
.th-usermenu__option:hover, .th-usermenu__option:focus {
    background: var(--th-lavender);
    color: var(--th-purple);
    outline: none;
}

/* Sign-out tints muted on hover — a confirm-with-click action, not a
   destructive primary button, so the warning stays gentle. */
.th-usermenu__option--danger { color: var(--th-text); }

/* Tours dropdown — same chrome as the user menu (it doubles up the
   .th-usermenu class for that). Tours-specific bits:
   - scroll cap so a long tour list doesn't bleed past the viewport
     (mobile drawer reset lives in the mobile block — drawer scrolls
     on its own),
   - the "All tours" header link sits at the top, painted bold +
     purple with an arrow so it reads as a section link rather than
     just another tour,
   - a hairline below the header separating it from the tour rows. */
.th-toursmenu__menu {
    max-height: 60vh;
    overflow-y: auto;
}
.th-toursmenu__overview {
    /* Bold weight + the hairline divider below carry the "section
       link" visual on their own. Avoid purple here — that's the
       active-tab colour app-wide, so a purple "All tours" reads as
       "you're on this page" rather than "navigate to this page". */
    font-weight: 600;
}
.th-toursmenu__divider {
    height: 1px;
    background: var(--th-grey-border);
    margin: var(--th-space-1) var(--th-space-3);
    opacity: 0.5;
}

.th-lang { margin: 0; }

.th-lang__details {
    position: relative;
    /* No native marker — the flag itself signals the current language. */
}
.th-lang__details > .th-lang__current::-webkit-details-marker { display: none; }
.th-lang__details > .th-lang__current::marker { content: ""; }

.th-lang__current {
    list-style: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: var(--th-tap-target);
    min-width: var(--th-tap-target);
    padding: 0 var(--th-space-2);
    border: 1px solid var(--th-grey-border);
    border-radius: 14px;
    background: var(--th-white);
    cursor: pointer;
}
.th-lang__current:hover { border-color: var(--th-purple); }
.th-lang__current:focus {
    outline: 2px solid var(--th-purple);
    outline-offset: 1px;
    border-color: var(--th-purple);
}
.th-lang__details[open] > .th-lang__current {
    border-color: var(--th-purple);
}

.th-lang__menu {
    position: absolute;
    top: calc(100% + 4px);
    right: 0;
    z-index: 50;
    display: flex;
    flex-direction: column;
    background: var(--th-white);
    border: 1px solid var(--th-grey-border);
    border-radius: 16px;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
    padding: var(--th-space-1);
}

.th-lang__option {
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: var(--th-tap-target);
    min-width: var(--th-tap-target);
    padding: 0 var(--th-space-2);
    background: transparent;
    border: 0;
    border-radius: 10px;
    color: var(--th-text);
    font: inherit;
    cursor: pointer;
}
.th-lang__option:hover, .th-lang__option:focus {
    background: var(--th-lavender);
    outline: none;
}
.th-lang__option--current {
    background: var(--th-lavender);
}

.th-lang__flag {
    /* 28px-ish wide, height auto via native aspect ratio. The 1px hairline
       gives the flag a defined edge against the white background, which keeps
       Sweden's blue / Norway's red from feeling like they bleed into the chrome. */
    width: 28px;
    height: auto;
    border: 1px solid var(--th-grey-border);
    border-radius: 3px;
    display: block;
    flex: 0 0 auto;
}
.th-lang__current .th-lang__flag { width: 26px; }

.th-app__main {
    flex: 1 1 auto;
    width: 100%;
    max-width: 56rem;
    margin: 0 auto;
    padding: var(--th-space-3) var(--th-space-4) var(--th-space-6);
    outline: none;
}

.th-app__footer {
    border-top: 1px solid var(--th-grey-light);
    color: var(--th-text-muted);
    text-align: center;
    /* Tight vertical padding so the footer stays a slim credit strip;
       horizontal padding keeps the content off the page edges. */
    padding: var(--th-space-2) var(--th-space-3);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--th-space-1);
}

/* Footer link opts out of the global a[target="_blank"] underline —
   it's a credit line, not a destination users need to spot from across
   the page. Attribute selector matches the global rule's specificity
   so this override actually wins. */
.th-app__footer a {
    color: inherit;
    text-decoration: none;
    text-underline-offset: 2px;
}

.th-app__footer a:hover {
    text-decoration: underline;
    text-decoration-color: currentColor;
}

.th-app__footer-build {
    font-size: 0.75em;
    opacity: 0.6;
}

/* ---------- Hero / form pages ---------- */

.th-hero { max-width: 40rem; }
.th-hero__lede {
    font-size: clamp(1.05rem, 1vw + 0.75rem, 1.25rem);
    color: var(--th-text-muted);
    margin: 0 0 var(--th-space-4);
}

.th-form-page {
    max-width: 28rem;
    margin: 0 auto;
}

.th-form-page__links { margin-top: var(--th-space-4); }

/* ---------- Buttons & form controls ---------- */

.th-button {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: var(--th-tap-target);
    padding: 0 var(--th-space-4);
    border: 1px solid var(--th-purple);
    border-radius: var(--th-radius);
    background: var(--th-white);
    color: var(--th-purple);
    font: inherit;
    text-decoration: none;
    cursor: pointer;
    margin-right: var(--th-space-2);
    /* Double-clicking a button can accidentally select its label, which on
       mobile pops up the copy/paste handle. Buttons aren't text — disable
       selection across the board. */
    user-select: none;
    -webkit-user-select: none;
}
/* The :where(:not(...)) qualifier scopes every variant's :hover/:focus
   skin so it never paints on a disabled or in-flight button. :where()
   contributes zero specificity, so the rules stay at the same weight
   as before — they just don't match when the button is inert. Without
   this, a primary button mid-submit kept the purple-dark hover paint
   if the cursor happened to be over it when the click landed.

   Bare .th-button (no modifier) is the "neutral / ambient" tier — Back,
   Cancel, Edit roles, etc. At rest it's a purple-outlined pill on the
   page surface; hover fills the pill with solid purple. The previous
   black-on-white-with-purple-text-on-hover read as inert chrome at rest
   (especially in dark mode where the dark fill + grey border just
   blended into the page). */
.th-button:where(:not(:disabled):not(.th-button--busy)):hover,
.th-button:where(:not(:disabled):not(.th-button--busy)):focus {
    background: var(--th-purple);
    border-color: var(--th-purple);
    color: var(--th-white);
}

.th-button--primary {
    background: var(--th-purple);
    border-color: var(--th-purple);
    color: var(--th-white);
}
.th-button--primary:where(:not(:disabled):not(.th-button--busy)):hover,
.th-button--primary:where(:not(:disabled):not(.th-button--busy)):focus {
    background: var(--th-purple-dark);
    border-color: var(--th-purple-dark);
    color: var(--th-white);
}

/* Coloured-button variants — each uses a tinted background and a subtle
   *darker* border (color-mix toward black) so the button has a defining
   edge without the border out-saturating the fill. The mix ratios are
   small (10–15%) so the border reads as a soft shadow of the surface
   rather than an outline of its own. */

/* Secondary — second-tier actions (Back, Reopen, optional toggles) that
   should read as actionable but not compete with the primary Save/Create. */
.th-button--secondary {
    background: var(--th-lavender);
    border-color: color-mix(in srgb, var(--th-lavender) 85%, black);
    color: var(--th-purple);
}
.th-button--secondary:where(:not(:disabled):not(.th-button--busy)):hover,
.th-button--secondary:where(:not(:disabled):not(.th-button--busy)):focus {
    background: var(--th-purple);
    border-color: color-mix(in srgb, var(--th-purple) 85%, black);
    color: var(--th-white);
}

/* Cancel — dismiss/abort a draft form (not delete persisted data). Soft
   amber at rest, solid amber on hover. Pair with .th-button--primary on
   submit rows so destructive vs. constructive intent reads at a glance. */
.th-button--cancel {
    background: var(--th-alert-warning-bg);
    border-color: color-mix(in srgb, var(--th-alert-warning-bg) 85%, black);
    color: var(--th-warning-text);
}
.th-button--cancel:where(:not(:disabled):not(.th-button--busy)):hover,
.th-button--cancel:where(:not(:disabled):not(.th-button--busy)):focus {
    background: var(--th-warning);
    border-color: color-mix(in srgb, var(--th-warning) 85%, black);
    color: var(--th-white);
}

/* Danger — delete persisted data. Solid red at rest so the destructive
   intent reads unambiguously; hover deepens via a brightness pass so the
   button still feels interactive without changing hue. */
.th-button--danger {
    background: var(--th-error);
    border-color: color-mix(in srgb, var(--th-error) 85%, black);
    color: var(--th-white);
}
.th-button--danger:where(:not(:disabled):not(.th-button--busy)):hover,
.th-button--danger:where(:not(:disabled):not(.th-button--busy)):focus {
    background: var(--th-error);
    border-color: color-mix(in srgb, var(--th-error) 70%, black);
    color: var(--th-white);
    filter: brightness(0.9);
}

/* Dark-mode: visible button outlines. The default light-mode border
   formula (`color-mix(... 85%, black)`) collapses into the bg when the
   bg is already dark — secondary lavender, cancel amber, the icon-btn
   alert surfaces — so the buttons read as borderless pills against the
   dark page. Bright dark-mode accents (--th-purple / --th-warning /
   --th-error) outline cleanly on those dark fills; light-bg variants
   (.th-button--primary, .th-button--danger) get a darkened accent so
   the border still sits above the pill. */
:root[data-theme="dark"] .th-button--secondary,
:root[data-theme="dark"] .th-icon-btn--primary,
:root[data-theme="dark"] .th-icon-btn--secondary {
    border-color: var(--th-purple);
}
:root[data-theme="dark"] .th-button--cancel,
:root[data-theme="dark"] .th-icon-btn--cancel {
    border-color: var(--th-warning);
}
:root[data-theme="dark"] .th-icon-btn--danger {
    border-color: var(--th-error);
}
:root[data-theme="dark"] .th-button--primary {
    border-color: color-mix(in srgb, var(--th-purple) 70%, black);
}
:root[data-theme="dark"] .th-button--danger {
    border-color: color-mix(in srgb, var(--th-error) 70%, black);
}
@media (prefers-color-scheme: dark) {
    :root:not([data-theme]) .th-button--secondary,
    :root:not([data-theme]) .th-icon-btn--primary,
    :root:not([data-theme]) .th-icon-btn--secondary {
        border-color: var(--th-purple);
    }
    :root:not([data-theme]) .th-button--cancel,
    :root:not([data-theme]) .th-icon-btn--cancel {
        border-color: var(--th-warning);
    }
    :root:not([data-theme]) .th-icon-btn--danger {
        border-color: var(--th-error);
    }
    :root:not([data-theme]) .th-button--primary {
        border-color: color-mix(in srgb, var(--th-purple) 70%, black);
    }
    :root:not([data-theme]) .th-button--danger {
        border-color: color-mix(in srgb, var(--th-error) 70%, black);
    }
}

/* Disabled + in-flight submit state. The global submit handler in
   App.razor tags submitting buttons with --busy on top of the disabled
   attribute so they stay in the layout while inert. A disabled button
   that still lifts on hover invites a second click and falsely signals
   "still clickable" when the form is incomplete — kill pointer events
   on both. Click activation is already blocked by the disabled
   attribute; this kills the hover/focus visual too. Mirrored on
   .th-icon-btn for inline-row Save/Remove buttons.

   The :hover and :focus duplicates exist because variant rules like
   `.th-button--primary:hover` (specificity 0,2,0) otherwise out-rank
   `.th-button--busy` (0,1,0) for background/border/color — meaning a
   button mid-submit still painted its hover skin if the cursor had
   landed on it before the busy class arrived. Repeating the selector
   with `:hover, :focus` keeps the inert visual locked in. */
.th-button:disabled,
.th-button:disabled:hover,
.th-button:disabled:focus,
.th-icon-btn:disabled,
.th-icon-btn:disabled:hover,
.th-icon-btn:disabled:focus,
.th-button--busy,
.th-button--busy:hover,
.th-button--busy:focus,
.th-icon-btn--busy,
.th-icon-btn--busy:hover,
.th-icon-btn--busy:focus {
    pointer-events: none;
    cursor: not-allowed;
    filter: none;
}

/* All variants disabled — fade own resting skin. Each variant carries
   a tinted resting state; halving its opacity preserves the hue (so
   intent stays legible) while signalling "not right now". A previous
   "inactive, not faded" lavender-fill rule for --primary was dropped
   because disabled-primary then read identically to an active
   --secondary icon (both lavender + purple text). */
.th-button--primary:disabled,   .th-button--primary.th-button--busy,
.th-button--secondary:disabled, .th-button--secondary.th-button--busy,
.th-button--cancel:disabled,    .th-button--cancel.th-button--busy,
.th-button--danger:disabled,    .th-button--danger.th-button--busy,
.th-icon-btn:disabled,            .th-icon-btn.th-icon-btn--busy,
.th-icon-btn--primary:disabled,   .th-icon-btn--primary.th-icon-btn--busy,
.th-icon-btn--secondary:disabled, .th-icon-btn--secondary.th-icon-btn--busy,
.th-icon-btn--cancel:disabled,    .th-icon-btn--cancel.th-icon-btn--busy,
.th-icon-btn--danger:disabled,    .th-icon-btn--danger.th-icon-btn--busy {
    opacity: 0.5;
}

.th-field {
    display: flex;
    flex-direction: column;
    gap: var(--th-space-2);
    margin-bottom: var(--th-space-4);
}

.th-field--inline { flex-direction: row; align-items: center; }

/* Lay two related fields side-by-side on wider viewports (e.g. Date + End
   date on /events/new). Wraps to stacked rows below ~440px so each input
   still has enough room. */
.th-field-row {
    display: flex;
    flex-wrap: wrap;
    gap: var(--th-space-3);
    margin-bottom: var(--th-space-4);
}
.th-field-row > .th-field {
    flex: 1 1 12rem;
    margin-bottom: 0;
}

.th-field label { font-weight: 600; }

/* Inline "*" marker on a required field label. Uses the brand purple so
   it reads as a deliberate UI affordance rather than a danger/error
   signal — the form only errors out on submit if the answer is blank. */
.th-required {
    color: var(--th-purple);
    margin-left: 0.15rem;
    font-weight: 700;
}

.th-field input[type="text"],
.th-field input[type="email"],
.th-field input[type="password"],
.th-field input[type="tel"],
.th-field input[type="url"],
.th-field input[type="number"],
.th-field input[type="search"],
.th-field input[type="date"],
.th-field input[type="time"],
.th-field input:not([type]),
.th-field select,
.th-field textarea {
    /* Explicit width + box-sizing keeps the row consistent regardless of the
       field's input type — browsers render password / number / time inputs with
       slightly different default metrics, and min-height alone wasn't enough to
       force them to the same visual height as text/email. Selects are included
       here so InputSelect renders at the same height + width as the text inputs
       — without this rule selects fall back to browser-default chrome (shorter,
       no rounded corners, native arrow). input:not([type]) covers Blazor's
       <InputText> which renders an <input> WITHOUT a type attribute when no
       type is passed (browsers default to text, but the [type="text"] selector
       requires the literal attribute and so wouldn't match otherwise). */
    box-sizing: border-box;
    width: 100%;
    height: var(--th-tap-target);
    padding: 0 var(--th-space-3);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    font: inherit;
    line-height: 1.2;
    background: var(--th-white);
    color: var(--th-text);
}

/* Labels are always associated with an input via `for=` in this app, so the
   whole label is clickable and should advertise that with a pointer. */
.th-field label { cursor: pointer; }

.th-field textarea {
    /* Override the single-line height the shared rule sets for inputs — the
       textarea is multi-line. Keep everything else (border, radius, theme
       colors) inherited from the shared rule above. */
    height: auto;
    min-height: calc(var(--th-tap-target) * 2);
    padding: var(--th-space-2) var(--th-space-3);
    line-height: 1.4;
    resize: vertical;
}

.th-field input:focus,
.th-field select:focus,
.th-field textarea:focus {
    outline: 2px solid var(--th-purple);
    outline-offset: 1px;
    border-color: var(--th-purple);
}

/* Compact density for long, mostly-keyboard-driven forms (e.g. /events/{id}?edit=1).
   Trims input height, vertical gaps, and textarea minimum so the whole form fits
   more on one screen. :where() wrapper keeps specificity at (0,2,1) — same as the
   base .th-field input rule — so existing field-level overrides (notably the
   .th-map-field__coord double-class hack at ~line 1999) still win and the
   inline lat/lng inputs keep their custom sizing. */
:where(.th-form--compact) .th-field,
:where(.th-form--compact) .th-field-row {
    margin-bottom: var(--th-space-3);
}
:where(.th-form--compact) .th-field {
    gap: var(--th-space-1);
}
:where(.th-form--compact) .th-field input[type="text"],
:where(.th-form--compact) .th-field input[type="email"],
:where(.th-form--compact) .th-field input[type="password"],
:where(.th-form--compact) .th-field input[type="tel"],
:where(.th-form--compact) .th-field input[type="url"],
:where(.th-form--compact) .th-field input[type="number"],
:where(.th-form--compact) .th-field input[type="search"],
:where(.th-form--compact) .th-field input[type="date"],
:where(.th-form--compact) .th-field input[type="time"],
:where(.th-form--compact) .th-field input:not([type]),
:where(.th-form--compact) .th-field select {
    height: 2.25rem;
    padding: 0 var(--th-space-2);
}
:where(.th-form--compact) .th-field textarea {
    min-height: 4.5rem;
    padding: var(--th-space-1) var(--th-space-2);
}

.th-checkbox {
    display: inline-flex;
    align-items: center;
    gap: var(--th-space-2);
    font-weight: 400;
    cursor: pointer;
    /* Drag-selecting the label text feels broken — the obvious affordance
       is to click and toggle, so make label text non-selectable. The
       checkbox itself stays interactive; cursor:pointer above signals
       the click target. */
    user-select: none;
    -webkit-user-select: none;
}

/* Retired role — sits in the picker only because the contact still
   carries it from a time when the role existed. Italic + faded so it
   reads as historical, but it stays interactive so the editor can drop
   it deliberately by unchecking. */
.th-checkbox--retired { font-style: italic; opacity: 0.75; }

.validation-message { color: var(--th-error); font-size: 0.95em; }

/* ---------- Alerts ---------- */

.th-alert {
    border: 1px solid var(--th-grey-border);
    border-left-width: 4px;
    border-radius: var(--th-radius);
    padding: var(--th-space-3);
    margin-bottom: var(--th-space-3);
    background: var(--th-grey-light);
}

.th-alert--error  { border-left-color: var(--th-error);  background: var(--th-alert-error-bg); }
.th-alert--warning { border-left-color: var(--th-warning); background: var(--th-alert-warning-bg); }
.th-alert--success { border-left-color: var(--th-success); background: var(--th-alert-success-bg); }
.th-alert--info    { border-left-color: var(--th-info);    background: var(--th-alert-info-bg); }

/* ---------- Toasts ----------
   Server-rendered acknowledgement that docks to the bottom-left of the
   viewport. The markup lives inside the page wherever _successMessage is
   set, but `position: fixed` lifts it to the corner. wwwroot/js/toast.js
   wires the close button and auto-dismisses non-sticky toasts after 4s. */

.th-toast {
    position: fixed;
    left: 16px;
    bottom: 16px;
    z-index: 9000;
    min-width: 240px;
    max-width: min(420px, calc(100vw - 32px));
    padding: var(--th-space-3);
    border-radius: var(--th-radius);
    border: 1px solid var(--th-grey-border);
    border-left-width: 4px;
    display: flex;
    gap: var(--th-space-2);
    align-items: flex-start;
    box-shadow: 0 6px 24px rgba(0,0,0,.18);
    transform: translateX(calc(-100% - 24px));
    opacity: 0;
    animation: th-toast-in 240ms cubic-bezier(.2,.7,.2,1) forwards;
}

.th-toast--success { border-left-color: var(--th-success); background: var(--th-alert-success-bg); }
.th-toast--warning { border-left-color: var(--th-warning); background: var(--th-alert-warning-bg); }
.th-toast--error   { border-left-color: var(--th-error);   background: var(--th-alert-error-bg); }
.th-toast--info    { border-left-color: var(--th-info);    background: var(--th-alert-info-bg); }

.th-toast__msg { flex: 1; line-height: 1.4; }

.th-toast__close {
    background: transparent;
    border: 0;
    cursor: pointer;
    font-size: 1.25rem;
    line-height: 1;
    padding: 0 var(--th-space-1);
    color: inherit;
    opacity: .7;
}
.th-toast__close:hover { opacity: 1; }
/* Purple focus ring is already supplied globally by the
   :where(button, …):focus-visible rule near the top of this file. */

.th-toast--leaving { animation: th-toast-out 200ms ease forwards; }

@keyframes th-toast-in {
    from { transform: translateX(calc(-100% - 24px)); opacity: 0; }
    to   { transform: translateX(0); opacity: 1; }
}

@keyframes th-toast-out {
    from { transform: translateX(0); opacity: 1; }
    to   { transform: translateX(calc(-100% - 24px)); opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
    .th-toast,
    .th-toast--leaving { animation: none; transform: translateX(0); opacity: 1; }
}

/* ---------- Spaces list ---------- */

.th-spaces { list-style: none; padding: 0; margin: 0; display: grid; gap: var(--th-space-3); }

.th-spaces__item {
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    display: flex;
    align-items: stretch;
    overflow: hidden; /* keeps the inner-link hover/lavender from spilling past the border-radius */
}

.th-spaces__link {
    flex: 1 1 auto;
    display: flex;
    flex-direction: column;
    padding: var(--th-space-3) var(--th-space-4);
    text-decoration: none;
    color: var(--th-text);
    min-height: var(--th-tap-target);
}
.th-spaces__link:hover, .th-spaces__link:focus {
    background: var(--th-lavender);
    color: var(--th-purple);
}

/* Trailing action button (e.g. "Edit") rendered as a sibling of .th-spaces__link.
   Vertical divider on the left, no rounded corners on its left side so it
   meshes with the link's rounded item corners. */
.th-spaces__action {
    display: inline-flex;
    align-items: center;
    padding: 0 var(--th-space-4);
    border: 0;
    border-left: 1px solid var(--th-grey-border);
    border-radius: 0;
    margin: 0;
    background: var(--th-white);
    color: var(--th-text);
    text-decoration: none;
}
.th-spaces__action:hover, .th-spaces__action:focus {
    background: var(--th-lavender);
    color: var(--th-purple);
}
.th-spaces__action svg { width: 18px; height: 18px; }

.th-spaces__name { font-size: 1.125rem; }
.th-spaces__meta { color: var(--th-text-muted); font-size: 0.95em; }

.th-empty { color: var(--th-text-muted); }

.th-app__brand-sub {
    display: block;
    color: var(--th-text-muted);
    font-size: 0.75rem;
    text-transform: uppercase;
    letter-spacing: 0.06em;
}

/* ---------- Page header (above content) ---------- */

.th-page-header {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: var(--th-space-3);
    margin-bottom: var(--th-space-4);
    flex-wrap: wrap;
}

.th-page-header h1 { margin: 0; }
.th-page-header__lede { color: var(--th-text-muted); margin: var(--th-space-1) 0 0; }
/* Right-align everything in the row. Default justify-content is
   space-between, which puts a lone child on the left. */
.th-page-header--end { justify-content: flex-end; }

/* Tours header — the Add-tour <details> sits at top-right of the row
   and opens its body as a floating panel anchored under the trigger, so
   the form doesn't push the tours list down when expanded. */
.th-tours__add { position: relative; }
.th-tours__add-trigger::-webkit-details-marker { display: none; }
.th-tours__add-trigger::marker { content: ""; }
.th-tours__add-trigger { list-style: none; cursor: pointer; }
.th-tours__add-body {
    position: absolute;
    top: calc(100% + var(--th-space-1));
    right: 0;
    z-index: 20;
    width: min(420px, 90vw);
    background: var(--th-white);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.10);
    padding: var(--th-space-3);
}

.th-form-page select {
    min-height: var(--th-tap-target);
    padding: var(--th-space-2) var(--th-space-3);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-white);
    font: inherit;
    color: var(--th-text);
}

/* ---------- Event list ---------- */

.th-events { list-style: none; padding: 0; margin: 0; display: grid; gap: var(--th-space-3); }

/* A subtle raised surface (--th-grey-light) lifts the cards off the page —
   light grey in light mode, a slightly lifted dark grey in dark mode. Hidden
   drafts opt out below so they recede onto the bare page surface.
   overflow:hidden clips the inner link's background (the lavender wash on
   current rows, and the hover/focus fill) to the card's rounded corners —
   without it the link paints square corners that poke past the rounded
   border, so the active highlight looked like it "missed" the corners. */
.th-events__item {
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    overflow: hidden;
    background: var(--th-grey-light);
}

.th-events__link {
    display: grid;
    grid-template-columns: auto 1fr;
    gap: var(--th-space-3);
    align-items: center;
    padding: var(--th-space-3) var(--th-space-4);
    text-decoration: none;
    color: var(--th-text);
    min-height: var(--th-tap-target);
    position: relative;
}

/* Hover-paint is gated to true pointer devices: phones report touch
   taps as a sticky :hover that lingers after navigation, leaving the
   tapped row washed in lavender as if it were selected. Keyboard users
   still get the highlight via :focus-visible. */
@media (hover: hover) {
    .th-events__link:hover {
        background: var(--th-lavender);
        color: var(--th-purple);
    }
}
.th-events__link:focus-visible {
    background: var(--th-lavender);
    color: var(--th-purple);
}

/* Status chip pins to the card's top-right corner so it reads as a
   lifecycle marker without eating into the title's row. Body reserves
   matching right-padding only when a badge is actually present, so
   confirmed rows still get the full title width. */
.th-events__link > .th-badge {
    position: absolute;
    top: var(--th-space-2);
    right: var(--th-space-2);
}
.th-events__link:has(> .th-badge) .th-events__body {
    padding-right: 5.5rem;
}

/* Phone-class viewports — tighten the column gap and link padding so
   the title (in body's 1fr) gets as much horizontal room as possible. */
@media (max-width: 480px) {
    .th-events__link {
        gap: var(--th-space-2);
        padding-inline: var(--th-space-3);
    }
}

.th-events__date {
    display: flex;
    flex-direction: column;
    align-items: center;
    min-width: 4rem;
    line-height: 1.1;
}
.th-events__day { font-size: 1.6rem; font-weight: 700; color: var(--th-purple); }
.th-events__month { font-size: 0.75rem; color: var(--th-text-muted); text-transform: uppercase; }

/* Live cards (Now / Upcoming — not past, not a hidden draft) get a
   full-contrast month + year (white in dark mode) while the day stays purple.
   Past and hidden rows keep the muted month so they recede. Year inherits the
   month colour. */
.th-events__item:not(.th-events__item--past):not(.th-events__item--hidden) .th-events__month {
    color: var(--th-text);
}

.th-events__body { display: flex; flex-direction: column; gap: var(--th-space-1); }
.th-events__title { font-size: 1.05rem; }
.th-events__meta { color: var(--th-text-muted); font-size: 0.95em; }
.th-events__type { color: var(--th-purple); font-weight: 600; }

/* Section headings on the events list (Now / Upcoming / Past). */
.th-events__section-heading {
    font-size: 0.85rem;
    font-weight: 700;
    color: var(--th-text-muted);
    text-transform: uppercase;
    letter-spacing: 0.05em;
    margin: var(--th-space-5) 0 var(--th-space-2);
}
.th-events__section-heading:first-of-type { margin-top: var(--th-space-3); }
.th-events__past > .th-events__past-toggle { margin-top: 0; }

/* "Only events I'm booked on" toggle — a quiet link above the list; the
   active state is carried by the shared .th-schedule__filter-banner. */
/* Toolbar row under the tab strip. Web: filter toggle hugs the right (the
   "New event" button lives in the tab strip). Mobile overrides below flip it
   to filter-left / New-event-right on one line. */
.th-events__toolbar {
    display: flex;
    align-items: center;
    gap: var(--th-space-2);
    justify-content: flex-end;
    margin: 0 0 var(--th-space-1);
}
.th-events__filter-link {
    font-size: 0.85em;
    line-height: 1.2;
}
/* "New event" shows in this toolbar on phones only — hidden on web, where the
   tab-strip action carries it. */
.th-events__new-mobile { display: none; }
/* On web, a toolbar holding only the (hidden) mobile button has nothing to
   show — collapse it so editors without bookings don't get an empty gap. */
@media (min-width: 641px) {
    .th-events__toolbar:not(:has(.th-events__filter-link)) { display: none; }
}

/* Active-filter row: the minimal "Showing only your events" banner plus, on
   mobile, the New event button on the same line (so the banner doesn't push it
   onto its own line). On web New event is hidden here (it's in the tab strip),
   so the banner flexes to fill the row at full width — same as before. */
.th-events__banner-row {
    display: flex;
    align-items: center;
    gap: var(--th-space-2);
    margin-bottom: var(--th-space-3);
}
.th-events__banner-row .th-schedule__filter-banner {
    flex: 1 1 auto;
    margin-bottom: 0;
}

/* Currently-running events get the strongest visual weight so they're the
   first thing the eye lands on — purple border, soft lavender wash, and a
   subtle glow ring. */
.th-events__item--current {
    border-color: var(--th-purple);
    box-shadow: 0 0 0 2px var(--th-lavender);
}
.th-events__item--current .th-events__link {
    background: var(--th-lavender);
}
.th-events__item--current .th-events__day { color: var(--th-purple-dark); }

/* Hidden (draft) events recede onto the bare page surface (no raised grey)
   and re-use the "Hidden" status chip. The date is also greyed so the row
   reads as "not live" without washing out the title. */
.th-events__item--hidden {
    background: transparent;
}
.th-events__item--hidden .th-events__day,
.th-events__item--hidden .th-events__title {
    color: var(--th-text-muted);
}

/* Phone-class layout for the events toolbar + cards. */
@media (max-width: 640px) {
    /* Filter toggle on the left, "New event" pinned to the right — same line.
       The tab strip is a horizontal scroller here, so the button drops down
       to this toolbar instead of crowding the tabs. */
    .th-events__toolbar { justify-content: flex-start; }
    .th-events__toolbar .th-events__new-mobile,
    .th-events__banner-row .th-events__new-mobile {
        display: inline-flex;
        width: auto;
        /* margin-left:auto pins it right whether or not a filter link / banner
           is present; the explicit 0s override the global mobile slab margins. */
        margin: 0 0 0 auto;
        min-height: var(--th-space-7, 2rem);
        padding: var(--th-space-1) var(--th-space-3);
        white-space: nowrap;
        font-size: 0.9em;
    }
    /* New event lives in this toolbar on phones, not the tab-strip sub-menu,
       so drop the tab-strip action there. The descendant selector outranks the
       base `.th-event-tabs__actions { display:inline-flex }` rule, which is
       declared later in the file and would otherwise win at equal specificity. */
    .th-event-tabs .th-event-tabs__actions { display: none; }
    /* Slimmer cards vertically — trim the row padding and the oversized day
       number so each Now/Upcoming card is less tall. min-height keeps the
       tap target comfortable; past cards have their own (already slim) rule. */
    .th-events__link { padding-block: var(--th-space-2); }
    .th-events__day { font-size: 1.35rem; }
}

/* Past events stay clickable but visually recede so they don't compete with
   the "what's next" content above. They also collapse to a single-line row
   (date · title · venue) — booking teams scan the past list for reference,
   not for action, so the full card height of Now/Upcoming rows would just
   waste vertical space here. */
.th-events__item--past .th-events__link {
    opacity: 0.7;
    min-height: 0;
    padding: var(--th-space-2) var(--th-space-4);
    align-items: baseline;
}
.th-events__item--past .th-events__link:hover,
.th-events__item--past .th-events__link:focus { opacity: 1; }

.th-events__item--past .th-events__date {
    flex-direction: row;
    align-items: baseline;
    gap: var(--th-space-1);
    min-width: 0;
}
.th-events__item--past .th-events__day { font-size: 0.95rem; font-weight: 600; }
.th-events__item--past .th-events__month { font-size: 0.85rem; }

.th-events__item--past .th-events__body {
    flex-direction: row;
    align-items: baseline;
    gap: var(--th-space-2);
    min-width: 0;
    overflow: hidden;
}
.th-events__item--past .th-events__title {
    font-size: 0.95rem;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    flex: 0 1 auto;
}
.th-events__item--past .th-events__meta {
    font-size: 0.9em;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    flex: 1 1 auto;
    min-width: 0;
}

/* Status badge has two labels (long + short) so phone-class viewports can
   swap to "Unc" / "Canc" without the desktop badge changing. */
.th-badge__short { display: none; }

/* Past events on phone-class viewports — keep the boxed card look, just
   tighten the gap between cards and trim year, venue/city, and the
   badge label to a short form so the title keeps its room. */
@media (max-width: 480px) {
    .th-events__past .th-events { gap: var(--th-space-2); }
    .th-events__item--past .th-events__year,
    .th-events__item--past .th-events__meta {
        display: none;
    }
    .th-events__item--past .th-badge__long { display: none; }
    .th-events__item--past .th-badge__short { display: inline; }
    /* Short badge needs less corner clearance on the body's right edge. */
    .th-events__item--past .th-events__link:has(> .th-badge) .th-events__body {
        padding-right: 3.5rem;
    }
}

/* Past section's <summary> tracks its own line — hide the native disclosure
   marker since the section heading already implies "click to expand". */
.th-events__past { margin: 0; }
.th-events__past-toggle { cursor: pointer; }
.th-events__past-toggle::-webkit-details-marker { display: none; }
.th-events__past-toggle::marker { content: ""; }
.th-events__past-toggle::after { content: " ▾"; font-size: 0.85em; }
.th-events__past[open] > .th-events__past-toggle::after { content: " ▴"; }

/* ---------- Status / billing badges ---------- */

.th-badge {
    display: inline-block;
    padding: 0.15rem var(--th-space-2);
    border-radius: 999px;
    font-size: 0.78rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    background: var(--th-grey-light);
    color: var(--th-text);
    border: 1px solid var(--th-grey-border);
}

/* A count badge that trails a button's label (e.g. "Assign crew" /
   "Create rooms" + a live count) needs breathing room — .th-button is
   inline-flex with no gap, so the pill would otherwise butt right up
   against the text. */
.th-button > .th-badge { margin-left: var(--th-space-2); }

.th-badge--cancelled  { background: var(--th-alert-error-bg);   color: var(--th-error);   border-color: var(--th-error); }
/* Event-type badge — neutral mid-grey so it's distinct from the status
   colour scale (which is reserved for lifecycle state: Confirmed /
   Cancelled / Completed / Unconfirmed). Sits next to the status badge in
   the event header. */
.th-badge--type       { background: var(--th-white); color: var(--th-text-muted); border-color: var(--th-grey-border); }

/* Guestlist-state badges (Hidden / Closed / Open) — surfaced on the public
   guestlist footer so door staff can see at a glance whether the list is
   still mutable. Hidden is mostly defensive: the public page short-circuits
   before rendering when state == Hidden, so the badge is only visible to
   members on the in-app guestlist page. */
.th-badge--hidden     { background: var(--th-grey-light); color: var(--th-text-muted); border-color: var(--th-grey-border); }
.th-badge--closed     { background: var(--th-alert-warning-bg); color: var(--th-warning-text); border-color: var(--th-warning); }
.th-badge--open       { background: var(--th-alert-success-bg); color: var(--th-success); border-color: var(--th-success); }
.th-badge--warning    { background: var(--th-alert-warning-bg); color: var(--th-warning-text); border-color: var(--th-warning); }

/* ---------- Event detail ---------- */

/* Sticky wrapper for the event header + tab strip. Keeps the entire
   chrome (back/prev/next, title, address, tabs) glued to the top of the
   viewport while the section content below scrolls. The solid background
   stops content from showing through when it passes under. The negative
   margin and padding cancel out the parent's .th-app__main padding so
   the sticky bar visually spans edge-to-edge, while child content keeps
   its original gutter via padding.

   Background matches --th-page-bg (not --th-white) so the edge-to-edge
   bar reads as page chrome rather than a white card floating wider than
   the content below it. Cards/tables further down the page keep their
   --th-white surface and stay raised against the page.

   No bottom border on the chrome itself — .th-event-tabs already draws a
   2px underline at the same y-position (the line the active tab's purple
   indicator pierces). Putting a border here too stacked two lines. */
.th-event-chrome {
    position: sticky;
    /* .th-app__header is sticky at top:0 with z-index:100; we park the
       chrome 4px *higher* than --th-header-h so its background slides
       under the top bar's bottom edge. The top bar (z-index:100) paints
       over the 4px overlap, which absorbs any subpixel mismatch between
       --th-header-h and the actual rendered header height — without
       this buffer the chrome either slid under the top bar (title
       visibly shimmying up on scroll) or left a 1–2px see-through gap
       below the top bar. Padding-top adds the buffer back so the
       prev/next row sits a true 8px below the top bar (half the prior
       16px gap, per design tweak). */
    top: calc(var(--th-header-h) - 0.25rem);
    /* Above the in-page dropdown panels (ListPicker / country / airport /
       role pickers all sit at z-index 40) so that when an open dropdown's
       trigger scrolls up underneath this sticky chrome, the chrome — the
       tour/event tab strip and the crew sub-tabs — keeps painting over the
       panel instead of the panel bleeding across the navigation. Still
       below the global top bar (z-index:100) so that bar always wins. */
    z-index: 60;
    background: var(--th-page-bg);
    /* Negative top margin cancels .th-app__main's 16px padding-top plus
       the 4px sticky buffer, so the chrome's natural document-flow top
       equals its sticky `top` offset. Without this, the chrome started
       at y≈97 and scroll-drifted up to y=77 before locking — visible
       as the title sliding upward at the start of every scroll. With it,
       natural = sticky, so the chrome appears locked from the first
       pixel of scroll. */
    margin: calc((var(--th-space-3) + 0.25rem) * -1) calc(var(--th-space-4) * -1) var(--th-space-3);
    padding: calc(var(--th-space-2) + 0.25rem) var(--th-space-4) 0;
}
.th-event-chrome .th-event-header { margin-bottom: var(--th-space-2); }
.th-event-chrome .th-event-tabs { margin-bottom: 0; }
/* Drop the tour-name H1's default bottom margin inside the chrome so the
   tab strip sits snug under the title (the header's own margin-bottom is
   the only gap). Applied across viewports — previously only mobile did
   this, leaving a roomy double-gap on desktop. */
.th-event-chrome h1 { margin-bottom: 0; }

.th-event-header { margin-bottom: var(--th-space-4); }
.th-event-header__back {
    font-size: 0.95em;
}
/* Date sits on its own row above the H1 (flex-basis: 100% below). The
   muted small text reads as a label for the title underneath. */
.th-event-header__date {
    font-size: 0.9rem;
    color: var(--th-text-muted);
    flex-basis: 100%;
}

/* Date + H1 + status pill stack: date on row 1, H1 + status pill on
   row 2. align-items: flex-start keeps the status pill anchored to the
   top of the title's line box. Top margin distances the row from the
   muted prev/next utility row above. */
.th-event-header__title-row {
    display: flex;
    align-items: flex-start;
    /* column-gap keeps breathing room between the H1 and the status pill,
       while a tight row-gap pulls the date label snug right above the
       title (the date wraps onto its own line via flex-basis: 100%). */
    column-gap: var(--th-space-3);
    row-gap: var(--th-space-1);
    flex-wrap: wrap;
    /* Half the previous gap (was --th-space-4) between the muted prev/next
       row and the date — keeps the header compact. */
    margin-top: var(--th-space-2);
}
/* Trim the event title a notch below the global H1 max and drop its
   bottom margin so the venue/address block can sit close beneath it
   (the venue block owns the title→address gap via its margin-top). */
.th-event-header__title-row h1 {
    font-size: clamp(1.375rem, 3vw + 0.75rem, 1.875rem);
    margin-bottom: 0;
}

/* Venue summary — stacked rows (venue + stage, location, website) under
   the title. Tight line-height keeps the multi-row block compact so the
   sticky chrome doesn't bloat. Each row is its own flex container so the
   map icon can sit inline with the venue + stage text on row 1. */
.th-event-header__venue {
    color: var(--th-text-muted);
    margin: var(--th-space-2) 0 0;
    line-height: 1.4;
}
.th-event-header__venue-row {
    display: flex;
    align-items: center;
    gap: var(--th-space-2);
}
.th-event-header__venue-name { color: var(--th-text); font-weight: 500; }
.th-event-header__venue-sep { color: var(--th-text-muted); }
.th-event-header__website { color: var(--th-purple); }

/* Icon-only "Open in Maps" — inline with the venue line. Purple by default
   so it reads as an affordance against the muted venue line; deeper purple
   on hover. */
.th-event-header__map {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    color: var(--th-purple);
    line-height: 0;
}
.th-event-header__map:hover { color: var(--th-purple-dark); }
.th-event-header__map-icon { width: 18px; height: 18px; display: block; }

/* Thin muted utility row: prev (left) — tour back link (center) — next
   (right). Demoted from headline chrome to footnote so the eye lands on
   the title below instead. Prev/next render disabled placeholders at the
   ends of a tour so the three-column layout stays steady. */
.th-event-header__nav {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--th-space-3);
    flex-wrap: wrap;
    margin-bottom: var(--th-space-1);
    font-size: 0.85rem;
    color: var(--th-text-muted);
}
.th-event-header__nav a { color: var(--th-text-muted); }
.th-event-header__nav a:hover { color: var(--th-purple); }
.th-event-header__step {
    font-size: inherit;
}
.th-event-header__step--disabled {
    color: var(--th-grey-border);
    cursor: default;
}

.th-event-tabs {
    display: flex;
    flex-wrap: wrap;
    gap: var(--th-space-1);
    /* --th-grey-border matches the crew table outline so container edges
       across the tour pages share one weight. Kept at 2px because the
       active-tab purple indicator is 2px and needs a base line the same
       thickness for the override to sit flush. */
    border-bottom: 2px solid var(--th-grey-border);
    margin-bottom: var(--th-space-4);
    /* No overflow-x:auto here — wrap-to-next-line is the right narrow-screen
       fallback. The old overflow setting forced a horizontal scrollbar to
       appear instead of letting the flex container wrap. */
}

.th-event-tabs__tab {
    display: inline-flex;
    align-items: center;
    min-height: var(--th-tap-target);
    padding: 0 var(--th-space-3);
    text-decoration: none;
    color: var(--th-text);
    border-bottom: 2px solid transparent;
    margin-bottom: -2px;
}

.th-event-tabs__tab--active {
    color: var(--th-purple-vivid);
    border-bottom-color: var(--th-purple-vivid);
    /* Bolding the active tab made each label wider and pushed the strip
       sideways on click. Color + underline is enough emphasis. The vivid
       variant lifts the indicator off the white surface in light mode;
       dark mode resolves --th-purple-vivid back to --th-purple. */
}

.th-event-tabs__tab--placeholder {
    color: var(--th-text-muted);
    cursor: not-allowed;
}

/* Page-level action pinned to the far right of the tab strip (e.g. the
   tour-overview "New event" button). margin-left:auto pushes it past the
   last tab; align-items keeps it vertically centred against the tab row. */
.th-event-tabs__actions {
    margin-left: auto;
    display: inline-flex;
    align-items: center;
    /* Lift it off the 2px baseline so the button doesn't sit on the
       underline the tabs draw. */
    margin-bottom: var(--th-space-1);
}
/* Compact, inline-sized button so it fits the strip's height and never
   stretches full-width on mobile (overrides the global mobile slab rule). */
.th-event-tabs__action {
    width: auto;
    min-height: var(--th-space-7, 2rem);
    padding: var(--th-space-1) var(--th-space-3);
    margin: 0;
    white-space: nowrap;
    font-size: 0.9em;
}

/* Mobile: too many tabs (Info + Schedule + Setlist + Guestlist +
   Contacts + Crew + Resources + Settings) to fit on a narrow viewport
   without squeezing each label into illegible 40-px chunks. Switch to a
   horizontal scroller — every tab keeps a comfortable touch target and
   the user swipes sideways. Scrollbar hidden for a clean look. */
@media (max-width: 640px) {
    .th-event-tabs {
        gap: 0;
        flex-wrap: nowrap;
        overflow-x: auto;
        scrollbar-width: none;
        /* Telegraph scrollability: the underline trails off from solid to
           long dashes to small dots — like a sentence ending in "..." —
           so users see the strip continues past the visible edge. The
           pattern is painted by background-image at the border line so
           the active-tab purple indicator still anchors correctly; the
           border-bottom itself is held transparent to avoid double-
           drawing under the dashes. Solid line stays grey to match the
           rest of the strip's chrome; the trailing dashes and dots are
           purple so the accent reads as "look here, scroll further".
           To keep purple still meaning "you are here", the active tab's
           own bar is bumped to 3px on mobile (rule below) so it reads
           as a solid block over the 2px dashes.

           Pattern flips with scroll position: at-start / mid → trail
           on the right (more to the right); at-end → trail mirrored to
           the left (everything's been scrolled past, swipe back to
           reach it). Hidden entirely when the strip fits without
           overflow (no-overflow class set by the App.razor scroll
           handler). */
        border-bottom-color: transparent;
        background-image: linear-gradient(to right,
            var(--th-grey-border) 0,
            var(--th-grey-border) calc(100% - 50px),
            transparent calc(100% - 50px),
            transparent calc(100% - 46px),
            var(--th-purple-vivid) calc(100% - 46px),
            var(--th-purple-vivid) calc(100% - 36px),
            transparent calc(100% - 36px),
            transparent calc(100% - 32px),
            var(--th-purple-vivid) calc(100% - 32px),
            var(--th-purple-vivid) calc(100% - 24px),
            transparent calc(100% - 24px),
            transparent calc(100% - 20px),
            var(--th-purple-vivid) calc(100% - 20px),
            var(--th-purple-vivid) calc(100% - 17px),
            transparent calc(100% - 17px),
            transparent calc(100% - 13px),
            var(--th-purple-vivid) calc(100% - 13px),
            var(--th-purple-vivid) calc(100% - 10px),
            transparent calc(100% - 10px)
        );
        background-position: 0 100%;
        background-size: 100% 2px;
        background-repeat: no-repeat;
    }
    .th-event-tabs--at-end {
        /* Mirror — solid line on the right, dashes-then-dots fading
           toward the left edge. Reads as "you're at the end; swipe
           back to see what came before". Dashes/dots purple matches the
           at-start variant; the active tab still pops as a 3px block. */
        background-image: linear-gradient(to right,
            transparent 0,
            transparent 10px,
            var(--th-purple-vivid) 10px,
            var(--th-purple-vivid) 13px,
            transparent 13px,
            transparent 17px,
            var(--th-purple-vivid) 17px,
            var(--th-purple-vivid) 20px,
            transparent 20px,
            transparent 24px,
            var(--th-purple-vivid) 24px,
            var(--th-purple-vivid) 32px,
            transparent 32px,
            transparent 36px,
            var(--th-purple-vivid) 36px,
            var(--th-purple-vivid) 46px,
            transparent 46px,
            transparent 50px,
            var(--th-grey-border) 50px,
            var(--th-grey-border) 100%
        );
    }
    .th-event-tabs--no-overflow {
        /* Strip fits without overflow — no scroll hint needed. Restore
           a plain solid underline via the regular border. */
        background-image: none;
        border-bottom-color: var(--th-grey-border);
    }
    .th-event-tabs::-webkit-scrollbar { display: none; }
    .th-event-tabs__tab {
        /* Tighter horizontal padding than desktop's --th-space-3 so the
           five tour tabs (Events / Crew / Tracks / Resources / Settings)
           fit a phone width without scrolling. Scrolling stays as the
           fallback for the longer event-page strip. */
        padding: 0 var(--th-space-2);
        flex: 0 0 auto;
        justify-content: center;
        text-align: center;
        min-width: 0;
        /* Drop the desktop -2px overlap on mobile. With overflow-x:auto
           on the parent, the child's border-bottom extension into the
           parent's border row is clipped, so the active-tab purple bar
           disappears — and even without clipping it would sit in a row
           below the parent's background-image trail, leaving the grey
           solid section visible underneath the active tab. Setting
           margin-bottom to 0 puts the child's border in the same row
           as the parent's background-image, so the active tab's purple
           paints over the grey/trail directly. */
        margin-bottom: 0;
    }
    /* Tour strip (5 tabs) stretches its tabs to fill the full width instead
       of sitting compact at the left — flex-grow distributes the spare space
       evenly. Scoped to --fill so the longer event-page strip keeps its
       fixed-width, scrollable tabs. */
    .th-event-tabs--fill .th-event-tabs__tab {
        flex: 1 1 auto;
    }
}

/* Secondary sub-navigation under a page (e.g. Settings → General /
   Editors / …). Same shape as .th-event-tabs (text + underline) so the
   visual language across all navigation layers matches; smaller font
   and tighter row keep the hierarchy below the main tab strip readable. */
.th-subtabs {
    display: flex;
    flex-wrap: wrap;
    gap: var(--th-space-1);
    border-bottom: 1px solid var(--th-grey-light);
    margin-bottom: var(--th-space-4);
    /* Own stacking context so an open dropdown panel below the strip that
       flips up (z-index:40) can't bleed across these sub-tabs. Sits above
       the panels but below the sticky tour/event chrome (z-index:60) so the
       strip still scrolls cleanly under that chrome rather than over it. */
    position: relative;
    z-index: 50;
}
.th-subtabs__tab {
    display: inline-flex;
    align-items: center;
    min-height: calc(var(--th-tap-target) - 8px);
    padding: 0 var(--th-space-2);
    text-decoration: none;
    color: var(--th-text);
    border-bottom: 2px solid transparent;
    margin-bottom: -1px;
    font-size: 0.95em;
}
.th-subtabs__tab:hover { color: var(--th-purple); }
.th-subtabs__tab--active {
    color: var(--th-purple);
    border-bottom-color: var(--th-purple);
}
/* A share-link copy affordance riding on the sub-nav (Roster's "Share link")
   gets pushed to the far right of the strip; the strip owns the spacing below,
   so drop the link's own standalone bottom margin. */
.th-subtabs > .th-public-link { margin: 0 0 0 auto; }

.th-event-section { margin-bottom: var(--th-space-5); }
.th-event-section__lede { color: var(--th-text-muted); margin-top: 0; }
.th-event-section__subhead {
    margin-top: var(--th-space-4);
    margin-bottom: var(--th-space-2);
    font-size: 1rem;
    display: flex;
    align-items: baseline;
    gap: var(--th-space-2);
}
.th-event-section__subhead-note {
    font-size: 0.8em;
    font-weight: 400;
}

.th-kv {
    display: grid;
    grid-template-columns: minmax(8rem, max-content) 1fr;
    gap: var(--th-space-2) var(--th-space-4);
    margin: 0 0 var(--th-space-4);
}

.th-kv dt { color: var(--th-text-muted); font-weight: 600; }
.th-kv dd { margin: 0; }

.th-notes {
    white-space: pre-wrap;
    background: var(--th-grey-light);
    padding: var(--th-space-3);
    border-radius: var(--th-radius);
    /* Drop the UA <p> margin-block so the Notes block doesn't add ~1em of
       extra space below itself on top of the grid row gap — keeps the
       Notes→Venue gap equal to the Venue→Accommodation gap. */
    margin: 0;
}

/* Notes editor (Settings) — a Bold/Underline toolbar directly above the notes
   textarea. Buttons insert the markdown-lite markers NotesMarkup renders
   (**bold**, __underline__); they're type=button so they never submit. */
.th-notes-editor { display: flex; flex-direction: column; gap: var(--th-space-1); }
/* Notes textareas resize vertically only — horizontal drag would break the
   column layout / grey-box confinement. */
.th-notes-editor textarea { resize: vertical; }
.th-notes-editor__toolbar { display: flex; gap: var(--th-space-1); }
.th-notes-editor__btn {
    min-width: 2rem;
    padding: var(--th-space-1) var(--th-space-2);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-white);
    color: var(--th-text);
    cursor: pointer;
    line-height: 1;
}
.th-notes-editor__btn:hover { background: var(--th-grey-light); }

/* Inline notes editor (Info overview) — a confined grey box holding the
   toolbar, textarea, hint and actions, mirroring the schedule/contacts inline
   editors so all in-place editing feels the same. */
.th-notes-edit {
    display: flex;
    flex-direction: column;
    gap: var(--th-space-2);
    background: var(--th-grey-light);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    padding: var(--th-space-3);
}

.th-form-page--wide { max-width: 40rem; }
.th-form-page--inline { max-width: 32rem; margin: 0 0 var(--th-space-5); padding: var(--th-space-3); border: 1px solid var(--th-grey-border); border-radius: var(--th-radius); }

/* ---------- Schedule table ---------- */

.th-schedule {
    width: 100%;
    border-collapse: collapse;
    margin-bottom: var(--th-space-4);
}

.th-schedule th, .th-schedule td {
    padding: var(--th-space-2) var(--th-space-3);
    border-bottom: 1px solid var(--th-grey-light);
    text-align: left;
    vertical-align: top;
}

.th-schedule th { color: var(--th-text-muted); font-weight: 600; font-size: 0.85em; }

.th-schedule__row--highlight td { background: var(--th-highlight-bg); }
/* Currently happening — row whose [start, end) range contains "now" in
   its own resolved zone AND whose date is today. Placed after --highlight
   so the cascade wins when a row carries both classes (the "now" state
   is more time-sensitive than a manual highlight). */
.th-schedule__row--now td { background: var(--th-lavender); }

.th-schedule__time { white-space: nowrap; font-variant-numeric: tabular-nums; }
.th-schedule__time--empty { color: var(--th-text-muted); }
/* Actions cell stays display: table-cell so it still participates in
   column-width and vertical-alignment with sibling cells in the row.
   To keep multiple action buttons on a single line — including those
   rendered inside their own sibling <EditForm> (which expands to a
   block <form>) — we make the form itself layout-transparent via
   `display: contents`. Its inner <button> then sits next to the
   surrounding pencil/cancel/save inlines under the cell's existing
   `white-space: nowrap`, instead of dropping to its own line.

   `display: flex` on the <td> would also inline the buttons, but it
   strips the cell of table-cell layout — the column then stops
   widening with the row and the cell's vertical alignment falls
   out of sync with the title/time cells next to it. */
.th-schedule__actions { width: 1%; white-space: nowrap; }
.th-schedule__actions form { display: contents; }

/* Guestlist read-row actions mix a plain <a> Edit pencil with the
   approve/decline/reopen buttons, which live inside display:contents
   <form>s. On desktop this cell isn't a flex container, so button spacing
   comes from the inline whitespace between siblings — but the whitespace
   around the Razor conditional that emits the pencil gets dropped, so it
   butts against its neighbour. Give the pencil an explicit inline gap so
   it never touches the adjacent decision icon (works whether the pencil
   leads — pending — or trails — decided). */
.th-guestlist-schedule .th-schedule__row:not(.th-schedule__row--editing)
    .th-schedule__actions > .th-icon-btn {
    margin-inline: var(--th-space-1);
}

/* Phone over email in one column — the .th-contacts-row__contact-info stack,
   but inside a <td> so it stays a table-cell (block children, not flex on the
   cell, which would drop it out of the table layout). The phone never breaks;
   the long email is the one value allowed to wrap. */
/* Each value on its own line (block) but only as wide as its text, so the
   clickable hit area is the phone/email itself, not the full cell width. */
.th-guestlist-contact a { display: block; width: fit-content; max-width: 100%; }
.th-guestlist-contact a[href^="tel:"] { white-space: nowrap; }
.th-guestlist-contact a[href^="mailto:"] { overflow-wrap: anywhere; }
/* Phone and email sit on adjacent lines in one cell. `user-select: contain`
   keeps a drag that starts inside one value from bleeding into the other, so
   each can be selected on its own. Only Firefox honours `contain` today; in
   other browsers a clean copy is still one right-click away ("Copy email
   address" / "Copy phone number" on the mailto/tel link). We avoid
   `user-select: all`, which flickers mid-drag on clickable links. */
.th-guestlist-contact a { user-select: contain; -webkit-user-select: contain; }

/* Desktop column behaviour for the guestlist roster table. Keep the short,
   atomic values — a person's name and the inviter — on one line so they don't
   wrap mid-value in the cramped grid. Scoped to ≥641px so the ≤640px
   stacked-card reflow (white-space: normal) still wins on mobile. */
@media (min-width: 641px) {
    .th-guestlist-schedule th { white-space: nowrap; }
    .th-guestlist-schedule td[data-label="Name"],
    .th-guestlist-schedule td[data-label="Invited by"] { white-space: nowrap; }
}

/* Avatar column — narrow leading column carrying just the 32px Sm tile.
   width: 1% + nowrap collapses to the tile's intrinsic size; vertical
   centering on the cell so the avatar lines up with the row's first
   text line (Position) instead of floating at the cell's top edge. */
.th-schedule__avatar-col { width: 1%; }
.th-schedule__avatar-cell {
    width: 1%;
    white-space: nowrap;
    vertical-align: middle;
}

/* Notes inlined under the title (the standalone Notes column has been
   removed). Smaller text, muted grey so it reads as a secondary line
   without the lavender-chip treatment the old column used. */
.th-schedule__notes-line {
    font-size: 0.85em;
    color: var(--th-text-muted);
    margin-top: var(--th-space-1);
    white-space: pre-wrap;
}
/* Audience marker also lives under the title now. Slightly muted so it
   reads as meta separate from the notes line. Plain text, no chips. */
.th-schedule__audience-line {
    font-size: 0.8em;
    color: var(--th-text-muted);
    margin-top: var(--th-space-1);
}

/* Variant for the EventSchedule table, which has an inline-edit colspan
   cell (.th-schedule__form-cell). Without table-layout:fixed, the wide
   edit form's natural min-content forces the columns wider and the
   thead headers shift positions whenever a row enters edit mode. Locking
   the layout also requires an explicit Time width since the first row
   no longer drives column widths. */
.th-schedule--with-form-cell { table-layout: fixed; }
.th-schedule--with-form-cell thead th:first-child { width: 9rem; }
.th-schedule--with-form-cell .th-schedule__actions { width: 4.5rem; }

/* Without this, the 32px .th-icon-btn in the admin-only Actions cell
   makes every row taller than the text-only Time / Title cells need,
   leaving visible air at the bottom of those cells. Shrinking the icon
   to 28px + tightening the actions cell's vertical padding lines the
   row height up with the text rows so admin mode looks identical to
   public mode (minus the icon column itself). */
.th-schedule--with-form-cell .th-schedule__actions .th-icon-btn {
    width: 28px;
    height: 28px;
}
.th-schedule--with-form-cell tbody .th-schedule__actions {
    padding-top: var(--th-space-1);
    padding-bottom: var(--th-space-1);
}

/* Editing/adding rows — wrapper <td> stays transparent and adds a small
   vertical pad so the editor reads as its own card with breathing room
   above and below the surrounding read rows. Card chrome (background,
   border, radius) lives on the inner .th-schedule-edit, mirroring the
   detached .th-schedule__add-card treatment. */
.th-schedule__row--editing td,
.th-schedule__row--adding td { vertical-align: middle; }
/* Setlist cells are single-line (#, title, duration, action) and the
   row's vertical footprint is set by a 32px icon button / grip — so
   the text needs to centre against the row, not sit at the top edge
   the way the schedule's multi-line notes/audience rows do. Scoped to
   `.th-schedule--setlist` so this doesn't bleed into the schedule
   tables that share the .th-schedule class. */
.th-schedule--setlist td { vertical-align: middle; }

/* Read-row height parity with the schedule list. A schedule row
   (.th-schedule--with-form-cell) is sized by its text cells — one line at
   1.5em + the cell's space-2 vertical padding ≈ 40px — with its action
   icon dropped to 28px inside space-1 padding so it tucks within that. The
   setlist table (.th-schedule--rows) left its action pencil at the default
   32px in space-2 padding (≈48px cell), and a catalogue row's 34px audio
   Play button is taller still, so setlist rows ran ~8px taller and uneven.
   Mirror the schedule's action sizing and bring the Play button within one
   text line so every track read-row lands on the same height as a schedule
   row. The explicit height is a floor (in a table, `height` is a minimum),
   so a row carrying a real second line — the "Tot.:" cumulative or a note —
   still grows, exactly as a schedule row with notes does. Comment rows keep
   their intentional half-height; editing + reorder rows are untouched.
   Touch viewports bump the icon buttons back to the 44px tap floor via the
   rule in the mobile block, which wins by source order. */
.th-schedule--setlist .th-schedule__row:not(.th-schedule__row--editing):not(.th-schedule__row--reorder):not(.th-schedule__row--comment) {
    height: calc(1.5em + 2 * var(--th-space-2));
}
.th-schedule--setlist .th-schedule__row:not(.th-schedule__row--editing):not(.th-schedule__row--reorder) .th-schedule__actions .th-icon-btn {
    width: 28px;
    height: 28px;
}
.th-schedule--setlist tbody .th-schedule__row:not(.th-schedule__row--editing):not(.th-schedule__row--reorder) .th-schedule__actions {
    padding-top: var(--th-space-1);
    padding-bottom: var(--th-space-1);
}
.th-schedule--setlist .th-setlist__title-line .th-audio__open,
.th-schedule--setlist .th-setlist__title-line .th-audio__download {
    width: 24px;
    height: 24px;
}
.th-schedule--setlist .th-setlist__title-line .th-audio__open svg,
.th-schedule--setlist .th-setlist__title-line .th-audio__download svg {
    width: 16px;
    height: 16px;
}

/* Let the Title column swallow the table's slack so long titles get the
   most room and the Duration + Actions columns hug the right edge (the
   # and Duration cells size to their nowrap content). Desktop auto-layout
   only — the mobile grid sizes the title via minmax(0,1fr) instead. */
.th-schedule--setlist thead th:nth-child(2),
.th-schedule--setlist tbody td:nth-child(2) { width: 100%; }

.th-schedule__form-cell {
    padding: var(--th-space-2) 0;
    background: transparent;
}
/* Descendant (not >) on purpose: <EditForm> renders a <form> element
   between the .th-schedule__form-cell <td> and the inner .th-schedule-edit,
   so a direct-child combinator would never match. */
.th-schedule__form-cell .th-schedule-edit {
    background: var(--th-grey-light);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
}
/* Detached add card — used when adding a schedule item. Lives BELOW the
   table (or alone when the schedule is empty) and carries its own
   chrome since it's no longer nested in a table cell. Matches the
   contacts page's --standalone add card so both event tabs feel the
   same when adding a record. Inner padding is owned by .th-schedule-edit. */
.th-schedule__add-card {
    margin-top: var(--th-space-3);
    background: var(--th-grey-light);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
}

/* Live preview of the Highlight toggle: ticking the checkbox shifts the
   editor's background to the same warm amber tint used by highlighted
   read-rows, so the user sees what the row will look like after save
   without needing to submit. Distinct from the lavender used by soft
   buttons and the day-offset pill, so the highlight state doesn't blend
   into the editor's other affordances. Uses :has() — supported in all
   modern evergreen browsers. */
.th-schedule__row--editing:has(.th-schedule-edit__highlight input:checked) .th-schedule-edit,
.th-schedule__add-card:has(.th-schedule-edit__highlight input:checked) {
    background: var(--th-highlight-bg);
}

/* Inline add/edit row for schedule items. No field labels — placeholders
   carry the role. Layout is a vertical stack of "lines":
   (Highlight + Assign), (Times + ±day + Zone), (Title),
   (Notes + Trash + Cancel + Save). Matches the chrome + colour palette of
   .th-contacts-row--editing/--adding so all inline CRUD on the event
   pages reads the same way. */
.th-schedule-edit {
    display: flex;
    flex-direction: column;
    gap: var(--th-space-2);
    padding: var(--th-space-2);
}

/* Top row above the main edit grid: Highlight on the left, Assign-people
   pushed flush right via margin-left:auto on .th-schedule-edit__assign.
   Trash lives in the bottom actions cluster (Trash | Cancel | Save) next
   to the Notes textarea, mirroring the setlist editor's layout. */
.th-schedule-edit__top-row {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--th-space-3);
}
.th-schedule-edit__top-row form { margin: 0; }
.th-schedule-edit input[type="text"],
.th-schedule-edit input[type="search"],
.th-schedule-edit input[type="time"],
.th-schedule-edit input:not([type]),
.th-schedule-edit textarea,
.th-schedule-edit select {
    /* input:not([type]) catches Blazor's <InputText>, which renders an
       <input> WITHOUT an explicit type attribute — without this branch the
       Subtitle <InputText> falls back to browser-default chrome (grey, no
       rounded corners). Mirrors the same trick in the .th-field baseline.

       Vertical padding is space-1 (compact) so the time inputs, timezone
       select, and Title/Subtitle text inputs land at the same height as
       the SuggestInput — which uses space-1 vertical too. Horizontal stays
       at space-2 so labels/values have breathing room. The Notes textarea
       overrides this below to stay roomy. */
    padding: var(--th-space-1) var(--th-space-2);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-white);
    color: var(--th-text);
    font: inherit;
    box-sizing: border-box;
}
.th-schedule-edit input[type="text"],
.th-schedule-edit input[type="search"],
.th-schedule-edit input:not([type]),
.th-schedule-edit textarea { width: 100%; min-width: 0; }
/* Focus ring matches the .th-field treatment on /tours/{id}/settings et al.
   so the user gets the same purple outline regardless of which form scope
   they're in. Without this, the inline schedule editor falls back to
   browser-default focus chrome (thin white/blue) and feels inconsistent
   next to the settings forms. */
.th-schedule-edit input[type="text"]:focus,
.th-schedule-edit input[type="search"]:focus,
.th-schedule-edit input[type="time"]:focus,
.th-schedule-edit input:not([type]):focus,
.th-schedule-edit textarea:focus,
.th-schedule-edit select:focus {
    outline: 2px solid var(--th-purple);
    outline-offset: 1px;
    border-color: var(--th-purple);
}
.th-schedule-edit textarea {
    /* Notes starts as a single line so it sits on the same row as
       Cancel + Save and matches the height of the other inputs (Title,
       Subtitle, time, timezone). Padding + font (including line-height)
       are inherited from the schedule-edit baseline above — overriding
       either pulls the textarea out of sync with the input row.
       resize:vertical still lets the user grow it for longer notes. */
    resize: vertical;
}

/* All time-related controls share a single horizontal row: start time,
   optional end time (with toggle), ±day segmented pill, timezone select.
   Wraps if the editor is narrower than the combined width. */
.th-schedule-edit__time-row {
    display: flex;
    flex-direction: row;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--th-space-2);
}
/* Just wide enough to fit "HH:mm" with the native spinner controls.
   `auto` + min-width lets browsers that render the spinner larger
   (e.g. Chromium) grow further if needed. */
.th-schedule-edit__time-row input[type="time"] {
    width: auto;
    min-width: 5rem;
}

/* Title + Subtitle share the row beneath the time row. Title is the
   "kind" (Show / Soundcheck / Load-out); Subtitle is the free-text
   addendum (artist name, focus note). On narrow editors the inputs
   wrap to two stacked rows. min-width:0 lets each flex child shrink
   inside the row instead of forcing horizontal overflow. */
.th-schedule-edit__title-row {
    display: flex;
    flex-wrap: wrap;
    gap: var(--th-space-2);
}
.th-schedule-edit__title-row > * {
    flex: 1 1 14rem;
    min-width: 0;
}
/* Subtitle inherits the schedule-edit baseline padding (space-1 vertical,
   space-2 horizontal) so it lands at the same height as the SuggestInput
   beside it. No padding override needed — it falls through to the
   baseline above. */

/* Optional end-time toggle. Two visible states driven by the
   data-th-mode attribute (server-rendered initially, swapped by the
   small inline JS handlers on the +/× buttons): "empty" shows only the
   "+ End time" button so a blank end time looks intentional, "set"
   shows the dash + time input + clear button so the user can adjust
   or remove it. */
.th-time-optional {
    display: inline-flex;
    align-items: center;
    gap: var(--th-space-1);
}
.th-time-optional[data-th-mode="empty"] > .th-time-optional__dash,
.th-time-optional[data-th-mode="empty"] > input,
.th-time-optional[data-th-mode="empty"] > .th-time-optional__clear { display: none; }
.th-time-optional[data-th-mode="set"] > .th-time-optional__add { display: none; }
/* "+ End time" trigger: drop the 44px .th-button min-height and inherit
   the same vertical padding + font as the time inputs so the button
   lands at the same height as the start-time input on the row. A smaller
   font-size override pulled the content box shorter and re-broke the
   alignment — leave font: inherit. */
.th-time-optional__add {
    min-height: 0;
    padding: var(--th-space-1) var(--th-space-2);
}

/* Compact "smart" zone picker — sized to its content with a globe glyph
   prefix so the user reads it as a zone selector even when collapsed.
   Options render "Name (GMT±N)" so the offset is visible in the
   dropdown without bloating the typical collapsed state. */
.th-schedule-edit__zone {
    width: auto;
    min-width: 0;
    max-width: 14rem;
    align-self: stretch;
}
/* The picker trigger is the visible control, so the globe glyph + compact
   padding live on it. align-self:stretch on the root + height:100% here
   grow it to whatever row height the time inputs anchor. */
.th-schedule-edit__zone .th-list-picker__trigger {
    min-height: 0;
    height: 100%;
    padding: var(--th-space-1) var(--th-space-2) var(--th-space-1) 1.9em;
    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='9'/><path d='M3 12h18'/><path d='M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18'/></svg>");
    background-repeat: no-repeat;
    background-position: 0.5em center;
    background-size: 0.95em 0.95em;
}
/* The trigger stays compact (≤14rem), but on desktop the dropdown needs a
   readable width — zone labels like "Europe/Copenhagen (GMT+1)" don't fit
   14rem. left:0 keeps it anchored to the trigger's left edge. On mobile the
   th-bottom-sheet rule takes over (full-width), so this is desktop-only. */
@media (min-width: 641px) {
    .th-schedule-edit__zone .th-list-picker__panel {
        min-width: 18rem;
        right: auto;
    }
}

/* Day picker — the multi-day dropdown that replaces the tab strip on events
   longer than the threshold. Mirrors the zone picker's row sizing so it lines
   up flush with the time inputs, minus the globe glyph. Labels read
   "Sat 7 Jun · day 3", so the trigger and panel are a touch wider. */
.th-schedule-edit__day {
    width: auto;
    min-width: 0;
    max-width: 12rem;
    align-self: stretch;
}
.th-schedule-edit__day .th-list-picker__trigger {
    min-height: 0;
    height: 100%;
    padding: var(--th-space-1) var(--th-space-2);
}
@media (min-width: 641px) {
    .th-schedule-edit__day .th-list-picker__panel {
        min-width: 16rem;
        right: auto;
    }
}

/* Notes row carries the textarea + the Cancel/Save icon group on the
   right. The textarea flexes to fill; the icon group anchors to the
   bottom edge so it tracks the textarea's resize handle as the user
   grows Notes vertically. min-width:0 lets the textarea actually shrink
   inside the flex container instead of forcing horizontal overflow. */
.th-schedule-edit__notes-row {
    display: flex;
    align-items: flex-end;
    gap: var(--th-space-2);
}
.th-schedule-edit__notes-row > .th-schedule-edit__notes {
    flex: 1 1 auto;
    min-width: 0;
}

/* Assign-people trigger: real flex container so the badge gets a
   proper gap from the button label instead of sitting flush against it.
   Pushed flush right within .th-schedule-edit__top-row so the row reads
   Highlight ……… Assign people. */
.th-schedule-edit__assign {
    display: inline-flex;
    margin-left: auto;
    align-items: center;
    gap: var(--th-space-2);
}

/* Trash + Cancel + Save icon group. Lives inside .th-schedule-edit__notes-row;
   margin-left:auto pushes it to the right edge of the row. Order matches
   the setlist editor: destructive on the left, then cancel, then save. */
.th-schedule-edit__actions-right {
    margin-left: auto;
    display: flex;
    align-items: center;
    gap: var(--th-space-2);
}

/* Segmented day-offset control. Three radio buttons styled as a single
   pill; the active option fills with the same lavender/purple treatment
   used by the read-row __day-badge so the colour palette stays
   consistent across view and edit. The underlying <input> is visually
   hidden but still focusable. */
.th-day-offset {
    display: inline-flex;
    /* <fieldset> has asymmetric UA padding (≈0.35em top vs 0.625em bottom)
       and inline margins — zero both so the pill sits flush with the time
       inputs on the same row. */
    margin: 0;
    padding: 0;
    /* Stretch to match the time inputs / timezone select height on the
       same row. The parent .th-schedule-edit__time-row uses
       align-items:center so children otherwise size to their own content;
       stretching here lets the pill grow without bumping its label font
       (which the user explicitly liked at its current size). The inner
       __opt flex items inherit `align-items: stretch` from the fieldset,
       and center their text via their own align-items:center. */
    align-self: stretch;
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    /* The tab strip tops out at 6 chips (−1d + 4 dates + +1d) before we flip
       to the dropdown, which fits desktop but is tight on a narrow phone.
       Scroll horizontally as a safety net rather than wrapping; snap so a
       chip never half-clips. Scrollbar chrome is hidden — the strip is short
       and the snap makes the overflow discoverable. */
    overflow-x: auto;
    flex-wrap: nowrap;
    max-width: 100%;
    scroll-snap-type: x proximity;
    scrollbar-width: none;
    background: var(--th-white);
}
.th-day-offset::-webkit-scrollbar { display: none; }
.th-day-offset__opt {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    white-space: nowrap;
    scroll-snap-align: start;
    padding: 3px var(--th-space-2);
    color: var(--th-text-muted);
    font-size: 0.9em;
    cursor: pointer;
    user-select: none;
    border-right: 1px solid var(--th-grey-border);
}
.th-day-offset__opt:last-child { border-right: 0; }
.th-day-offset__opt input { position: absolute; opacity: 0; pointer-events: none; }
.th-day-offset__opt:has(input:checked) {
    background: var(--th-lavender);
    color: var(--th-purple);
    font-weight: 600;
}

/* Read-only chips under a row's notes when the item is restricted.
   AppliesToAll rows render no chips so unchanged schedules look the same
   as before. */
.th-schedule__audience-chips {
    margin-top: var(--th-space-1);
}

/* Day-offset badge + zone tag rendered inline next to the time on view
   rows. Subtle so the time itself stays the dominant glyph. */
/* Day-offset rendered as the concrete date ("12. Jun"), stacked above
   the time when it falls on a previous day and below when it falls on a
   future day — so the date sits on the chronological side of the time
   line. Plain muted text, no chip. */
.th-schedule__day-date {
    display: block;
    color: var(--th-text-muted);
    font-size: 0.85em;
    white-space: nowrap;
}

/* Full-width date divider that separates each day on a multi-day event's
   schedule. The <td> stays a table-cell (it spans every column via colspan
   under table-layout:fixed); the flex layout + purple underline live on an
   inner div so the cell itself keeps normal table flow. Weekday leads, the
   date trails muted, and a lavender "Day N" chip is pushed flush right. */
.th-schedule__day-row td.th-schedule__day-cell {
    /* Zero the cell's own horizontal padding so the divider's underline can
       reach both table edges; the text inset is restored on the inner div
       below, where it sits inside the border box and so doesn't shorten the
       line. (Mobile, where the table reflows to cards, gets a full-width
       override in the max-width:640px block.) */
    padding: var(--th-space-4) 0 0;
    border-bottom: 0;
    background: transparent;
}
.th-schedule__day-row:first-child td.th-schedule__day-cell { padding-top: var(--th-space-2); }
.th-schedule__day-inner {
    display: flex;
    align-items: baseline;
    gap: var(--th-space-2);
    padding: 0 var(--th-space-3) var(--th-space-1);
    border-bottom: 2px solid var(--th-purple);
}
.th-schedule__day-weekday { font-weight: 700; }
.th-schedule__day-full { color: var(--th-text-muted); font-size: 0.9em; }
.th-schedule__day-chip {
    margin-left: auto;
    align-self: center;
    background: var(--th-lavender);
    color: var(--th-purple);
    font-size: 0.75em;
    font-weight: 600;
    padding: 1px var(--th-space-2);
    border-radius: 999px;
}
/* Inline "— Subtitle" rendered after the Title in the schedule's title
   cell. Slightly muted so the Title stays the dominant glyph. */
.th-schedule__subtitle { color: var(--th-text-muted); }

.th-schedule__zone-tag {
    display: inline-block;
    margin-left: var(--th-space-1);
    color: var(--th-text-muted);
    font-size: 0.75em;
    font-variant-numeric: normal;
}

/* "Show for" picker + filter banner above the schedule table. The picker
   is a tiny GET form so the URL is the shareable artifact. */
.th-schedule__filter {
    display: flex;
    align-items: center;
    gap: var(--th-space-2);
    margin-bottom: var(--th-space-2);
    color: var(--th-text-muted);
    font-size: 0.9em;
}
.th-schedule__filter-label { font-weight: 600; }
.th-schedule__filter-select {
    /* Right padding leaves room for the custom caret added app-wide by the
       native-select reset near the end of this file. */
    padding: var(--th-space-1) var(--th-space-2);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-white);
    color: var(--th-text);
    font: inherit;
}
.th-schedule__filter-banner {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--th-space-3);
    margin-bottom: var(--th-space-3);
}
.th-schedule__filter-actions {
    display: inline-flex;
    align-items: center;
    gap: var(--th-space-2);
}

/* ---------- Setlist ----------
   The setlist + tour-song catalogue both reuse .th-schedule for the table
   layout — these rules add the totals row and the two-card add layout
   for the event setlist (Catalogue / Custom side-by-side on wide
   screens, stacked on narrow). */
.th-setlist__total {
    margin: 0 0 var(--th-space-3);
    color: var(--th-text-muted);
    font-variant-numeric: tabular-nums;
}
.th-setlist__total strong { color: var(--th-text); margin-right: var(--th-space-2); }
.th-setlist__count { color: var(--th-text-muted); }
/* Post-run summary — its own row under the est-duration line. */
.th-setlist__finished {
    display: block;
    margin-top: var(--th-space-1);
    color: var(--th-text-muted);
}
.th-setlist__finished[hidden] { display: none; }

/* Event roster overview strip — headline crew count + assigned/unassigned
   split. Same muted, tabular-num treatment as the setlist total so the
   two event sub-pages read consistently. */
.th-roster-summary {
    margin: 0 0 var(--th-space-3);
    color: var(--th-text);
    font-variant-numeric: tabular-nums;
}
.th-roster-summary strong { font-size: 1.1em; }
.th-roster-summary__muted { color: var(--th-text-muted); }

/* Roster overview bar — count on the left, compact download icon pinned to the
   right edge above the crew table. The summary keeps its own bottom margin; the
   bar zeroes it so the icon and text align on one baseline. */
.th-roster-bar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--th-space-3);
    margin-bottom: var(--th-space-3);
}
.th-roster-bar .th-roster-summary,
.th-roster-bar .th-setlist__total { margin-bottom: 0; }
/* Action cluster on the right of the bar — plane (manage flights) + download. */
.th-roster-bar__actions { display: flex; align-items: center; gap: var(--th-space-2); flex: 0 0 auto; }
/* Export icon stays the neutral (muted) icon colour. */
.th-roster-bar__download { flex: 0 0 auto; }
/* Start-the-rundown symbol on the roster row — purple to stand out. Descendant
   selector so the purple wins over the later base .th-icon-btn (text-muted)
   colour. setlist-show.js hides it once the show goes live. */
.th-roster-bar__actions .th-roster-bar__start { flex: 0 0 auto; color: var(--th-purple); }
.th-roster-bar__flights { flex: 0 0 auto; color: var(--th-purple); }
/* Manage-accommodation action — same purple "action" tint as the flights
   button so the two lead the cluster together, left of the PDF downloads. */
.th-roster-bar__housing { flex: 0 0 auto; color: var(--th-purple); }
/* Downloads menu — every PDF export behind one glyph. The <details> reuses
   the shared .th-usermenu popover chrome, but its trigger is an icon button
   (not the bordered nav pill), so reset the disclosure marker + list styling
   the <summary> carries by default. */
.th-roster-bar__downloads { flex: 0 0 auto; }
.th-roster-bar__downloads-trigger { list-style: none; }
.th-roster-bar__downloads-trigger::-webkit-details-marker { display: none; }
.th-roster-bar__downloads-trigger::marker { content: ""; }
/* Edit affordance on the right of the bar — neutral row-tool glyph (not the
   purple export tint), pinned right next to the status/counts. */
.th-roster-bar__edit { flex: 0 0 auto; }


.th-setlist__add {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
    gap: var(--th-space-4);
}

/* Add-track card — one bordered surface holding the tab strip and the
   active form. Replaces the prior two-column "Catalogue / Custom"
   layout; the user opens this via the "Add track" button and switches
   between catalog and custom via the tabs at the top.

   Spans the full container width and uses the same tinted-grey skin
   as .th-schedule__add-card so both event tabs read the same when
   adding a record.

   `interpolate-size` + `transition: height` smoothly animates the
   card growing/shrinking when the active tab swaps (catalog = short
   single-select; custom = title + mm:ss). Blazor's enhanced
   navigation preserves the same DOM node across the tab navigation
   so the transition fires on the content swap. Falls back to an
   instant resize on browsers without interpolate-size support. */
.th-setlist__add-card {
    margin: var(--th-space-3) 0 var(--th-space-5);
    padding: var(--th-space-3);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-grey-light);
    interpolate-size: allow-keywords;
    transition: height 200ms ease-out;
}
.th-setlist__add-actions {
    display: flex;
    justify-content: flex-end;
    gap: var(--th-space-2);
    margin-top: var(--th-space-3);
}
/* Below-table actions (Add track / Reorder / Done). Matches the existing
   add-actions cluster but left-aligned and used in read/reorder mode. */
.th-setlist__top-actions {
    display: flex;
    gap: var(--th-space-2);
    margin-top: var(--th-space-3);
}
/* Comment rows — setlist annotations (intros, set breaks, banter).
   Purple-tinted background so they read as labels, not songs.
   Tighter padding keeps them visually compact relative to song rows. */
.th-schedule__row--comment td {
    padding-top: var(--th-space-1);
    padding-bottom: var(--th-space-1);
    background: color-mix(in srgb, var(--th-purple) 12%, transparent);
    font-style: italic;
}
/* Edit row in comment mode — server stamps th-setlist__editing-comment on
   the <tr> so the background shows immediately without relying on the
   :checked pseudo-class (which can lag behind on morphed DOM elements).
   JS toggles this class on the change event for live toggling. */
.th-setlist__editing-comment td {
    background: color-mix(in srgb, var(--th-purple) 12%, transparent);
}
/* Also handled by :has(:checked) as a CSS-only fallback for browsers
   where the DOM patcher did correctly sync .checked. Both rules are
   harmless together. */
.th-schedule__row--editing:has([data-th-setlist-comment-toggle]:checked) td {
    background: color-mix(in srgb, var(--th-purple) 12%, transparent);
}
/* Per-track note — small grey line under the title, scoped to this one
   setlist row. Block so it drops below the title text; muted + smaller so
   it reads as a secondary annotation, not a second song. */
.th-setlist__title { display: block; }
/* Shared "title + audio player on one line" pattern, used by both the setlist
   (.th-setlist__title-line) and the Tour → Tracks list (.th-track-audio-line):
   the collapsed Play+download (or the revealed bar) sits on the title's row,
   wrapping beneath only when the row is too tight. */
.th-setlist__title-line,
.th-track-audio-line {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--th-space-2);
}
/* Title flexes (and shrinks) so the play/download icons stay on its row even
   on a narrow phone; the title growing right-aligns them (no margin auto
   needed). The wider revealed player still wraps below via flex-wrap. */
.th-setlist__title-line .th-setlist__title,
.th-track-audio-line .th-track-audio-line__title { flex: 1 1 0; min-width: 0; }
.th-setlist__title-line .th-audio,
.th-track-audio-line .th-audio { margin-top: 0; }
/* Collapsed, the Play+download icons ride on the title's row. The moment the
   player is revealed it gets its own full-width line below the title (on every
   viewport, not just phones) — a wide audio bar squeezed onto the title's row
   pinched the title to a few characters. flex-wrap on the title-line drops the
   100%-basis .th-audio onto the next line. */
.th-setlist__title-line:has(.th-audio__player:not([hidden])) .th-audio {
    flex-basis: 100%;
    margin-top: var(--th-space-1);
}
/* On its own line the bar can use the whole cell: let the player grow and
   drop the 28rem desktop cap so the audio bar stretches to fill the row, with
   the L/R + download sitting at the right edge. */
.th-setlist__title-line:has(.th-audio__player:not([hidden])) .th-audio__player {
    flex: 1 1 auto;
}
.th-setlist__title-line:has(.th-audio__player:not([hidden])) .th-audio__el {
    max-width: none;
}
.th-setlist__note {
    display: block;
    margin-top: 2px;
    font-size: 0.85em;
    font-style: normal;
    color: var(--th-text-muted);
    white-space: normal;
}
/* Accumulated stage time through this row — the running total beside the
   row's own duration. Skipped on the first row (would just echo it).
   Inline with a "·" lead so a setlist row stays the same height as a
   schedule row (a stacked second line made the row ~19px taller). On a
   phone it drops back to its own line under the duration — the fixed 5rem
   Duration column there can't hold both on one line; see the mobile block. */
.th-setlist__cumulative,
.th-setlist__countdown {
    margin-left: var(--th-space-2);
    font-size: 0.85em;
    font-weight: normal;
    color: var(--th-text-muted);
    white-space: nowrap;
}
.th-setlist__cumulative::before { content: "· "; }
/* The live countdown to a song's end gets the purple emphasis — it's the
   value that ticks, so it should draw the eye over the muted running total. */
.th-setlist__countdown { color: var(--th-purple); font-weight: 600; }

/* Live show timer bar — sits between the roster summary and the track
   table. JS ticks elapsed/remaining; controls post via fetch. */
.th-setlist-show {
    position: relative;
    margin-bottom: var(--th-space-3);
    padding: var(--th-space-2) var(--th-space-3);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-grey-light);
    font-variant-numeric: tabular-nums;
}
.th-setlist-show--running {
    border-color: color-mix(in srgb, var(--th-purple) 45%, var(--th-grey-border));
    background: color-mix(in srgb, var(--th-purple) 8%, var(--th-grey-light));
}
/* Paused — the setlist is held but the show clock still runs. Amber reads as
   "caution / paused-ish but still live". */
.th-setlist-show--paused {
    border-color: color-mix(in srgb, #c77800 45%, var(--th-grey-border));
    background: color-mix(in srgb, #c77800 10%, var(--th-grey-light));
}
/* Before the show starts the grey box is hidden — starting is the small symbol
   up in the roster bar. The box appears once armed/running and stays through
   stop. The status class is server-rendered and kept in sync by setlist-show.js
   so a no-reload start reveals it. */
.th-setlist-show--notstarted { display: none; }
/* Armed — the roster Start symbol reveals the grey control bar (prev / play /
   next) without starting the clock; the × closes it again. Client-only
   (NotStarted + this class), so it overrides the display:none. */
.th-setlist-show--notstarted.th-setlist-show--armed { display: block; }
/* Collapsed — the × dismisses the stopped bar back to the roster launcher
   (the run stays stopped; pressing the launcher re-opens it). Client-only. */
.th-setlist-show--stopped.th-setlist-show--collapsed { display: none; }
/* Transport buttons read as buttons — a resting border so they're identifiable
   even before hover. Clickable ones are purple; disabled ones fall back to the
   muted colour (and fade via the shared :disabled opacity) so "can press" vs
   "not right now" is obvious at a glance. */
.th-setlist-show__actions .th-icon-btn {
    border-color: var(--th-grey-border);
    color: var(--th-purple);
}
.th-setlist-show__actions .th-icon-btn:disabled { color: var(--th-text-muted); }
.th-setlist-show__close {
    position: absolute;
    top: var(--th-space-1);
    right: var(--th-space-1);
    width: 1.5rem;
    height: 1.5rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    border: 0;
    border-radius: var(--th-radius);
    background: transparent;
    color: #e0a800;
    font-size: 1.1rem;
    line-height: 1;
    cursor: pointer;
}
.th-setlist-show__close:hover,
.th-setlist-show__close:focus { background: color-mix(in srgb, #e0a800 18%, transparent); }
.th-setlist-show__close[hidden] { display: none; }

/* Performance mode — active whenever the grey control box is visible (armed,
   running, paused, or stopped-and-not-dismissed): hide the per-song edit
   pencils and the Add track / Reorder actions so nothing editable is in reach
   mid-show. The :has() list enumerates exactly the box's visible states (the
   same classes JS toggles), so it reacts immediately to arm / start / × close.
   The action cell is removed (display:none) so its column collapses — no
   leftover empty cell where the pencil was. */
body:has(.th-setlist-show--running,
         .th-setlist-show--paused,
         .th-setlist-show--notstarted.th-setlist-show--armed,
         .th-setlist-show--stopped:not(.th-setlist-show--collapsed)) .th-setlist__top-actions {
    display: none;
}
body:has(.th-setlist-show--running,
         .th-setlist-show--paused,
         .th-setlist-show--notstarted.th-setlist-show--armed,
         .th-setlist-show--stopped:not(.th-setlist-show--collapsed))
    .th-schedule--setlist .th-schedule__row:not(.th-schedule__row--editing) .th-schedule__actions {
    display: none;
}

.th-setlist-show__main {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--th-space-3);
}
.th-setlist-show__stats {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--th-space-2);
    color: var(--th-text);
}
.th-setlist-show__clock strong { color: var(--th-purple); }
.th-setlist-show__badge {
    display: inline-flex;
    align-items: center;
    padding: 0.1em 0.55em;
    border-radius: 999px;
    font-size: 0.75rem;
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
}
.th-setlist-show__badge--live {
    color: #fff;
    background: var(--th-purple);
}
.th-setlist-show__badge--paused {
    color: #fff;
    background: #c77800;
}
.th-setlist-show__actions {
    display: flex;
    flex-wrap: nowrap;
    flex-basis: 100%;
    width: 100%;
    gap: var(--th-space-2);
}
/* Spread the transport controls edge-to-edge across the show bar, like a
   media player's control row — each visible button takes an equal share, and
   the [hidden] ones (the inactive start/resume/pause variants) take none. */
.th-setlist-show__actions .th-icon-btn {
    flex: 1 1 0;
}
/* These carry an author `display` (inline-flex / inline) that beats the UA
   `[hidden] { display: none }`, so the `hidden` attribute alone wouldn't
   collapse them — the same trap restated for the audio player, flight panel,
   etc. The show bar toggles its badges and live readouts purely via `hidden`,
   so restate it or the wrong status badge / a stale counter keeps showing. */
.th-setlist-show__badge[hidden],
.th-setlist__live[hidden] { display: none; }
/* Animatable progress percentage for the playing row's fill — registering it
   lets the gradient stop transition smoothly between the per-second ticks
   instead of jumping. Falls back to stepping where @property is unsupported. */
@property --th-progress {
    syntax: '<percentage>';
    inherits: false;
    initial-value: 0%;
}
/* Current song during a live show — the whole row is tinted purple and
   doubles as a progress bar: the gradient's hard stop sits at --th-progress
   (0% by default, ticked by setlist-show.js / stamped server-side), so the
   elapsed portion reads deeper purple and the remainder lighter. Painted on
   the row box (not the cells) so the fill runs continuously across the full
   width — including the grid column-gaps on mobile — instead of restarting in
   each cell. Scoped under .th-schedule so it outranks the mobile reflow's
   white `tr` background. */
.th-schedule .th-setlist__row--current {
    background: linear-gradient(to right,
        color-mix(in srgb, var(--th-purple) 32%, transparent) var(--th-progress, 0%),
        color-mix(in srgb, var(--th-purple) 11%, transparent) var(--th-progress, 0%));
    transition: --th-progress 1s linear;
}
.th-setlist__row--current td {
    background: transparent;
}
/* Played / skipped songs keep the purple highlight — a fully-filled row (the
   same elapsed colour as the progress bar at 100%) so the show's progress
   reads down the list. Their text recedes slightly to set them apart from the
   actively-playing row. Scoped under .th-schedule to beat the mobile reflow's
   white `tr` background, like the current-row rule above. */
.th-schedule .th-setlist__row--played {
    background: color-mix(in srgb, var(--th-purple) 32%, transparent);
}
.th-setlist__row--played td {
    background: transparent;
    opacity: 0.72;
}
/* A played comment row takes the played colour too — outrank its own comment
   tint, which otherwise paints over it (the td on desktop, the tr on mobile). */
.th-schedule .th-schedule__row--comment.th-setlist__row--played {
    background: color-mix(in srgb, var(--th-purple) 32%, transparent);
}
.th-schedule__row--comment.th-setlist__row--played td {
    background: transparent;
}
.th-setlist__live {
    display: inline;
    margin-left: var(--th-space-2);
    font-size: 0.85em;
    font-weight: 600;
    color: var(--th-purple);
    white-space: nowrap;
}
.th-setlist__live::before { content: "· "; font-weight: normal; color: var(--th-text-muted); }
.th-setlist__remaining {
    display: inline;
    margin-left: var(--th-space-2);
    font-size: 0.85em;
    color: var(--th-text-muted);
    white-space: nowrap;
}
.th-setlist__remaining::before { content: "· "; }
/* Note input on the inline edit row — sits under the title input/picker.
   Higher specificity than the generic edit-row text-input rule so the
   muted/compact look wins. */
.th-schedule__row--editing input[type="text"].th-setlist__note-input {
    margin-top: var(--th-space-1);
    height: 28px;
    font-size: 0.85em;
    color: var(--th-text-muted);
}
/* Inline Comment toggle that sits next to the m:ss inputs in the edit
   row. Compact so the duration cell doesn't blow out — checkbox + label
   on one line, small gap. The --inline variant flips the layout to a
   row instead of the form-card's column stack. */
.th-setlist__comment-toggle {
    display: flex;
    align-items: center;
    gap: var(--th-space-1);
    cursor: pointer;
    font-size: 0.9em;
    color: var(--th-text-muted);
    user-select: none;
}
.th-setlist__comment-toggle input[type="checkbox"] {
    margin: 0;
    accent-color: var(--th-purple);
}
.th-setlist__comment-toggle--inline {
    margin-bottom: 0;
}
/* Inline edit row's duration cell — Comment toggle on the left,
   m:ss inputs on the right. Row direction so the cell stays at the
   read-row's 32px footprint instead of stacking vertically and
   pushing the row taller than its read-mode neighbours. */
.th-setlist__duration-cell {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--th-space-2);
}
/* AddCustom card only — generous vertical rhythm around the Comment
   toggle so it sits centred between the title input and the m:ss
   row instead of crowding against the duration fields. Edit-row's
   `--inline` variant has its own (zero) margin so this rule scopes
   to the add card's wrapper. */
[data-th-setlist-comment-scope] > .th-setlist__comment-toggle {
    margin: var(--th-space-3) 0;
}

/* ---------- Stage-effect flags (Pyro / Konfetti / Guest vocals) ---------- */
/* Inline icon group beside the title in the read + reorder rows and the
   catalogue list. Each glyph is coloured by effect so they read at a glance. */
.th-setlist__fx {
    display: inline-flex;
    align-items: center;
    gap: var(--th-space-1);
    flex: 0 0 auto;
}
.th-setlist__fx-icon {
    display: inline-flex;
    align-items: center;
    line-height: 0;
}
.th-setlist__fx-icon svg {
    width: 15px;
    height: 15px;
}
.th-setlist__fx-icon--pyro { color: #e8590c; }
.th-setlist__fx-icon--confetti { color: var(--th-purple); }
.th-setlist__fx-icon--guest { color: #0c8599; }
/* Edit-row effect cluster: the three toggles on one wrapping line. The
   comment field sits on the row beneath (.th-setlist__comment-input). */
.th-setlist__fx-edit {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--th-space-2);
    margin-top: var(--th-space-1);
}
/* Always-visible comment field on its own full-width line. box-sizing +
   max-width keep it inside the edit cell so it never overlaps the
   duration/actions columns. */
.th-setlist__comment-input {
    display: block;
    width: 100%;
    max-width: 100%;
    box-sizing: border-box;
    margin-top: var(--th-space-1);
}

/* Compact tab strip inside the add card. Same purple-underline pattern
   as .th-event-tabs but scaled for a card-level mode picker — no need
   for the mobile horizontal scroller treatment (only two tabs). */
.th-add-tabs {
    display: flex;
    gap: var(--th-space-2);
    border-bottom: 2px solid var(--th-grey-border);
    margin: 0 calc(var(--th-space-3) * -1) var(--th-space-3);
    padding: 0 var(--th-space-3);
}
.th-add-tabs__tab {
    display: inline-flex;
    align-items: center;
    min-height: var(--th-tap-target);
    padding: 0 var(--th-space-3);
    text-decoration: none;
    color: var(--th-text);
    border-bottom: 2px solid transparent;
    margin-bottom: -2px;
    font-weight: 500;
}
.th-add-tabs__tab--active {
    color: var(--th-purple-vivid);
    border-bottom-color: var(--th-purple-vivid);
}

/* Compact mm:ss duration input pair used on the inline edit row of the
   tracks catalogue and setlist. Lays the two number inputs out tight
   with a literal ":" between them so the row stays narrow. */
.th-track-duration {
    display: inline-flex;
    align-items: center;
    gap: var(--th-space-1);
}
/* Inline edit row inputs (title / duration / catalogue select). The bare
   <input>s would otherwise fall back to browser-default chrome — short,
   thin border, no rounded corners — and read as inconsistent with the
   surrounding form chrome. Pin them to a bordered/rounded look BUT
   match the read row's vertical footprint: 32px to align with the
   .th-icon-btn pencil that defines the read row's height. Using the
   full 44px tap target here would stretch the edit row taller than
   read and the table would visibly jump as you toggle pencil → edit
   → save. Tighter padding for the same reason. Width 100% so the title
   input fills its cell; the m:ss duration pair gets its compact 3.5em
   re-pinned by `.th-track-duration input` below in source order. */
.th-schedule__row--editing input[type="text"],
.th-schedule__row--editing input[type="number"],
.th-schedule__row--editing select {
    box-sizing: border-box;
    width: 100%;
    height: 32px;
    padding: 0 var(--th-space-2);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    font: inherit;
    line-height: 1.2;
    background: var(--th-white);
    color: var(--th-text);
}
.th-schedule__row--editing input:focus,
.th-schedule__row--editing select:focus {
    outline: 2px solid var(--th-purple);
    outline-offset: 1px;
    border-color: var(--th-purple);
}
/* The generic .th-contacts-row--editing/--adding input rule below sets
   width:100% on every input inside an inline editor; that would stretch
   the m:ss number inputs across the grid cell. Re-pin them to a compact
   numeric width at higher specificity so the duration pair reads as a
   single tight unit instead of two giant boxes flanking the colon. The
   3.5em width fits a 3-digit value ("180") with the inline editor's
   4px/8px padding intact and centers the typed digits visually. */
.th-track-duration input,
.th-contacts-row .th-track-duration input {
    width: 3.5em;
    text-align: center;
}
/* Hide the native number-input spinner chrome — for an m:ss pair the
   spinner arrows steal width without adding usefulness (users type the
   number, they don't click +/- to seek through 60 values). Pairs with
   the compact width above so the field reads as one numeric token. */
.th-track-duration input[type="number"]::-webkit-inner-spin-button,
.th-track-duration input[type="number"]::-webkit-outer-spin-button {
    -webkit-appearance: none;
    margin: 0;
}
.th-track-duration input[type="number"] { -moz-appearance: textfield; }

/* Footer that anchors the "Add track" button below the catalogue table —
   matches the .th-contacts-list__footer spacing. */
.th-tracks__footer { margin-top: var(--th-space-3); }

/* ---------- Track audio player (Components/Shared/AudioPlayer.razor) ---------- */

/* Inline player: native <audio> transport on the left, the Both/L/R channel
   solo group + download on the right. Wraps on narrow widths so the audio bar
   keeps its usable length on a phone instead of being squeezed. */
.th-audio {
    display: flex;
    align-items: center;
    gap: var(--th-space-2);
    margin-top: var(--th-space-2);
}
/* Collapsed state: a flat Play button that matches the download icon beside it.
   The player stays hidden (and unfetched) until it's clicked — see
   audio-player.js. */
.th-audio__open {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 34px;
    height: 34px;
    padding: 0;
    border: 0;
    border-radius: var(--th-radius-2, 8px);
    background: transparent;
    color: var(--th-text-muted);
    cursor: pointer;
}
.th-audio__open:hover { color: var(--th-text); background: var(--th-grey-hover, rgba(0,0,0,0.05)); }
.th-audio__open svg { width: 18px; height: 18px; margin-left: 1px; } /* optical-center the triangle */
/* Same hidden + display:inline-flex trap as the player — restate display:none
   so the button actually disappears once the player is revealed. */
.th-audio__open[hidden] { display: none; }
/* Small close (×) button on the revealed player — collapses it back to Play. */
.th-audio__close {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 28px;
    height: 28px;
    padding: 0;
    border: 0;
    border-radius: var(--th-radius-2, 8px);
    background: transparent;
    color: var(--th-text-muted);
    cursor: pointer;
    flex: 0 0 auto;
}
.th-audio__close:hover { color: var(--th-text); background: var(--th-grey-hover, rgba(0,0,0,0.05)); }
.th-audio__close svg { width: 16px; height: 16px; }
/* hidden + display:inline-flex trap — restate so the collapsed state hides it. */
.th-audio__close[hidden] { display: none; }
/* Revealed player — the flex row the collapsed state expands into. */
.th-audio__player {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--th-space-2) var(--th-space-3);
}
/* UA `[hidden] { display: none }` loses to the flex display above, so restate
   it (the hidden + display:flex trap). */
.th-audio__player[hidden] { display: none; }
.th-audio__el {
    flex: 1 1 16rem;
    min-width: 12rem;
    height: 34px;
    max-width: 28rem;
}
.th-audio__controls {
    display: inline-flex;
    align-items: center;
    gap: var(--th-space-2);
}
/* hidden + display:inline-flex trap — collapse the controls when not revealed. */
.th-audio__controls[hidden] { display: none; }
/* Segmented Both/L/R control — a single pill split into three buttons. The
   active segment fills purple (the app's "this is on" colour), matching the
   focus-ring / active-state convention used elsewhere. */
.th-audio__channels {
    display: inline-flex;
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius-2, 8px);
    overflow: hidden;
}
.th-audio__ch {
    appearance: none;
    border: 0;
    border-left: 1px solid var(--th-grey-border);
    background: var(--th-surface);
    color: var(--th-text);
    font: inherit;
    font-size: 0.8rem;
    font-weight: 600;
    line-height: 1;
    padding: 6px 10px;
    min-width: 2.5rem;
    cursor: pointer;
}
.th-audio__ch:first-child { border-left: 0; }
.th-audio__ch:hover { background: var(--th-grey-hover, rgba(0,0,0,0.05)); }
.th-audio__ch--active,
.th-audio__ch--active:hover {
    background: var(--th-purple, #6A1B9A);
    color: #fff;
}
.th-audio__download {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 34px;
    height: 34px;
    color: var(--th-text-muted);
    border-radius: var(--th-radius-2, 8px);
}
.th-audio__download:hover { color: var(--th-text); background: var(--th-grey-hover, rgba(0,0,0,0.05)); }
.th-audio__download svg { width: 18px; height: 18px; }

/* Catalogue-page audio strip — spans the full track row beneath Title /
   Duration / Actions so the player gets the row's whole width. Used on the
   read row (player only) and inside the edit row's audio sub-line. */
.th-track-audio {
    grid-column: 1 / -1;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--th-space-2) var(--th-space-4);
    padding-top: var(--th-space-1);
}
/* The edit row now carries two stacked children — the title/duration line and
   the audio sub-line — so collapse it to a single column (same as the other
   inline editors: --with-role / --with-position / --templates) and let each
   child take the full width on its own grid row. Without this the audio
   sub-line would auto-place into the narrow second column beside the line. */
.th-contacts-list--tracks .th-contacts-row--editing {
    grid-template-columns: 1fr;
    /* Positioning context for the upload progress line that spans the top. */
    position: relative;
}
/* Purple upload progress line — pinned to the top edge of the editing row,
   spanning its full width. JS sets the width from real XHR upload progress
   (0 → 100%); the transition smooths between progress events. */
.th-track-audio__progress {
    position: absolute;
    top: 0;
    left: 0;
    height: 3px;
    width: 0;
    background: var(--th-purple-vivid, #7B1FA2);
    border-radius: 3px;
    transition: width 0.15s ease-out;
    pointer-events: none;
}
/* Edit-row audio sub-line: player (when present) + Remove-audio button +
   the "Upload/Replace · max N MB" hint. Sits under the title/duration line
   inside the edit <EditForm>; the actual upload is driven by the upload icon
   in the action cluster (a <label for> pointing at the detached file input). */
.th-track-audio__edit {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--th-space-2) var(--th-space-3);
    margin-top: var(--th-space-2);
}
.th-track-audio__remove-btn { font-size: 0.8rem; padding: 4px 10px; }
/* Edit-row "Default channel" segmented picker. Mirrors the player's Both/L/R
   control (.th-audio__ch) but is a radio group bound to the edit form, so the
   chosen side saves with the row. The radios themselves are visually hidden;
   their labels carry the button look and the :checked label lights purple. */
.th-track-channel__label {
    font-size: 0.8rem;
    color: var(--th-text-muted);
}
.th-track-channel {
    display: inline-flex;
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius-2, 8px);
    overflow: hidden;
}
.th-track-channel__radio {
    position: absolute;
    width: 1px;
    height: 1px;
    opacity: 0;
    pointer-events: none;
}
.th-track-channel__btn {
    border-left: 1px solid var(--th-grey-border);
    background: var(--th-surface);
    color: var(--th-text);
    font-size: 0.8rem;
    font-weight: 600;
    line-height: 1;
    padding: 6px 10px;
    min-width: 2.5rem;
    text-align: center;
    cursor: pointer;
}
.th-track-channel__radio:first-child + .th-track-channel__btn { border-left: 0; }
.th-track-channel__btn:hover { background: var(--th-grey-hover, rgba(0,0,0,0.05)); }
.th-track-channel__radio:checked + .th-track-channel__btn {
    background: var(--th-purple, #6A1B9A);
    color: #fff;
}
/* Keep the focus ring purple even though the real radio is hidden — light the
   selected label when its radio is focused via keyboard. */
.th-track-channel__radio:focus-visible + .th-track-channel__btn {
    outline: 2px solid var(--th-purple-vivid, #7B1FA2);
    outline-offset: -2px;
}
/* The upload icon is a <label> — keep it visually identical to sibling icon
   buttons and show the click affordance. */
.th-track-audio__upload-icon { cursor: pointer; }
/* While a picked file uploads, pulse the icon and block re-clicks so a large
   upload reads as in-progress rather than "nothing happened". */
.th-track-audio__upload-icon--busy {
    pointer-events: none;
    animation: th-audio-pulse 1s ease-in-out infinite;
}
@keyframes th-audio-pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.35; }
}
/* The detached multipart upload form only exists to carry the hidden file
   input the upload-icon label opens; take it out of layout entirely. */
.th-audio-upload-form {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
}

/* Drag handle on the setlist editing row's # cell. Visible on hover-
   capable pointers (mouse / pen) as the affordance for drag-to-position;
   hidden on touch screens where the ↑↓ buttons in the actions cluster
   take over (drag-and-drop is awkward with thumbs). The split is by
   pointer type — not viewport width — so a touch laptop still gets the
   right control regardless of window size. */
.th-schedule__grip {
    display: none;
    cursor: grab;
    user-select: none;
    color: var(--th-text-muted);
    font-weight: 700;
    letter-spacing: -2px;
    margin-right: var(--th-space-1);
    padding: 0 var(--th-space-1);
    touch-action: none;
    /* Match the .th-icon-btn's 32px vertical footprint so the
       reorder row sits at the same height as the read row. Pinning
       it with line-height (instead of width/height on an inline-
       flex box) keeps the grip a simple inline-block that doesn't
       repaint its glyph position when the drag state toggles —
       earlier the inline-flex centering + color swap (grey → purple)
       made the ⋮⋮ visually shift toward the row number on drag,
       which read as the gap shrinking. */
    line-height: 32px;
    vertical-align: middle;
}
.th-schedule__grip[data-th-setlist-dragging] {
    cursor: grabbing;
    /* Colour stays the same on drag — switching to purple repaints
       the glyph just enough that the eye reads it as "the grip moved
       a few pixels right", which is the gap-shrink complaint. The
       row's shadow + reordering layout do the "I'm holding this"
       signalling on their own. */
}
/* Lifted-card look while a row is being dragged. The JS adds a
   translate3d() offset on the row to follow the cursor between
   swap thresholds, so the row visually leaves its slot — the
   shadow + background sell the "pulled out of the table" feel.

   Deliberately NOT setting `position: relative` here: in Chromium,
   a positioned <tr> makes its own offsetParent skip the <table>
   and land on <body>, so `row.offsetTop` switches from a table-
   relative ~37px to the viewport-relative ~430px. The JS swap
   logic compares that against the other rows' (still table-
   relative) offsetTops and concludes every move belongs at the
   last slot, so every drag dropped to the bottom. The transform
   we're about to apply already creates its own stacking context,
   so the row paints above its neighbours without needing a
   positioned context — and `will-change: transform` also creates
   one before the first frame, so the lift is consistent from
   pointerdown. */
.th-schedule__row[data-th-setlist-dragging] {
    /* drop-shadow (not box-shadow) because the row's `border-collapse:
       collapse` table context paints cells flush against the row's
       outline and a box-shadow on the <tr> ends up trapped behind the
       cells. drop-shadow follows the painted cell silhouette, so the
       lift reads above the lavender wash and the rows below/above. */
    filter: drop-shadow(0 14px 22px rgba(0, 0, 0, 0.38))
            drop-shadow(0 4px 8px rgba(0, 0, 0, 0.22));
    will-change: transform, filter;
    /* The dragged row tracks the cursor with translate3d, so it sits
       visually under the pointer. Without this, elementFromPoint —
       which JS used to use to pick the swap target — would return the
       dragged row itself. We've since switched the swap to offsetTop
       lookups, but we keep this so other pointer-driven UI (selection,
       tooltip handlers, etc.) doesn't engage on the floating row. */
    pointer-events: none;
}
/* Dim purple wash on the cells of the row you're currently carrying.
   Same --th-lavender that .th-schedule__row--now uses, so the
   "selected/active" colour stays consistent across the schedule UI.
   Applied to td (not tr) because cell backgrounds reliably paint
   above row backgrounds — matches the --highlight/--now precedent
   above. */
.th-schedule__row[data-th-setlist-dragging] td {
    background: var(--th-lavender);
}
/* Suppress text/element selection across the page while a drag is
   in flight — without this the pointer move sweeps a selection
   over the title cell / neighbour rows and you end up with a blue
   smear under the row you're carrying. Body attribute is set/cleared
   by the same JS that owns the gesture. */
body[data-th-setlist-dragging] {
    user-select: none;
    cursor: grabbing;
}
/* Hover-capable pointer (mouse / pen) — show the grip, hide the
   mobile-only ↑↓ arrows. The arrow selector mirrors .th-icon-btn (which
   is defined further down with `display: inline-flex`); a single-class
   override would lose to the later definition in source order, so we
   chain the base class to win on (0,2,0) specificity. */
@media (hover: hover) and (pointer: fine) {
    .th-schedule__grip { display: inline-block; }
    .th-icon-btn.th-setlist__move-arrow { display: none; }
}

/* Brief highlight on the row that was just moved in reorder mode.
   Helps the eye follow the item across the list — especially on
   touch where the page rerenders without a smooth drag animation. */
@keyframes th-setlist-just-moved {
    0%   { background-color: color-mix(in srgb, var(--th-purple) 28%, transparent); }
    100% { background-color: transparent; }
}
.th-schedule__row--just-moved {
    animation: th-setlist-just-moved 1.6s ease-out;
}

/* ---------- Public daysheet ---------- */

/* Public-page wrapper (PublicLayout, used by /daysheet and /crewlist).
   No app header above this, so the content gets its own breathing room
   from the viewport edge. Padding scales up past 540px because phones
   need every horizontal pixel for the schedule. */
.th-public {
    padding: var(--th-space-3);
    outline: none;
}
@media (min-width: 540px) {
    .th-public { padding: var(--th-space-5) var(--th-space-4); }
}

.th-daysheet { max-width: 48rem; margin: 0 auto; }
.th-daysheet__header { margin-bottom: var(--th-space-5); }
.th-daysheet__tenant { color: var(--th-text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.85em; }
.th-daysheet__date { display: block; color: var(--th-text-muted); margin-top: var(--th-space-1); }
.th-daysheet__title { margin: var(--th-space-2) 0 var(--th-space-1); }

/* Address + Maps button row. Stack on phones (button full-width below
   address) and side-by-side on tablet+. The button is intentionally a
   primary CTA on the public page — no admin chrome competing — so it
   reads as the obvious "tap to get directions" action. */
.th-daysheet__where {
    display: flex;
    flex-direction: column;
    gap: var(--th-space-3);
    align-items: stretch;
    margin: var(--th-space-3) 0 0;
}
.th-daysheet__address {
    color: var(--th-text-muted);
    font-style: normal;
    line-height: 1.5;
}
.th-daysheet__address strong { color: var(--th-text); }
.th-daysheet__map {
    align-self: stretch;
    justify-content: center;
    gap: var(--th-space-2);
}
.th-daysheet__map-icon { width: 18px; height: 18px; }
@media (min-width: 540px) {
    .th-daysheet__where {
        flex-direction: row;
        align-items: flex-start;
        justify-content: space-between;
    }
    .th-daysheet__map { align-self: auto; flex: 0 0 auto; }
}

/* Day-offset chip on the section heading — flags a setup-/load-out-day
   so the reader can spot a "Day −1" call without checking the date
   against the event range. Sits inline with the date so a single line
   carries both pieces of information. */
.th-daysheet__day-offset {
    display: inline-block;
    margin-right: var(--th-space-2);
    padding: 0 var(--th-space-2);
    background: var(--th-lavender);
    color: var(--th-purple);
    border-radius: var(--th-radius);
    font-size: 0.75em;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    vertical-align: middle;
}

.th-daysheet__day { margin-bottom: var(--th-space-5); }
.th-daysheet__day-heading { font-size: 1.1em; margin: 0 0 var(--th-space-2); padding-bottom: var(--th-space-1); border-bottom: 1px solid var(--th-grey-light); }
.th-daysheet__schedule { width: 100%; border-collapse: collapse; }
.th-daysheet__schedule td { padding: var(--th-space-2) var(--th-space-3); border-bottom: 1px solid var(--th-grey-light); vertical-align: top; }
.th-daysheet__row--highlight td { background: var(--th-highlight-bg); }
.th-daysheet__time { white-space: nowrap; font-variant-numeric: tabular-nums; width: 1%; }
.th-daysheet__time--empty { color: var(--th-text-muted); }

/* Public-crewlist avatar column — narrow leading tile column. Hugs
   intrinsic content width (1% + nowrap is the classic "shrink-wrap" cell)
   so the rest of the row gets every remaining pixel for the Position
   and Crew member values. */
.th-daysheet__avatar-col { width: 1%; }
.th-daysheet__avatar-cell {
    width: 1%;
    white-space: nowrap;
    vertical-align: middle;
}
/* Download icon, right-aligned above the public crewlist table. */
.th-daysheet__toolbar {
    display: flex;
    justify-content: flex-end;
    margin-bottom: var(--th-space-2);
}
.th-daysheet__toolbar .th-icon-btn { color: var(--th-purple); }
/* Per-member comment line under a crewlist row — parenthesised, muted, italic,
   spanning the data columns. The leading avatar cell stays empty so the comment
   aligns under the Position/Name block. */
.th-daysheet__comment-row td {
    padding-top: 0;
    color: var(--th-text-muted);
    font-size: 0.9em;
    font-style: italic;
}
/* Drop the divider between a row and its own comment so the comment groups with
   the row above it; the comment row keeps the bottom border that closes the pair. */
.th-daysheet__row:has(+ .th-daysheet__comment-row) td { border-bottom: none; }
.th-daysheet__subtitle { color: var(--th-text-muted); font-weight: normal; }
.th-daysheet__notes { color: var(--th-text-muted); font-size: 0.95em; margin-top: var(--th-space-1); }
.th-daysheet__empty { color: var(--th-text-muted); font-style: italic; }

.th-daysheet__event-notes { background: var(--th-grey-light); padding: var(--th-space-3); border-radius: var(--th-radius); margin-bottom: var(--th-space-5); }
.th-daysheet__event-notes p { white-space: pre-wrap; margin: 0; }

.th-daysheet__footer { text-align: center; color: var(--th-text-muted); margin-top: var(--th-space-5); }

/* Phone-first tweaks for the public daysheet/crewlist. The whole page is
   read on someone's phone in a venue, often one-handed, so we trade some
   side padding for vertical breathing room and bump the title smaller
   so the venue address is still above the fold. */
@media (max-width: 540px) {
    .th-daysheet__title { font-size: 1.4em; line-height: 1.2; }
    .th-daysheet__schedule td {
        padding: var(--th-space-1) var(--th-space-2);
        font-size: 0.95em;
    }
    /* Time column hugs its content so the item title takes the rest of
       the row — keeps each schedule line on one row on a narrow phone. */
    .th-daysheet__time { padding-left: 0; }
    .th-daysheet__item { padding-right: 0; }
}

@media print {
    .th-app__header, .th-app__footer { display: none; }
    .th-daysheet { max-width: none; }
    .th-daysheet__footer small { color: #000; }
    /* "Open in Maps" is meaningless on paper — drop it from the print
       view so the address breathes and there's no orphan button. */
    .th-daysheet__map { display: none; }
}

/* ---------- Event details tab ---------- */

/* Two-column grid: Venue on the left, Public links on the right. Notes
   spans the full row above when present. Collapses to one column under
   ~640px so the labels and URLs keep their breathing room. */
.th-event-details {
    display: grid;
    /* Single column: each Info sub-page (Venue / Accommodation / Flights /
       Transport) now renders one topic, so blocks stack full-width. (The old
       two-column split paired Venue with the Public links list, which has
       since moved to its own per-content pages.) */
    grid-template-columns: 1fr;
    gap: var(--th-space-5);
}
.th-event-details__block { min-width: 0; }
.th-event-details__block--full { grid-column: 1 / -1; }
/* Info overview: Notes spans the top; the other sections fill two INDEPENDENT
   columns, distributed round-robin in source order (1st left, 2nd right, 3rd
   left…). Each column is its own flex stack, so a tall card never forces a gap
   or pushes the other column's cards down (no shared grid rows). Mobile-first
   single column; two columns once there's room. */
.th-info-cols {
    display: flex;
    flex-direction: column;
    gap: var(--th-space-5);
}
.th-info-col {
    display: flex;
    flex-direction: column;
    gap: var(--th-space-5);
    min-width: 0;
}
@media (min-width: 48rem) {
    .th-info-cols { flex-direction: row; align-items: flex-start; }
    .th-info-col { flex: 1 1 0; }
}
/* Hairline rule under each section, delineating one from the next — full-width
   under Notes, per-card in the columns. Notes also gets a gap to the columns. */
.th-info-overview .th-event-details__block {
    border-bottom: 1px solid var(--th-grey-border);
    padding-bottom: var(--th-space-4);
}
.th-info-overview > .th-event-details__block { margin-bottom: var(--th-space-5); }
/* A lone / last top-level card (e.g. a notes-only overview) shouldn't trail a
   dangling divider under it — applies on every screen size. */
.th-info-overview > .th-event-details__block:last-child {
    border-bottom: none;
    padding-bottom: 0;
    margin-bottom: 0;
}
/* Mobile (single column): tighten the vertical rhythm between the stacked
   sections, and drop the dangling divider under the very last one. */
@media (max-width: 47.99rem) {
    .th-info-cols,
    .th-info-col { gap: var(--th-space-3); }
    .th-info-overview > .th-event-details__block { margin-bottom: var(--th-space-3); }
    .th-info-overview .th-event-details__block { padding-bottom: var(--th-space-3); }
    .th-info-col:last-child > .th-event-details__block:last-child {
        border-bottom: none;
        padding-bottom: 0;
    }
}
.th-event-details__block > h2 { margin: 0 0 var(--th-space-2); }
.th-event-details__address { font-style: normal; line-height: 1.5; }
.th-event-details__address strong { color: var(--th-text); }
/* Stage + Capacity sit in their own kv group beneath the address. The
   top margin reads as a blank line so the group detaches from the
   venue-as-place block above without overshooting. Double-class
   selector beats the later `.th-kv { margin: 0 }` shorthand reset on
   specificity. */
.th-event-details__stage-capacity.th-kv { margin-top: var(--th-space-4); }
/* ---------- Public link copy (Schedule / Roster / Guestlist) ---------- */

/* Standalone share-link affordance — a label + copy icon, right-aligned on
   its own row beneath the page sub-nav. Replaces the old Info "Public links"
   list now that each link lives with the content it publishes. */
.th-public-link {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: var(--th-space-2);
    margin: 0 0 var(--th-space-3);
}
/* Just label + copy icon — the URL itself isn't rendered, these are
   share-targets and owners only need the copy action. */
.th-public-link__label {
    color: var(--th-text);
    font-weight: 600;
    font-size: 0.9em;
}
/* Copy button piggybacks on .th-icon-btn. Shrink to a compact 28px so it
   sits comfortably next to the label, and flip the resting glyph to
   --th-purple so it reads as "do this" instead of "decorative." */
.th-public-link__copy {
    width: 28px;
    height: 28px;
    color: var(--th-purple);
}
.th-public-link__copy svg { width: 14px; height: 14px; }
/* Solid-green flash via the data-th-copy handler — mirrors the chip
   pattern (`.th-chip.th-just-copied`). 900ms is long enough to register
   without lingering. */
.th-public-link__copy.th-just-copied {
    background: var(--th-success);
    color: var(--th-white);
    border-color: var(--th-success);
}

/* ---------- Accommodation (Info read-summary) ---------- */

/* A block can carry a heading + a right-aligned action (Manage). */
.th-event-details__block-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--th-space-3);
    margin: 0 0 var(--th-space-2);
}
.th-event-details__block-head > h2 { margin: 0; }
/* Action cluster beside the heading (e.g. PDF download + Manage). */
.th-event-details__block-actions {
    display: flex;
    align-items: center;
    gap: var(--th-space-2);
}
.th-accom-summary { margin-bottom: var(--th-space-4); }
.th-accom-summary:last-child { margin-bottom: 0; }
/* Address sits flush under the hotel name (part of the heading), with a gap
   before the indented contents (WiFi, rooms). */
.th-accom-summary__address { font-size: 0.9em; margin-bottom: var(--th-space-2); }
/* Map pin sitting inline beside the hotel name, links out to Google Maps. */
.th-accom-summary__map {
    display: inline-flex;
    align-items: center;
    color: var(--th-purple);
    line-height: 0;
    margin-left: var(--th-space-1);
    vertical-align: -3px;
}
.th-accom-summary__map:hover { color: var(--th-purple-dark); }
.th-accom-summary__map-icon { width: 16px; height: 16px; display: block; }
/* Two levels by indentation: a header row ("dates · type") with one booking
   row per physical room indented beneath it. */
.th-accom-summary__lines,
.th-accom-summary__bookings {
    list-style: none;
    margin: 0;
    padding: 0;
    line-height: 1.6;
}
/* Indent the room list under the hotel name so the hotel is the top level:
   hotel → room line (dates · type) → booking row. */
.th-accom-summary__lines { margin-top: var(--th-space-1); padding-left: var(--th-space-3); }
.th-accom-summary__line + .th-accom-summary__line { margin-top: var(--th-space-2); }
.th-accom-summary__stay-dates { color: var(--th-text-muted); }
.th-accom-summary__type-label { color: var(--th-text); font-weight: 600; }
.th-accom-summary__bookings { padding-left: var(--th-space-3); }
/* Roommates sharing one room are joined by "&"; a lone occupant shows plain. */
.th-accom-summary__crew-name { white-space: nowrap; }
.th-accom-summary__crew-name:not(:last-child)::after { content: " \0026 "; }
.th-accom-summary__booking-note { font-size: 0.9em; }
/* Hotel-level notes on the overview — a muted block under the address, with
   the line breaks typed into the note preserved (matches the room notes'
   pre-line treatment in the editor). */
.th-accom-summary__notes { margin-top: var(--th-space-1); font-size: 0.9em; white-space: pre-line; }

/* ---------- Flights (Info read-summary) ---------- */

/* One block per crew member, mirroring the per-hotel accommodation summary:
   the person's name heads the block with their legs indented beneath it.
   Tight line-height + small per-block gaps keep the list compact. */
.th-flight-summary__lines {
    list-style: none;
    margin: 0;
    padding: 0;
    line-height: 1.45;
}
.th-flight-summary__line { margin-bottom: var(--th-space-3); }
.th-flight-summary__line:last-child { margin-bottom: 0; }
.th-flight-summary__name { color: var(--th-text); font-weight: 600; }
/* Legs (Outbound / Return) indented under the name. */
.th-flight-summary__legs {
    list-style: none;
    margin: 0;
    padding-left: var(--th-space-3);
}
/* Each leg is a single compact line: date/time lead · airport · airline ·
   ref · bags, wrapping only when the row runs out of width. The " · " between
   the date lead and the rest lives in CSS so mobile can drop the trail to its
   own line without a dangling separator. */
.th-flight-summary__leg { font-size: 0.9em; }
.th-flight-summary__lead { color: var(--th-text); font-weight: 600; }
.th-flight-summary__trail::before { content: " · "; }

/* Narrow viewports: the date/time leads on its own line, with every other
   flight detail (route · airline · ref · bags) on the row below. */
@media (max-width: 30rem) {
    .th-flight-summary__trail { display: block; }
    .th-flight-summary__trail::before { content: none; }
}

/* ---------- Accommodation editor (/accommodation) ---------- */

.th-accom-hotel {
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    padding: var(--th-space-3);
    margin-bottom: var(--th-space-4);
}
.th-accom-hotel__head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: var(--th-space-3);
}
.th-accom-hotel__name { margin: 0 0 var(--th-space-1); font-size: 1.1em; }
.th-accom-hotel__address,
.th-accom-hotel__notes { margin: 0 0 var(--th-space-1); }
/* pre-line honours the line breaks typed into the note (matches the room
   notes + the overview summary). */
.th-accom-hotel__notes { color: var(--th-text-muted); white-space: pre-line; }
.th-accom-hotel__edit-actions,
.th-accom-room__edit-actions {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: var(--th-space-2);
    margin-top: var(--th-space-2);
}

/* Rooms nested under a hotel, slightly inset so the hierarchy reads.
   Read rows sit on a tight rhythm (no gap; a hairline divides them) so the
   list scans compactly while each row spreads across the full width. */
.th-accom-rooms {
    margin-top: var(--th-space-3);
    padding-top: var(--th-space-2);
    border-top: 1px solid var(--th-grey-light);
    display: flex;
    flex-direction: column;
    gap: 0;
}
.th-accom-rooms__empty { color: var(--th-text-muted); margin: 0; }
/* Add-room sits to the right, set off from the room list above it. */
.th-accom-rooms__add { align-self: flex-end; margin-top: var(--th-space-2); }
/* Auto-fill + Add-room sit together, right-aligned below the room list. */
.th-accom-rooms__actions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--th-space-2);
    justify-content: flex-end;
    margin-top: var(--th-space-2);
}

.th-accom-room {
    position: relative;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--th-space-1) var(--th-space-5);
    padding: var(--th-space-1) var(--th-space-2) var(--th-space-1) 0;
}
/* Hairline between read rows (not the editing row, which is a card). */
.th-accom-room:not(.th-accom-room--editing) + .th-accom-room:not(.th-accom-room--editing) {
    border-top: 1px solid var(--th-grey-light);
}
/* The editing row keeps its roomier padding (set below); only read rows
   collapse to a normal-line-break rhythm. */
.th-accom-room--editing {
    flex-direction: column;
    align-items: stretch;
    background: var(--th-surface-2, var(--th-grey-light));
    border-radius: var(--th-radius);
    padding: var(--th-space-3);
}
.th-accom-room__main { display: flex; align-items: baseline; gap: var(--th-space-2); }
.th-accom-room__label { font-weight: 600; }
.th-accom-room__dates { color: var(--th-text-muted); font-size: 0.92em; }
/* Crew fills the space between the room identity and the edit pencil so the
   row spreads across the full width. */
.th-accom-room__crew {
    display: flex;
    flex-wrap: wrap;
    gap: var(--th-space-1) var(--th-space-2);
    align-items: center;
    flex: 1;
}
/* Plain crew names — like the crew list, not the purple role chips. */
.th-accom-room__crew-name { color: var(--th-text); }
.th-accom-room__crew-name:not(:last-child)::after {
    content: ",";
    color: var(--th-text-muted);
}
.th-accom-room__notes {
    flex-basis: 100%;
    margin: 0;
    color: var(--th-text-muted);
    font-size: 0.9em;
    white-space: pre-line; /* honour the line breaks typed into the note */
}
.th-accom-room__edit { margin-left: auto; }
.th-accom-room__fields { display: flex; flex-direction: column; gap: var(--th-space-3); }
.th-accom-room__datetime { display: flex; gap: var(--th-space-2); flex-wrap: wrap; }
.th-accom-room__assign-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--th-space-3);
    flex-wrap: wrap;
}

.th-accom-actions { margin-top: var(--th-space-2); }
/* Right-aligned toolbar above the hotel list — holds the PDF download. */
.th-accom-toolbar { display: flex; justify-content: flex-end; margin-bottom: var(--th-space-2); }

/* ---------- Transport (Info read-summary) ---------- */

.th-transport-summary {
    list-style: none;
    padding: 0;
    margin: 0;
    line-height: 1.6;
}
.th-transport-summary li { display: block; }
.th-transport-summary__name { font-weight: 600; }
.th-transport-summary__driver { color: var(--th-text); }

/* ---------- Transport editor (/transport) ---------- */

.th-transport-list {
    display: flex;
    flex-direction: column;
    gap: var(--th-space-2);
}
.th-transport {
    position: relative;
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: var(--th-space-1) var(--th-space-3);
    padding: var(--th-space-2) var(--th-space-2) var(--th-space-2) 0;
    border-bottom: 1px solid var(--th-grey-light);
}
.th-transport--editing {
    flex-direction: column;
    align-items: stretch;
    background: var(--th-surface-2, var(--th-grey-light));
    border: 0;
    border-radius: var(--th-radius);
    padding: var(--th-space-2) var(--th-space-3);
}
.th-transport__main { display: flex; align-items: baseline; gap: var(--th-space-2); }
.th-transport__name { font-weight: 600; }
.th-transport__reg { color: var(--th-text-muted); font-size: 0.92em; }
.th-transport__driver {
    display: flex;
    flex-wrap: wrap;
    gap: var(--th-space-1);
    align-items: center;
}
.th-transport__driver-label { color: var(--th-text-muted); font-size: 0.85em; }
.th-transport__edit { margin-left: auto; }
/* Compact editor: the flex gap alone spaces the fields — zero out each
   inner field's own margin-bottom so it doesn't double up and read airy. */
.th-transport__fields { display: flex; flex-direction: column; gap: var(--th-space-2); }
.th-transport__fields .th-field,
.th-transport__fields .th-field-row { margin-bottom: 0; }
.th-transport__fields .th-field { gap: var(--th-space-1); }
.th-transport__edit-actions {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: var(--th-space-2);
    margin-top: var(--th-space-2);
}
.th-transport-actions { margin-top: var(--th-space-3); }

.th-kv__hint { color: var(--th-text-muted); }

/* ---------- Inline edit (EventDetail) ---------- */

.th-inline-edit__trigger {
    background: none;
    border: 1px dashed transparent;
    color: inherit;
    cursor: pointer;
    font: inherit;
    padding: var(--th-space-1) var(--th-space-2);
    margin: calc(var(--th-space-1) * -1) calc(var(--th-space-2) * -1);
    text-align: left;
    border-radius: var(--th-radius);
    width: 100%;
    display: block;
}
.th-inline-edit__trigger:hover, .th-inline-edit__trigger:focus-visible {
    border-color: var(--th-grey-border);
    background: var(--th-grey-light);
    outline: none;
}
.th-inline-edit__input {
    width: 100%;
    padding: var(--th-space-2);
    font: inherit;
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    box-sizing: border-box;
}
.th-inline-edit__input--small { width: 8rem; }
.th-inline-edit__input + .th-inline-edit__input { margin-top: var(--th-space-2); }
.th-inline-edit__row { display: flex; gap: var(--th-space-2); margin-top: var(--th-space-2); }
.th-inline-edit__row .th-inline-edit__input { margin-top: 0; }
.th-inline-edit__unit { color: var(--th-text-muted); margin-left: var(--th-space-2); }
.th-inline-edit__hint { display: block; color: var(--th-text-muted); margin-top: var(--th-space-1); }
.th-inline-edit__actions { display: flex; gap: var(--th-space-2); margin-top: var(--th-space-2); }

.th-link {
    background: none;
    border: 0;
    color: var(--th-purple);
    cursor: pointer;
    font: inherit;
    padding: 0;
    text-decoration: none;
}
.th-visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
}

/* ---------- Form rows (paired fields side-by-side) ---------- */

.th-field-row {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--th-space-3);
}

@media (max-width: 640px) {
    /* Stack the row's fields when there isn't room for two columns —
       but keep them visually rhythmic with the surrounding stand-alone
       .th-field gap, otherwise the Date/EndDate pair collapses against
       each other while Title→Date keeps its normal spacing. Matches
       .th-field's margin-bottom so the cadence reads even top to
       bottom. */
    .th-field-row { grid-template-columns: 1fr; gap: var(--th-space-4); }
}

/* ---------- Contacts list ---------- */

/* Event-contacts table-style grid. Each row is its own grid with the same
   column template so values line up across rows; the same class is reused
   for header, view rows, edit forms, and the add form.

   Outer border uses --th-grey-border (the same token as input borders)
   rather than --th-grey-light: against the off-white --th-page-bg the
   lighter divider barely registered, leaving the table edges floating. */
.th-contacts-list {
    display: flex;
    flex-direction: column;
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-white);
    margin-bottom: var(--th-space-3);
    /* No overflow:hidden — the phone-country picker's absolute panel
       needs to escape the row to render correctly. Border-radius
       clipping is handled via row-level radii instead. */
}
.th-contacts-list > .th-contacts-row:first-child {
    border-top-left-radius: var(--th-radius);
    border-top-right-radius: var(--th-radius);
}
.th-contacts-list > .th-contacts-row:last-child {
    border-bottom-left-radius: var(--th-radius);
    border-bottom-right-radius: var(--th-radius);
}
.th-contacts-row {
    display: grid;
    /* Read-row layout for the event-contacts variant: 2 data columns +
       actions. Each data cell internally stacks two values (Title over
       Name; Phone over Email) so emails get the full column width and
       no longer wrap awkwardly against a narrow Email column. The
       tour-crew variant overrides this template via
       .th-contacts-list--no-company below; the links variant via
       .th-contacts-list--links further down. */
    grid-template-columns:
        minmax(0, 1fr)
        minmax(0, 1.4fr)
        minmax(auto, max-content);
    gap: var(--th-space-3);
    align-items: center;
    padding: var(--th-space-2) var(--th-space-3);
}
/* Identity cell stacks small muted Title over bold Name. Contact-info
   cell stacks Phone link over Email link. flex-column centers the
   single-line case vertically against rows that have both values, so
   a row missing a title or phone still aligns visually. */
.th-contacts-row__identity,
.th-contacts-row__contact-info {
    display: flex;
    flex-direction: column;
    gap: 2px;
    min-width: 0;
}
.th-contacts-row__title {
    font-size: 0.85em;
    color: var(--th-text-muted);
    line-height: 1.2;
}
/* Tour crew list drops the Company column — everyone on a tour belongs to
   the same touring party, so the org name is redundant noise per row. The
   list re-templates to 4 columns: Name, Phone, Email, Actions. */
.th-contacts-list--no-company .th-contacts-row {
    grid-template-columns:
        minmax(0, 1.5fr)
        minmax(0, 1.1fr)
        minmax(0, 1.4fr)
        minmax(auto, max-content);
}
/* Tour crew variant: 4 columns on desktop with a dedicated Role column
   between identity and contact info. Identity stacks Company over Name;
   Contact info stacks Phone over Email. On mobile the row collapses to
   a single stack (handled in the @media block below) and Role uses CSS
   `order` to drop to the bottom of the card. */
.th-contacts-list--with-role .th-contacts-row {
    grid-template-columns:
        auto
        minmax(0, 1.7fr)
        minmax(0, 0.8fr)
        minmax(0, 1.5fr)
        4.5rem;
}
/* The trailing Actions column is a FIXED width (not max-content) so every
   row resolves the four data columns against the same remaining space —
   otherwise a row with both the invite + edit icons sizes its actions cell
   wider than an edit-only row, the fr columns absorb the difference, and
   the table looks ragged from row to row. 4.5rem fits the two 28px row
   icons + gap with a little slack; the icons stay flush-right within it. */
/* Leading avatar column — narrow, just holds the 32px Sm tile. The cell
   centers the tile vertically so it lines up with the name (which sits on
   the first line of the identity stack) instead of floating at the cell's
   top edge next to a 2-line Company-over-Name pair. */
.th-contacts-row__avatar {
    display: flex;
    align-items: center;
    justify-content: center;
    min-width: 32px;
}
/* When the inline edit/add form lives INSIDE the variant list, the
   variant's grid template would otherwise win on specificity and spread
   the form fields across the read-row columns. Restate the single-column
   template at matching specificity so the editor's __line divs stack
   vertically as designed. */
.th-contacts-list--with-role .th-contacts-row--editing,
.th-contacts-list--with-role .th-contacts-row--adding {
    grid-template-columns: 1fr;
}
/* Event-page variant — Title (event-only crew) and Phone are typically
   short and the 1.4fr Email column was wrapping common addresses (
   firstname.lastname@company.example) onto 2–3 lines. Shaves col-1 and
   col-3 to give Email closer to 2fr while keeping wrap behaviour for
   truly long values. Also applied to the read-only Tour contacts table
   on the same page so both lists line up the same way. */
.th-contacts-list--wide-email .th-contacts-row {
    grid-template-columns:
        minmax(0, 1fr)
        minmax(0, 1.2fr)
        minmax(0, 1fr)
        minmax(0, 1.9fr)
        minmax(auto, max-content);
}
/* Event-crew variant: 5 cols on desktop — Position chip | Avatar | Name |
   Contact info | Actions. Position is the primary axis for crew slots
   (it's what the row exists to fill), so it leads instead of trailing
   the identity columns like the tour-crew --with-role variant does.
   Mobile reflow shares the --with-role rules below: position drops to
   the bottom of the card via CSS `order` so name + contact info read as
   the headline. */
.th-contacts-list--with-position .th-contacts-row {
    grid-template-columns:
        minmax(0, 0.9fr)
        auto
        minmax(0, 1.5fr)
        minmax(0, 1.3fr)
        minmax(auto, max-content);
}
.th-contacts-list--with-position .th-contacts-row--editing,
.th-contacts-list--with-position .th-contacts-row--adding,
.th-contacts-list--with-position .th-contacts-row--empty {
    grid-template-columns: 1fr;
}
/* Crew-template slots — Position chip | Default crew | actions. A lean
   3-column grid: a template row only names the role and its default
   holder, so it drops the avatar / contact-info columns the event-crew
   --with-position variant carries. Edit / add / empty rows collapse to one
   stacking column like every other inline editor. */
.th-contacts-list--templates .th-contacts-row {
    grid-template-columns:
        minmax(0, 1fr)
        minmax(0, 1.3fr)
        minmax(auto, max-content);
}
.th-contacts-list--templates .th-contacts-row--editing,
.th-contacts-list--templates .th-contacts-row--adding,
.th-contacts-list--templates .th-contacts-row--empty {
    grid-template-columns: 1fr;
}
.th-contacts-row + .th-contacts-row { border-top: 1px solid var(--th-grey-light); }

/* Zebra striping for data rows. The header is the 1st .th-contacts-row,
   so :nth-of-type(odd) on the data rows resolves to indexes 3, 5, 7…
   (every other body row, leaving the first body row at the base tone).
   The :not() guards keep editing/adding rows out — those carry their
   own grey-light background and shouldn't combine. currentColor at low
   alpha keeps the stripe legible in both light and dark themes without
   adding a new token. */
.th-contacts-list:not(.th-contacts-list--js-stripe) > .th-contacts-row:not(.th-contacts-row--head):not(.th-contacts-row--empty):not(.th-contacts-row--editing):not(.th-contacts-row--adding):nth-of-type(odd) {
    background: color-mix(in srgb, currentColor 4%, transparent);
}
/* JS-striped variant (tour crew list, which filters rows): :nth-of-type
   can't see which rows are hidden, so filtering leaves the stripes
   uneven. The list opts out of the structural rule above via
   --js-stripe and instead carries a .th-contacts-row--alt class that
   App.razor recomputes against the *visible* rows after each filter.
   The server renders the same class by row index so the unfiltered
   page (and any no-JS load) still stripes correctly. */
.th-contacts-list--js-stripe > .th-contacts-row--alt {
    background: color-mix(in srgb, currentColor 4%, transparent);
}

/* Header uses the same grey-light as the inline-editor form so the two
   share a "this is a chrome surface, not a data row" feel. The editor
   carries the divider (border-top below) so when the editor opens on
   the first data row the user still reads the header as a separate
   band on top. */
.th-contacts-row--head {
    background: var(--th-grey-light);
    font-size: 0.85em;
    font-weight: 600;
    color: var(--th-text-muted);
    text-transform: uppercase;
    letter-spacing: 0.04em;
}
.th-contacts-row--empty {
    color: var(--th-text-muted);
    font-style: italic;
    grid-template-columns: 1fr;
}
.th-contacts-row__name { font-weight: 600; white-space: nowrap; }
.th-contacts-row__name-cell {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 2px;
    min-width: 0;
}
/* First line of the name cell: display name + Accepted badge. The
   badge is pinned with flex: 0 0 auto so it always renders whole; the
   name wraps to a second line within the cell if it's truly too long
   to fit — long content is allowed to break, never truncated. */
.th-contacts-row__name-line {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: var(--th-space-2);
    min-width: 0;
}
.th-contacts-row__name-line .th-badge {
    flex: 0 0 auto;
}

/* Plain-text cells (Company, Phone, Email) and header labels can break
   onto a second line when they don't fit — wrapping is preferred over
   ellipsis truncation so the user always sees the full value. min-width
   already comes from the grid template's minmax(0, ...) so the cells
   can shrink to the column width and wrap inside. */
.th-contacts-row > [role="cell"],
.th-contacts-row > [role="columnheader"] {
    word-break: break-word;
    overflow-wrap: anywhere;
}
/* Full-width second line of each contact row — used for the role list.
   `grid-column: 1 / -1` lets the chips span the entire row width
   regardless of the main 5-column template; on the mobile single-column
   fallback it just sits stacked underneath. Long role lists are
   allowed to wrap to additional lines — never truncated — because
   roles convey identity context the user actually wants to read.
   Empty / no-role rows omit this element so they collapse to a single
   line. */
.th-contacts-row__roles-row {
    grid-column: 1 / -1;
    font-size: 0.82em;
    color: var(--th-text-muted);
    line-height: 1.3;
    padding-top: 2px;
}
.th-role-chips--inline { gap: 4px; }
.th-role-chips--inline .th-role-chip { font-size: 0.72em; padding: 1px 6px; }
.th-contacts-row__empty { color: var(--th-text-muted); }
/* Event-crew position — plain, lightly de-emphasised text (no chip). Kept
   normal weight and a touch smaller so the crew member's name reads as the
   row's headline and the position column can stay narrow. */
.th-crew-position { font-weight: 400; font-size: 0.9em; }
/* Flight markers beside a crew member's name — a takeoff plane when they're
   booked on the outbound leg, a landing plane for the return (both for a
   round-trip). Muted so they read as quiet status metadata, not an
   attention state; centred against the baseline-aligned name line. The
   fly list itself is managed in the flight-info dialog. */
.th-crew-flights {
    display: inline-flex;
    align-self: center;
    align-items: center;
    gap: var(--th-space-1);
    color: var(--th-text-muted);
    flex: 0 0 auto;
}
.th-crew-flight { display: inline-flex; }
.th-crew-flight svg { width: 15px; height: 15px; }
/* Per-member comment — stacked under the crew member's name inside the
   identity flex cell, so it picks up that column's 2px gap (matching the
   phone-over-email stack). Parenthesised + muted + italic so it reads as a
   note rather than a data field. */
.th-contacts-row__comment {
    font-size: 0.85em;
    font-style: italic;
    color: var(--th-text-muted);
    line-height: 1.3;
}
.th-contacts-row__actions {
    display: flex;
    align-items: center;
    gap: var(--th-space-1);
    justify-content: flex-end;
}
/* Inside a contacts row the icon buttons shrink from the standalone
   32×32 down to 28×28 so the actions column stays compact and leaves
   width for the surrounding cells. Stand-alone usage elsewhere keeps
   the larger tap target. */
.th-contacts-row__actions .th-icon-btn {
    width: 28px;
    height: 28px;
}
.th-contacts-row__actions form { display: inline; margin: 0; }
/* Push the Cancel + Save icon pair to the right edge of the actions
   cell so any Remove trash on its left sits flush left — matches the
   inline-editor standard (trash separated from the destructive-adjacent
   save). The class is applied to Cancel only; Save naturally follows. */
.th-contacts-row__actions-cancel { margin-left: auto; }
/* Save sits next to the Cancel × in the inline-editor actions cluster;
   the default flex gap is tight, so an extra margin opens a clearer
   visual separation between "go back" and "commit" without enlarging
   the cell. */
.th-contacts-row__actions-cancel + .th-icon-btn--primary {
    margin-left: var(--th-space-2);
}
/* Inline-editor action cluster (Trash · Cancel · Save) reads as one
   evenly-spaced group. The margin-left:auto on Cancel and the extra Save
   margin above are meant for wider, multi-element action cells; in the
   single-line crew/contact editor the three icons sit in a max-content
   cell with no slack, so those rules only produce a lopsided
   Trash–Cancel (tight) vs Cancel–Save (wide) gap. Inside an editing/adding
   row, drop both extras and lean on one uniform flex gap instead. */
.th-contacts-row--editing .th-contacts-row__actions,
.th-contacts-row--adding .th-contacts-row__actions {
    gap: var(--th-space-2);
}
.th-contacts-row--editing .th-contacts-row__actions-cancel,
.th-contacts-row--adding .th-contacts-row__actions-cancel {
    margin-left: 0;
}
.th-contacts-row--editing .th-contacts-row__actions-cancel + .th-icon-btn--primary,
.th-contacts-row--adding .th-contacts-row__actions-cancel + .th-icon-btn--primary {
    margin-left: 0;
}
/* Read-row actions cluster: Send invite + Edit sit side by side on
   every crew row. Mobile collapses the gap to zero so the row-tools
   read as one block in the tight space. On wider viewports the row
   has room to breathe, so re-introduce a small gap so the two icons
   don't visually fuse — the user reported they need separation on
   the web. The inline-editor's Cancel/Save still gets the default
   flex gap from the base rule. */
.th-contacts-row__actions--tight { gap: 0; }
@media (min-width: 640px) {
    .th-contacts-row__actions--tight { gap: var(--th-space-2); }
}
/* Buttons inside the actions cell shrink to compact pills so the cell
   stops out-sizing the column header and the input cells in edit/add
   rows align with the table title row. */
.th-contacts-row__actions .th-button {
    min-height: 0;
    padding: var(--th-space-1) var(--th-space-2);
    font-size: 0.9em;
    margin-right: 0;
}

/* Edit + Add rows: two-line layout. Title + Name + actions on the first
   line; Phone + Email on the second. Gives each input a much wider cell
   than the read-row grid would allow. Override the parent
   .th-contacts-row's grid template back to a single stacking column,
   then the inner __line divs lay out their fields. */
.th-contacts-row.th-contacts-row--editing,
.th-contacts-row.th-contacts-row--adding {
    grid-template-columns: 1fr;
    gap: var(--th-space-2);
    background: var(--th-grey-light);
}
/* Dividers above AND below the inline editor — the editor is a
   chrome surface, so it gets a 2px frame on both edges to separate
   it from the header above and the next data row below. The Add
   form lives below the list in --standalone form with its own full
   box border, so it doesn't need (and doesn't want) these edges.
   Double-class selector bumps specificity to (0,2,0) so this beats
   the adjacent-sibling 1px grey-light row separator that would
   otherwise win. */
.th-contacts-row.th-contacts-row--editing {
    border-top: 2px solid var(--th-grey-border);
    border-bottom: 2px solid var(--th-grey-border);
}

/* Lifted out of the contacts table — used when the add form lives BELOW
   the table rather than as a final in-table row. Adds breathing room
   above so it doesn't visually weld to the table, and restores its own
   border + radius since it no longer inherits the table's shell. */
.th-contacts-row--standalone {
    margin-top: var(--th-space-3);
    padding: var(--th-space-3);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
}
.th-contacts-row__line {
    display: grid;
    gap: var(--th-space-3);
    align-items: center;
}
.th-contacts-row__line--top {
    grid-template-columns: 1fr 1fr;
}
/* Bottom row now carries the Cancel + Save/Add actions at its right
   edge so DOM order (= keyboard tab order) is Title, Name, Phone,
   Email, X, Add. auto sizes the actions to their natural width; Email
   gets the lion's share since addresses are long, Phone stays narrow
   because the country picker + local digits fit comfortably under
   ~12 characters. */
.th-contacts-row__line--bottom {
    grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) auto;
}
/* Event-crew comment line — the optional per-event note takes the full
   row width with the Save/Cancel/Remove cluster pinned to the right, so
   the action buttons sit below every input in tab order. */
.th-contacts-row__line--comment {
    grid-template-columns: minmax(0, 1fr) auto;
}
/* Tour crew compact form: three equal-width identity inputs
   (First name | Middle name | Last name). */
.th-contacts-row__line--triple {
    grid-template-columns: 1fr 1fr 1fr;
}
/* Single-item line — used for the Company input on its own line, the
   locked-edit "Managed by their profile" banner, and the add form's
   Company field. Explicit single-column grid so the line behaves the
   same way regardless of how many tracks the row would otherwise infer. */
.th-contacts-row__line--full {
    grid-template-columns: 1fr;
}
/* Tour crew compact form: roles picker on the left, the Remove + Save
   icon cluster pinned to the right. The cluster ships wrapped in
   .th-contacts-row__line-icons (plain flex, not .th-contacts-row__actions)
   so the icons keep their natural 32×32 .th-icon-btn size instead of
   shrinking to the read-row 28×28 treatment. */
.th-contacts-row__line--roles {
    grid-template-columns: minmax(0, 1fr) auto;
}
/* Plain flex cluster for icon-only buttons inside a __line — used for
   the edit form's Remove/Save pair. Skips the .th-contacts-row__actions
   shrink rule so the icons stay at their natural .th-icon-btn size. */
.th-contacts-row__line-icons {
    display: flex;
    align-items: center;
    gap: var(--th-space-2);
}

/* Phone viewports — collapse the multi-column edit-form lines so each
   input gets full row width. Without this the inputs are squeezed into
   ~150px columns inside a ~320px viewport.

   Gap also drops from var(--th-space-3) (16px) to var(--th-space-2)
   (8px) so the within-line stacking rhythm matches the between-line
   rhythm set on .th-contacts-row--editing/--adding. Without this,
   stacked inputs inside one line (Phone above Email) read further
   apart than the gap between lines, and the form feels broken-up. */
@media (max-width: 640px) {
    .th-contacts-row__line--top,
    .th-contacts-row__line--bottom,
    .th-contacts-row__line--triple {
        grid-template-columns: 1fr;
        gap: var(--th-space-2);
    }
    /* Comment line keeps the note + action cluster side by side even on
       mobile (note shrinks, icons stay their natural size) so Save doesn't
       drop onto its own full-width row below a short note. */
    .th-contacts-row__line--comment {
        grid-template-columns: minmax(0, 1fr) auto;
        gap: var(--th-space-2);
    }
    /* Roles row also collapses on mobile — picker takes its own row at
       full width, then the right-hand actions (trash/save in edit, or
       Cancel + Add-and-invite + Add in the new-contact form) drop to a
       second row. On desktop the picker + small icons or text buttons
       fit comfortably side-by-side, but at phone width the icons crowd
       the picker chips and the text actions squeeze "Add and invite"
       into a two-line label. */
    .th-contacts-row__line--roles {
        grid-template-columns: 1fr;
        gap: var(--th-space-2);
    }
    /* Right-align everything except the picker so the trailing actions
       hug the form's right edge under the picker, matching the desktop
       "actions on the right" reading order. */
    .th-contacts-row__line--roles > :not(.th-list-picker) {
        justify-self: end;
    }
    /* Cancel sits on the left, Add-and-invite + Add hug the right edge.
       The base .th-contacts-row__actions rule sets justify-content:
       flex-end which collides with the auto right-margin trick — flip
       it back to flex-start here so the auto margin alone controls the
       split. flex-wrap stays off so the three buttons read as a single
       row even on very narrow viewports (overflow is acceptable; wrap
       would scatter the buttons across multiple lines unpredictably). */
    .th-contacts-row__actions--text {
        flex-direction: row;
        flex-wrap: nowrap;
        justify-content: flex-start;
    }
    .th-contacts-row__actions--text > :first-child {
        margin-right: auto;
    }
    /* Override the global mobile `.th-button { width: 100% }` for this
       cluster so the buttons size to their label width. nowrap keeps
       "Add and invite" from breaking into two text lines now that the
       buttons share a row again. flex: 0 0 auto pins each button to its
       natural width so flex shrink can't squeeze them below readable. */
    .th-contacts-row__actions--text .th-button {
        width: auto;
        flex: 0 0 auto;
        white-space: nowrap;
    }
}

/* Variant of __actions for clusters that hold text buttons (Cancel /
   Add / Add and invite) instead of icon-only buttons. Same right-edge
   alignment, slightly larger gap to match button proportions. */
.th-contacts-row__actions--text {
    gap: var(--th-space-2);
}

/* Inputs and selects inside edit/add rows shrink to fit their grid cell
   instead of the shared .th-field full-width treatment. select is styled
   alongside input so a native dropdown (e.g. the crew-member picker) lines
   up at the same height as the text inputs next to it instead of falling
   back to the browser-default control height. */
.th-contacts-row--editing input,
.th-contacts-row--editing select,
.th-contacts-row--adding input,
.th-contacts-row--adding select {
    width: 100%;
    min-width: 0;
    padding: var(--th-space-1) var(--th-space-2);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    font: inherit;
    line-height: 1.5;
    /* Native <select> ignores authored line-height for its internal text
       box (computed line-height stays `normal`), so it would render ~5px
       shorter than the text inputs beside it. A shared border-box
       min-height — line-box + vertical padding + both borders — pins every
       control in the editor to the same height regardless of that quirk. */
    min-height: calc(1.5em + var(--th-space-1) * 2 + 2px);
    background: var(--th-white);
    color: var(--th-text);
    box-sizing: border-box;
}
/* Checkboxes inside the embedded multi-select picker keep their native
   sizing — the wide-input rule above would otherwise stretch each tickbox
   into a row-wide bar with a stray border + background, and the form-input
   min-height would inflate every option row to ~48px. */
.th-contacts-row--editing .th-list-picker__option input[type="checkbox"],
.th-contacts-row--adding .th-list-picker__option input[type="checkbox"] {
    width: auto;
    min-height: 0;
    padding: 0;
    border: 0;
    background: transparent;
}

/* Inside a compact contacts row the country-picker trigger drops its
   fixed 44px tap target and sizes from padding instead, so it lines up
   with the shorter inline inputs next to it. align-items: stretch on the
   phone-fields flex parent then pulls it to the same height as the
   InputText sibling. The full tap target is preserved in standalone
   .th-field forms (e.g. TourContacts edit form) where this override
   does not apply. */
.th-contacts-row--editing .th-phone-country__trigger,
.th-contacts-row--adding .th-phone-country__trigger {
    height: auto;
    padding: var(--th-space-1) var(--th-space-2);
}
/* The guestlist inline editor reuses a th-schedule table row (not a
   th-contacts-row), so the override above doesn't reach it — the country
   picker would otherwise tower at the full 44px tap-target height beside
   the text inputs. Match the trigger to the input height here, and let the
   phone field consume the column width so it doesn't look cramped. */
.th-schedule__row--editing .th-phone-country__trigger {
    height: auto;
    padding: var(--th-space-1) var(--th-space-2);
}
.th-schedule__row--editing .th-phone-fields {
    width: 100%;
}
/* The guestlist inline editor drops plain <InputText>s straight into
   th-schedule table cells, which match none of the scoped input baselines
   (.th-field / .th-schedule-edit / .th-contacts-row) — so they'd render with
   browser-default chrome. Give them the app's input treatment, compact to
   line up with the country-picker trigger above. The picker's own search box
   keeps its dedicated styling. input:not([type]) catches <InputText>, which
   renders an <input> with no type attribute. */
.th-schedule__row--editing input:not([type="hidden"]):not(.th-phone-country__search) {
    box-sizing: border-box;
    width: 100%;
    padding: var(--th-space-1) var(--th-space-2);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    font: inherit;
    line-height: 1.2;
    background: var(--th-white);
    color: var(--th-text);
}
/* Compact treatment for an embedded ListPicker (crew-member picker and
   the multi-select role picker) so its trigger matches the height of the
   text inputs beside it instead of standing 44px tall in the inline row.
   The full tap target is preserved everywhere else. */
.th-contacts-row--editing .th-list-picker__trigger,
.th-contacts-row--adding .th-list-picker__trigger {
    min-height: 0;
    padding: var(--th-space-1) var(--th-space-2);
}

.th-contacts-list__footer {
    display: flex;
    justify-content: flex-end;
    margin-bottom: var(--th-space-4);
}

/* List + add-slot wrapper. Documented in docs/conventions/ui-forms.md.
   DOM order is list → slot on every viewport: the add button sits at
   the bottom of the list across desktop AND mobile. An earlier rule
   used `order: -1` on phones to float the slot above the list (to
   save scrolling), but the user feedback was that "bottom" matches
   the rhythm of every other list-with-actions on the platform and is
   what they expect to see. Gap handles spacing in both directions —
   any standalone add-form margin-top inside the wrapper is zeroed so
   spacing comes from a single source.

   Gap is var(--th-space-2) — the last table row already contributes
   its own bottom cell padding (var(--th-space-2)), so an 8px wrapper
   gap reads as ~16px of breathing room between the table baseline and
   the add-slot, in line with the chrome→content rhythm above. */
.th-list-with-add {
    display: flex;
    flex-direction: column;
    gap: var(--th-space-2);
}
.th-list-with-add__slot {
    display: flex;
    flex-direction: column;
    gap: var(--th-space-3);
}
.th-list-with-add .th-contacts-row--standalone {
    margin-top: 0;
}
.th-list-with-add .th-contacts-list__footer {
    margin-bottom: 0;
}
/* .th-schedule carries its own margin-bottom for stand-alone use; inside
   the flex wrapper that would stack with the gap above and double the
   spacing between table and add-slot. */
.th-list-with-add .th-schedule {
    margin-bottom: 0;
}

/* Right-aligned action bar above the contacts table, holds the primary
   "Add contact" / "Add crew" button so it sits at the corner of the
   table title row rather than at the bottom of the list. */
.th-contacts-list__topbar {
    display: flex;
    justify-content: flex-end;
    margin-bottom: var(--th-space-2);
}

/* Crew-list filter toolbar — a name search box + role-filter chips above
   the list. Both are client-side only (document-level handlers in
   App.razor, keyed on data-th-crew-* attributes), so the list narrows
   without a round trip on this static-SSR page. On narrow viewports the
   bar wraps: the search box drops to its min width and the chip group
   flows onto the next line. */
.th-crew-filter__bar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--th-space-2) var(--th-space-3);
    margin-bottom: var(--th-space-3);
}
/* The search box isn't inside a .th-field, so it doesn't inherit the base
   input chrome — restate the metrics and the app-wide purple focus ring
   here so it matches every other text input. */
.th-crew-filter__search {
    box-sizing: border-box;
    flex: 0 1 18rem;
    min-width: 12rem;
    height: var(--th-tap-target);
    padding: 0 var(--th-space-3);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    font: inherit;
    line-height: 1.2;
    background: var(--th-white);
    color: var(--th-text);
}
.th-crew-filter__search:focus {
    outline: 2px solid var(--th-purple);
    outline-offset: 1px;
    border-color: var(--th-purple);
}
.th-crew-filter__roles {
    display: flex;
    flex-wrap: wrap;
    gap: var(--th-space-1);
}
/* Toggle chip. Quiet outline at rest; the pressed state borrows the
   lavender/purple role-chip palette so an active role filter reads as
   "selected" without inventing a new colour. */
.th-crew-filter__chip {
    cursor: pointer;
    padding: 3px var(--th-space-2);
    border-radius: 999px;
    border: 1px solid var(--th-grey-border);
    background: var(--th-white);
    color: var(--th-text-muted);
    font: inherit;
    font-size: 0.8em;
    font-weight: 600;
    line-height: 1.4;
    white-space: nowrap;
}
.th-crew-filter__chip:where(:hover, :focus-visible) {
    border-color: var(--th-purple);
    color: var(--th-text);
}
.th-crew-filter__chip[aria-pressed="true"] {
    background: var(--th-lavender);
    border-color: color-mix(in srgb, var(--th-lavender) 80%, black);
    color: var(--th-purple);
}
/* Clear-all chip — amber, sits at the end of the chip row. Shares the
   .th-crew-filter__chip pill shape; overrides the palette to the warning
   amber (same tokens as .th-badge--warning) so it reads as a distinct
   "reset" action rather than another role toggle. App.razor un-hides it
   only while a filter is active. */
.th-crew-filter__clear {
    /* inline-flex (the base chip is inline-block) so the lone X glyph
       centres in the pill instead of sitting on the text baseline. */
    display: inline-flex;
    align-items: center;
    justify-content: center;
    background: var(--th-alert-warning-bg);
    border-color: var(--th-warning);
    color: var(--th-warning-text);
}
.th-crew-filter__clear svg { width: 12px; height: 12px; }
.th-crew-filter__clear:where(:hover, :focus-visible) {
    background: var(--th-warning);
    border-color: var(--th-warning);
    color: var(--th-warning-text);
}
/* The display:inline-flex above beats the UA [hidden]{display:none}, so
   the hidden attribute alone wouldn't hide the chip — restate it at
   class specificity. */
.th-crew-filter__clear[hidden] { display: none; }

/* Phone input — compact country picker (flag + +CC) + local-number text
   input side by side. */
.th-phone-fields {
    display: flex;
    gap: var(--th-space-2);
    align-items: stretch;
    min-width: 0;
}
.th-phone-fields__local {
    flex: 1 1 auto;
    min-width: 0;
}

/* Country picker — compact <details> button that opens a searchable
   panel of countries. The trigger shows just flag + +CC (e.g. 🇳🇴 +47)
   so the field stays narrow. */
.th-phone-country { position: relative; flex: 0 0 auto; }
.th-phone-country__trigger {
    list-style: none;
    display: inline-flex;
    align-items: center;
    gap: 4px;
    height: var(--th-tap-target);
    padding: 0 var(--th-space-2);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-white);
    color: var(--th-text);
    cursor: pointer;
    font: inherit;
    white-space: nowrap;
}
.th-phone-country__trigger::-webkit-details-marker { display: none; }
.th-phone-country__trigger::marker { content: ""; }
.th-phone-country__trigger:hover { border-color: var(--th-purple); }
.th-phone-country[open] > .th-phone-country__trigger { border-color: var(--th-purple); }

.th-phone-country__flag { font-size: 1.1em; line-height: 1; }
.th-phone-country__code { font-variant-numeric: tabular-nums; }
.th-phone-country__placeholder { color: var(--th-text-muted); }
.th-phone-country__caret { color: var(--th-text-muted); font-size: 0.8em; margin-left: 2px; }

.th-phone-country__panel {
    position: absolute;
    top: calc(100% + 4px);
    left: 0;
    z-index: 40;
    width: min(20rem, 90vw);
    background: var(--th-white);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
    padding: var(--th-space-2);
    display: flex;
    flex-direction: column;
    gap: var(--th-space-2);
}
.th-phone-country__panel--up {
    top: auto;
    bottom: calc(100% + 4px);
}
/* <details>'s toggle event is queued, so the picker panel paints once
   in its default below-trigger position before our JS gets a chance to
   add --up. Visually that's a flash where the panel pops in below then
   snaps above. Hide the panel on desktop until the JS toggle handler
   has tagged the root with .th-picker--positioned. The fixed-positioned
   override during the hidden frame keeps the panel from extending past
   the document edge — without it an unflipped panel near the viewport
   bottom pops a transient scrollbar even while invisible. Mobile is
   unaffected — the .th-bottom-sheet rule overrides positioning anyway,
   so there's no flip to wait for. */
@media (min-width: 641px) {
    .th-phone-country[open]:not(.th-picker--positioned) > .th-phone-country__panel {
        visibility: hidden;
        position: fixed;
        top: 0;
        left: 0;
    }
}
.th-phone-country__search {
    width: 100%;
    padding: var(--th-space-1) var(--th-space-2);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    font: inherit;
    box-sizing: border-box;
}
.th-phone-country__list {
    list-style: none;
    margin: 0;
    padding: 0;
    max-height: 16rem;
    overflow-y: auto;
}
.th-phone-country__option {
    display: grid;
    grid-template-columns: 1.5em auto 1fr;
    gap: var(--th-space-2);
    align-items: center;
    padding: var(--th-space-1) var(--th-space-2);
    border-radius: var(--th-radius);
    cursor: pointer;
    user-select: none;
}
.th-phone-country__option:hover { background: var(--th-lavender); }
.th-phone-country__option--active {
    background: var(--th-lavender);
    font-weight: 600;
}
.th-phone-country__option .th-phone-country__name {
    color: var(--th-text-muted);
    font-size: 0.9em;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Stack on narrow screens — five columns don't fit on a phone. Uses the
   standard --th-bp-md (640px) breakpoint so contact rows reflow at the
   same cutoff as the rest of the mobile overhaul. */
@media (max-width: 640px) {
    .th-contacts-row,
    .th-contacts-list--no-company .th-contacts-row,
    .th-contacts-list--with-role .th-contacts-row,
    .th-contacts-list--with-position .th-contacts-row,
    .th-contacts-list--wide-email .th-contacts-row {
        grid-template-columns: 1fr;
        gap: var(--th-space-1);
    }
    /* Template slots stack in DOM order on mobile — name then position,
       each on its own line, instead of the cramped two-column desktop grid
       (the long name kept wrapping over the position when both shared a
       narrow row). The desktop selector carries two classes, so it has to
       be reset here explicitly or it out-specifies the base 1fr reset
       above. Only the action cluster floats to the card's top-right
       corner. */
    .th-contacts-list--templates .th-contacts-row {
        position: relative;
        grid-template-columns: 1fr;
        gap: var(--th-space-1);
    }
    .th-contacts-list--templates .th-contacts-row:not(.th-contacts-row--editing):not(.th-contacts-row--adding) .th-contacts-row__actions {
        position: absolute;
        top: var(--th-space-2);
        right: 0;
        justify-content: flex-end;
    }
    /* Name owns the full width now, so let a long name wrap instead of
       overflowing, and keep its first line clear of the floated pencil. */
    .th-contacts-list--templates .th-contacts-row__name {
        white-space: normal;
    }
    .th-contacts-list--templates .th-contacts-row:not(.th-contacts-row--editing):not(.th-contacts-row--adding) .th-contacts-row__identity {
        padding-right: var(--th-space-6);
    }
    /* With-role / with-position mobile layout: avatar pinned to col 1
       spanning the card's vertical stack, identity / categorical-chip /
       contact-info reflowing in col 2. Keeps the "circle with name on the
       left" pattern intact on phones instead of pushing the avatar above
       the card. The two variants share every mobile rule below — they
       only differ in the desktop column ordering of the chip cell. */
    .th-contacts-list--with-role .th-contacts-row,
    .th-contacts-list--with-position .th-contacts-row {
        grid-template-columns: auto 1fr;
        column-gap: var(--th-space-3);
    }
    .th-contacts-list--with-role .th-contacts-row__avatar,
    .th-contacts-list--with-position .th-contacts-row__avatar {
        grid-column: 1;
        grid-row: 1 / span 3;
        align-self: start;
    }
    .th-contacts-list--with-role .th-contacts-row__identity,
    .th-contacts-list--with-role .th-contacts-row__role,
    .th-contacts-list--with-role .th-contacts-row__contact-info,
    .th-contacts-list--with-position .th-contacts-row__identity,
    .th-contacts-list--with-position .th-contacts-row__role,
    .th-contacts-list--with-position .th-contacts-row__contact-info {
        grid-column: 2;
    }
    /* Editing / adding forms collapse back to one column on mobile so the
       form's __line children stack vertically. Without this override the
       `--with-role .th-contacts-row { auto 1fr }` rule above wins (same
       specificity, later in source than the desktop `--editing { 1fr }`
       rule), placing the form's lines into a 2-up grid that reads as
       jumbled. */
    .th-contacts-list--with-role .th-contacts-row--editing,
    .th-contacts-list--with-role .th-contacts-row--adding,
    .th-contacts-list--with-position .th-contacts-row--editing,
    .th-contacts-list--with-position .th-contacts-row--adding {
        grid-template-columns: 1fr;
    }
    .th-contacts-row--head { display: none; }
    .th-contacts-row__actions { justify-content: flex-start; }
    /* Push the Role / Position chip cell to the bottom of the stacked
       card on mobile — identity and contact info are the headline
       details, the chip is supporting context that should read after
       them. Other cells default to order: 0 so they keep DOM order
       above. The --with-position variant reuses the .__role class for
       its position chip so a single rule covers both. */
    .th-contacts-list--with-role .th-contacts-row__role,
    .th-contacts-list--with-position .th-contacts-row__role { order: 1; }
    /* Actions float to the top-right of the card on mobile. Pulling
       them out of the grid flow keeps the name row at the visual top
       with the row tools in their natural "right-hand corner of a
       card" position, instead of stacking under the contact info. */
    .th-contacts-list--with-role .th-contacts-row,
    .th-contacts-list--with-position .th-contacts-row {
        position: relative;
    }
    /* Read rows only. The :not() guards keep the inline edit/add forms
       out — their trash/cancel/save cluster lives inside a stacked
       __line and must stay in flow. Without this exclusion the absolute
       pin drew the edit buttons on top of the (stacked) Position field. */
    .th-contacts-list--with-role .th-contacts-row:not(.th-contacts-row--editing):not(.th-contacts-row--adding) .th-contacts-row__actions,
    .th-contacts-list--with-position .th-contacts-row:not(.th-contacts-row--editing):not(.th-contacts-row--adding) .th-contacts-row__actions {
        position: absolute;
        top: var(--th-space-2);
        /* right: 0 pins the cluster to the card's outer edge (the row has
           no border, so the padding box's right edge coincides with the
           card edge). The rightmost button's 16px glyph then sits ~14px
           from the card edge — visually balanced against the left text
           gutter, instead of floating 30px inside it like the desktop
           offset did. */
        right: 0;
        justify-content: flex-end;
    }
    /* Tighten the read-row button chrome around the 20px glyph — the
       44px touch-target default leaves a visible hover/focus halo that
       reads as a too-big box around the icon. 32px keeps a comfortable
       tap zone (above the 24px WCAG floor) while shrinking the visible
       border/background to sit close to the glyph. */
    .th-contacts-list--with-role .th-contacts-row__actions .th-icon-btn,
    .th-contacts-list--with-position .th-contacts-row__actions .th-icon-btn {
        width: 32px;
        height: 32px;
        min-width: 32px;
        min-height: 32px;
    }
    .th-contacts-list--with-role .th-contacts-row__actions .th-icon-btn svg,
    .th-contacts-list--with-position .th-contacts-row__actions .th-icon-btn svg {
        width: 20px;
        height: 20px;
    }
    /* Phone and email on the same row instead of stacked — saves a
       row's worth of vertical real estate when the values are short
       enough to share a line; long values wrap onto a second line via
       flex-wrap. */
    .th-contacts-row__contact-info {
        flex-direction: row;
        flex-wrap: wrap;
        column-gap: var(--th-space-3);
        row-gap: 2px;
    }
    /* Hide the "—" placeholder spans on mobile cards — on desktop the
       dash holds the grid column open so the email always lines up with
       the email header, but on a stacked card it's just dead chrome. If
       both phone and email are empty the contact-info row collapses to
       zero height, which is what we want. */
    .th-contacts-row__empty { display: none; }
}

/* Legacy .th-contacts list styles kept for any other pages that still use
   them; the event-contacts page is on the new grid above. */
.th-contacts { list-style: none; padding: 0; margin: 0; display: grid; gap: var(--th-space-3); }

.th-contacts__item {
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    padding: var(--th-space-3) var(--th-space-4);
}

.th-contacts__primary {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: var(--th-space-2);
}

.th-contacts__name { font-size: 1.1rem; }

.th-contacts__role {
    color: var(--th-purple);
    background: var(--th-lavender);
    padding: 0 var(--th-space-2);
    border-radius: 999px;
    font-size: 0.8rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
}

.th-contacts__company { color: var(--th-text-muted); font-size: 0.95em; }

.th-contacts__contact { margin-top: var(--th-space-1); display: flex; gap: var(--th-space-2); flex-wrap: wrap; }
.th-contacts__sep { color: var(--th-text-muted); }

/* When a promote/add form sits directly under the legacy .th-contacts list
   (tour settings → Editors), the list's margin:0 lets the next form's label
   crowd the last list card. Open a clear gap so the "Promote a crew member"
   prompt reads as a separate action, not a continuation of the last row. */
.th-contacts + form { margin-top: var(--th-space-4); }

.th-event-actions {
    display: flex;
    align-items: center;
    gap: var(--th-space-3);
    margin: var(--th-space-3) 0 var(--th-space-4);
}

.th-event-actions form { margin: 0; }

/* ---------- Links list ---------- */
/* Variant of .th-contacts-list. Read rows are 3 columns (Title+host stacked
   | Notes | Actions) — the raw URL is implied by the clickable title and
   shown abbreviated as the hostname. Edit/add rows widen to 4 columns so the
   URL input gets its own field. */
.th-contacts-list--links .th-contacts-row {
    grid-template-columns:
        minmax(0, 2fr)
        minmax(0, 1.6fr)
        minmax(auto, max-content);
}

.th-contacts-list--links .th-contacts-row--editing,
.th-contacts-list--links .th-contacts-row--adding {
    grid-template-columns:
        minmax(0, 1.4fr)
        minmax(0, 2fr)
        minmax(0, 1.4fr)
        minmax(auto, max-content);
}

/* Title cell: bold link with a small ↗ icon to telegraph "opens in new tab".
   The link is the whole row's affordance, so the hover state nudges the
   purple color through the icon too. */
.th-links-row__title {
    display: inline-flex;
    align-items: center;
    gap: var(--th-space-1);
    font-weight: 600;
    text-decoration: none;
    color: var(--th-text);
    min-width: 0;
}
.th-links-row__title:hover { color: var(--th-purple); }
.th-links-row__external {
    width: 0.85em;
    height: 0.85em;
    opacity: 0.55;
    flex-shrink: 0;
}

/* Hostname under the title — replaces the old full-URL column. Muted, small,
   single-line ellipsis so unusual hosts don't push the row layout. */
.th-links-row__host {
    color: var(--th-text-muted);
    font-size: 0.85em;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    max-width: 100%;
}

/* ---------- Tracks list ---------- */
/* Variant of .th-contacts-list — tour-tracks catalogue. Read rows are 3
   columns (Title | Duration | Actions). Edit/add rows fall back to the
   default single-column row stack and use the __line--tracks template
   below to lay their two inputs out horizontally — same pattern as the
   other inline editors in the app. */
.th-contacts-list--tracks .th-contacts-row {
    grid-template-columns:
        minmax(0, 1fr)
        minmax(auto, max-content)
        minmax(auto, max-content);
}
/* Read rows with audio carry the player inside the title cell via
   .th-track-audio-line (shared with the setlist) — no special row grid
   needed; the title's 1fr column holds the title + player on one line. */
/* Duration cell aligns with its column header (Duration sits on the right
   so durations and the header read as one column). Tabular numerics keep
   m:ss values vertically aligned across rows of differing widths. */
.th-tracks-row__duration {
    text-align: right;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}
/* Tracks edit/add form line — Title input (1fr) | Duration m:ss inputs
   (auto) | Actions (auto). Matches the read-row column rhythm so the
   edit form's inputs sit under the columns they'll become. Same template
   serves the inline editor (inside the list) and the standalone add
   form (in the slot below). */
.th-contacts-row__line--tracks {
    grid-template-columns:
        minmax(0, 1fr)
        minmax(auto, max-content)
        minmax(auto, max-content);
}
@media (max-width: 640px) {
    .th-contacts-row__line--tracks {
        grid-template-columns: 1fr;
        gap: var(--th-space-2);
    }
}

/* ---------- Resources page (unified links + files table) ---------- */

/* Resources read rows share the same name-cell shape — the kind icon
   sits to the LEFT of the title (and host, for links) — but split on
   their grid layout: link rows are a tight 1-row 3-col table (Name |
   Notes | Actions), while file rows step up to a 2-row mini-grid via
   the --read-file modifier so long filenames get the full row 1 width. */
.th-contacts-list--resources .th-contacts-row--read {
    grid-template-columns:
        minmax(0, 1fr)
        minmax(0, 1.6fr)
        minmax(auto, max-content);
    align-items: center;
}
.th-contacts-list--resources .th-contacts-row--read > .th-contacts-row__name-cell {
    /* Override the base name-cell column stack so the kind icon sits to
       the LEFT of the title + host stack, not above it. The inner
       .th-resources-row__name-text handles the title/host vertical
       stack on the right of the icon. */
    flex-direction: row;
    align-items: center;
    gap: var(--th-space-2);
}
.th-resources-row__name-text {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 2px;
    min-width: 0;
}

/* File rows: 2-row mini-grid so the filename spans the full first row
   and the meta + actions share row 2 (meta left, actions right). */
.th-contacts-list--resources .th-contacts-row--read-file {
    grid-template-columns: minmax(0, 1fr) auto;
    grid-template-areas:
        "name name"
        "meta actions";
    column-gap: var(--th-space-2);
    row-gap: var(--th-space-1);
}
.th-contacts-list--resources .th-contacts-row--read-file > .th-contacts-row__name-cell {
    grid-area: name;
}
.th-contacts-list--resources .th-contacts-row--read-file > .th-resources-row__meta {
    grid-area: meta;
}
.th-contacts-list--resources .th-contacts-row--read-file > .th-contacts-row__actions {
    grid-area: actions;
    justify-self: end;
}

/* Link editor mirrors the --links pattern: Title | URL | Notes | Actions. */
.th-contacts-list--resources .th-contacts-row--editing:not(.th-contacts-row--upload-file) {
    grid-template-columns:
        minmax(0, 1.4fr)
        minmax(0, 2fr)
        minmax(0, 1.4fr)
        minmax(auto, max-content);
}

/* Add-link standalone row reuses the link editor's 4-col template. */
.th-contacts-list--resources .th-contacts-row--adding:not(.th-contacts-row--upload-file) {
    grid-template-columns:
        minmax(0, 1.4fr)
        minmax(0, 2fr)
        minmax(0, 1.4fr)
        minmax(auto, max-content);
}

/* File edit row: filename input takes all available space, the Public
   checkbox and the action cluster sit at their natural widths pinned
   to the right. Overrides the 3-class link-editor rule above by
   chaining the --editing-file modifier (same specificity, later
   declaration wins). */
.th-contacts-list--resources .th-contacts-row--editing.th-contacts-row--editing-file {
    grid-template-columns: minmax(0, 1fr) auto auto;
    align-items: center;
}

/* Rename field — the editable name stem plus a fixed, greyed-out
   extension suffix so the user can't change the file type. The wrap is
   the first grid cell; the input flexes to fill it and the ".pdf" suffix
   pins at its natural width on the right. */
.th-resources-row__edit-name-wrap {
    display: flex;
    align-items: center;
    gap: var(--th-space-1);
    min-width: 0;
}
.th-resources-row__edit-name-wrap > .th-resources-row__edit-name {
    flex: 1 1 auto;
    min-width: 0;
}
.th-resources-row__edit-name-ext {
    flex: 0 0 auto;
    color: var(--th-text-muted);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
    user-select: none;
}

/* Narrow viewports: link edit + add rows drop from the cramped
   4-column desktop layout (Title | URL | Notes | Actions) to a 2-col
   grid that auto-flows the three text inputs + actions into two
   rows — Title + URL share row 1, Notes + Actions share row 2.
   AntiforgeryToken renders as a display:none hidden input, so it
   doesn't consume a grid cell and the visible children fall into
   place without needing explicit grid-area assignments. */
@media (max-width: 640px) {
    .th-contacts-list--resources .th-contacts-row--editing:not(.th-contacts-row--upload-file):not(.th-contacts-row--editing-file),
    .th-contacts-list--resources .th-contacts-row--adding:not(.th-contacts-row--upload-file) {
        grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
        row-gap: var(--th-space-2);
    }
    .th-contacts-list--resources .th-contacts-row--editing:not(.th-contacts-row--upload-file):not(.th-contacts-row--editing-file) > .th-contacts-row__actions,
    .th-contacts-list--resources .th-contacts-row--adding:not(.th-contacts-row--upload-file) > .th-contacts-row__actions {
        justify-self: end;
    }

    /* File edit row on mobile: filename input spans the full row 1
       so the user can see the whole name while typing; row 2 holds
       Public checkbox at the left and the action icon cluster at the
       right, each compact within their own cell. */
    .th-contacts-list--resources .th-contacts-row--editing.th-contacts-row--editing-file {
        grid-template-columns: minmax(0, 1fr) auto;
        row-gap: var(--th-space-2);
    }
    .th-contacts-list--resources .th-contacts-row--editing.th-contacts-row--editing-file > .th-resources-row__edit-name-wrap {
        grid-column: 1 / -1;
    }
    .th-contacts-list--resources .th-contacts-row--editing.th-contacts-row--editing-file > .th-checkbox {
        justify-self: start;
    }
    .th-contacts-list--resources .th-contacts-row--editing.th-contacts-row--editing-file > .th-contacts-row__actions {
        justify-self: end;
    }
}

/* Upload-file row — native file inputs render with internal chrome that
   doesn't grid-align cleanly, so flow the file input / hint / actions
   as a flex row inside the standalone chrome. */
.th-contacts-list--resources .th-contacts-row--upload-file {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--th-space-2);
}
.th-resources__upload-input {
    flex: 0 1 auto;
    max-width: 18rem;
    font-size: 0.9em;
    color: var(--th-text);
}
.th-resources__upload-hint {
    color: var(--th-text-muted);
    font-size: 0.85em;
}
.th-contacts-row--upload-file > .th-contacts-row__actions--text {
    margin-left: auto;
}

/* Kind icon prefix in the Name cell — muted, sized to read like a label
   prefix rather than competing with the row title. */
.th-resources-row__icon {
    width: 16px;
    height: 16px;
    color: var(--th-text-muted);
    flex-shrink: 0;
}

/* Edit-mode action bar: small extra gap so the danger trash on the left
   sits clear of the Cancel/Save cluster on the right (pushed right via
   the established `.th-contacts-row__actions-cancel { margin-left:auto }`
   rule). */
.th-contacts-row__actions--edit {
    gap: var(--th-space-2);
}
/* The base actions cluster (gap: space-1) adds an extra space-2 margin
   between Cancel × and Save so they don't visually fuse at the tight
   default gap. Inside --edit the cluster already runs at space-2 gap,
   so that extra margin doubles up and reads as an uneven spread —
   trash | cancel | (wider gap) | save. Reset it back to the row's
   shared gap. */
.th-contacts-row__actions--edit .th-contacts-row__actions-cancel + .th-icon-btn--primary {
    margin-left: 0;
}

/* Meta cell on row 2 — muted, smaller. Carries either link notes
   (variable length, wraps freely) or the file's "Updated {date}"
   stamp (short, no wrap needed). */
.th-resources-row__meta {
    color: var(--th-text-muted);
    font-size: 0.9em;
}

/* Add panel: appears when ?add=link or ?add=file is set. Holds an
   optional Link/File tab strip (shown only to users with both
   capabilities) above the matching inline add form. */
.th-resources-add {
    display: flex;
    flex-direction: column;
    gap: var(--th-space-2);
}
.th-resources-add__tabs {
    display: flex;
    gap: var(--th-space-1);
    border-bottom: 1px solid var(--th-grey-border);
}
.th-resources-add__tab {
    display: inline-flex;
    align-items: center;
    gap: var(--th-space-1);
    padding: var(--th-space-1) var(--th-space-2);
    color: var(--th-text-muted);
    text-decoration: none;
    border-bottom: 2px solid transparent;
    margin-bottom: -1px;
    font-size: 0.95em;
}
.th-resources-add__tab:hover { color: var(--th-text); }
.th-resources-add__tab--active {
    color: var(--th-purple);
    border-bottom-color: var(--th-purple);
}
/* Icons embedded in the tab labels: match the muted-text colour by
   default, then inherit the active purple. */
.th-resources-add__tab .th-resources-row__icon {
    color: currentColor;
}

/* ---------- Crew assignment status badges ---------- */

.th-badge--pending     { background: var(--th-grey-light); color: var(--th-text); }
.th-badge--unconfirmed { background: var(--th-alert-warning-bg); color: var(--th-warning-text); border-color: var(--th-warning); }
.th-badge--none        { background: var(--th-alert-error-bg);   color: var(--th-error);   border-color: var(--th-error); }
.th-badge--active      { background: var(--th-alert-success-bg); color: var(--th-success); border: 1px solid var(--th-success); border-radius: var(--th-radius); padding: 0 var(--th-space-2); }
.th-badge--archived    { background: var(--th-grey-light); color: var(--th-text-muted); border: 1px solid var(--th-grey-border); border-radius: var(--th-radius); padding: 0 var(--th-space-2); }

/* ---------- Danger zone ---------- */

/* Sits at the bottom of a settings page to hold irreversible actions
   (currently just Delete event). Set apart visually with the error
   border + tint so it doesn't read as a normal form section. */
.th-danger-zone {
    margin-top: var(--th-space-5);
    padding: var(--th-space-3) var(--th-space-4);
    border: 1px solid var(--th-error);
    background: var(--th-alert-error-bg);
    border-radius: var(--th-radius);
}
.th-danger-zone h2 {
    color: var(--th-error);
    margin: 0 0 var(--th-space-2);
}
.th-danger-zone__lede {
    color: var(--th-text);
    margin: 0 0 var(--th-space-3);
}

/* ---------- Admin pages ---------- */

.th-admin { width: 100%; }
.th-admin__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--th-space-3);
    margin-bottom: var(--th-space-3);
}
.th-admin__head h1 { margin: 0; }

.th-admin-nav {
    display: flex;
    gap: var(--th-space-3);
    border-bottom: 1px solid var(--th-grey-border);
    margin-bottom: var(--th-space-4);
    padding-bottom: var(--th-space-2);
}
.th-admin-nav__link {
    text-decoration: none;
    padding: var(--th-space-2) 0;
    border-bottom: 2px solid transparent;
}
.th-admin-nav__link--active {
    border-bottom-color: var(--th-purple);
    font-weight: 600;
}

/* Profile tabs — same shape as the admin nav, but lives inside the
   narrow .th-form-page column, so wraps to a second row instead of
   overflowing on small screens. */
.th-profile-nav {
    display: flex;
    flex-wrap: wrap;
    gap: var(--th-space-3);
    border-bottom: 1px solid var(--th-grey-border);
    margin-bottom: var(--th-space-4);
    padding-bottom: var(--th-space-2);
}
.th-profile-nav__link {
    text-decoration: none;
    padding: var(--th-space-2) 0;
    border-bottom: 2px solid transparent;
}
.th-profile-nav__link--active {
    border-bottom-color: var(--th-purple);
    font-weight: 600;
}

.th-table {
    width: 100%;
    border-collapse: collapse;
    margin-bottom: var(--th-space-4);
}
.th-table th, .th-table td {
    text-align: left;
    padding: var(--th-space-2) var(--th-space-3);
    border-bottom: 1px solid var(--th-grey-light);
    vertical-align: top;
}
.th-table th { font-weight: 600; color: var(--th-text-muted); }
.th-table__actions { white-space: nowrap; }
.th-table__actions a, .th-table__actions button { margin-right: var(--th-space-2); }

.th-field__hint { color: var(--th-text-muted); font-size: 0.9em; }

.th-slug-status { font-size: 0.9em; font-weight: 600; }
.th-slug-status--ok  { color: var(--th-success); }
.th-slug-status--bad { color: var(--th-error); }

/* ---------- Address autocomplete dropdown ---------- */
.th-suggestions {
    list-style: none;
    margin: 4px 0 0;
    padding: var(--th-space-1);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-white);
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
    max-height: 240px;
    overflow-y: auto;
    /* Float above the map widget that immediately follows. */
    position: relative;
    z-index: 10;
}
.th-suggestion {
    padding: var(--th-space-2) var(--th-space-3);
    border-radius: var(--th-radius);
    cursor: pointer;
    /* Long display_name lines from Nominatim wrap naturally. */
    line-height: 1.3;
}
.th-suggestion:hover, .th-suggestion:focus {
    background: var(--th-lavender);
    color: var(--th-purple);
    outline: none;
}

/* ---------- Event-form map widget (Leaflet) ---------- */
.th-map-field { display: flex; flex-direction: column; gap: var(--th-space-2); }
.th-map-field__controls {
    /* Grid (not flex) so every child cell is forced to exactly
       --th-tap-target tall by grid-template-rows, regardless of each
       item's intrinsic chrome. The previous flex layout had to wrestle
       with each item's box-sizing + appearance + line-height to make
       the row align flush — grid sidesteps it: the cell is the cell,
       and align-items:stretch fills it edge to edge.

       Three columns: two fixed 14ch columns for Lat / Lng, then a
       1fr column the Clear pin sits at the right of (justify-self:end
       on the button below). */
    display: grid;
    grid-template-columns: 14ch 14ch 1fr;
    grid-template-rows: var(--th-tap-target);
    align-items: stretch;
    /* --th-space-3 (12px) gives the Lat / Lng pair and the Clear pin
       a little more breathing room than the form's default --space-2
       gutters — they read as three distinct controls instead of one
       tight cluster. */
    gap: var(--th-space-3);
}
.th-map-field__controls > .th-map-field__coord,
.th-map-field__controls > .th-button {
    /* Each child fills its cell exactly — no centering math, no UA
       chrome that can shift content within the cell. */
    align-self: stretch;
    height: 100%;
}
.th-map-field__controls > .th-button {
    /* Clear pin lives in the third column (1fr) and sits at the far
       right of it via justify-self:end. Box-sizing + appearance reset
       stay so the button's own chrome doesn't bleed past the grid
       cell, but the height is now driven entirely by the cell's
       --th-tap-target row template. */
    box-sizing: border-box;
    -webkit-appearance: none;
    appearance: none;
    min-height: 0;
    padding: 0 var(--th-space-2);
    min-width: 0;
    line-height: 1;
    white-space: nowrap;
    justify-self: end;
}
.th-map-field__coords {
    font-family: ui-monospace, "SF Mono", Consolas, monospace;
    font-size: 0.9em;
    color: var(--th-text-muted);
    margin-left: var(--th-space-2);
}
/* Sits beneath the map. Auto-geocode writes a short message here when
   it can't locate the typed address; any explicit pin (autosuggest,
   map click, dragged pin, typed coords) clears it. */
.th-map-field__hint {
    margin: 0;
    color: var(--th-text-muted);
    font-size: 0.9em;
}
/* Hide the browser-native empty-date placeholder ("dd/mm/yyyy")
   whenever the input has no value — clean blank box at rest.
   data-empty="" is rendered server-side from the Razor binding and
   kept in sync by JS in App.razor as the value changes, so the
   moment the user picks (or types) a real date the attribute drops
   and the date paints normally. Input flow assumes the picker, so
   no :not(:focus) escape hatch — focused-but-empty also stays clean
   instead of flashing "dd/mm/yyyy" the moment the field is tabbed
   into.

   opacity:0 (not color:transparent) so the whole text subtree fades
   out *after* the UA paints the focused-segment selection skin —
   otherwise clicking the field shows a stray "dd" or "yyyy" pill
   floating in an otherwise blank box while the picker is open. */
input[type="date"][data-empty]::-webkit-datetime-edit {
    opacity: 0;
}

/* Role checkbox grid — wraps as many roles per row as the form width
   allows. Each .th-checkbox is a label wrapping a checkbox + text. */
.th-roles {
    display: flex;
    flex-wrap: wrap;
    gap: var(--th-space-2) var(--th-space-3);
}
.th-roles .th-checkbox { white-space: nowrap; }

/* Role chips on the contacts list — pill-shaped, lavender fill, small
   darker border so they read as tags rather than inline text. The
   un-styled <ul> sits inline next to the contact name. */
.th-role-chips {
    list-style: none;
    padding: 0;
    margin: 0;
    display: inline-flex;
    flex-wrap: wrap;
    gap: var(--th-space-1);
    align-items: center;
}
.th-role-chip {
    display: inline-flex;
    align-items: center;
    gap: var(--th-space-1);
    padding: 2px var(--th-space-2);
    border-radius: 999px;
    background: var(--th-lavender);
    border: 1px solid color-mix(in srgb, var(--th-lavender) 80%, black);
    color: var(--th-purple);
    font-size: 0.8em;
    font-weight: 600;
    line-height: 1.4;
    white-space: nowrap;
}
.th-role-chip--retired { font-style: italic; opacity: 0.75; }
/* Origin variants used on the tour-settings "Crew roles" section so the
   user can see at a glance which roles came from the platform vs. their
   space vs. this tour. Muted backgrounds because they're read-only. */
.th-role-chip--platform {
    background: var(--th-grey-light);
    border-color: var(--th-grey-border);
    color: var(--th-text-muted);
}
.th-role-chip--space {
    background: color-mix(in srgb, var(--th-lavender) 35%, var(--th-white));
    border-color: color-mix(in srgb, var(--th-lavender) 60%, var(--th-grey-border));
    color: var(--th-text-muted);
}
.th-role-chip__origin {
    font-size: 0.7em;
    font-weight: 500;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    opacity: 0.7;
    padding-left: 4px;
}
.th-role-chip__remove { display: inline-flex; margin: 0; }
.th-role-chip__remove-btn {
    background: none;
    border: 0;
    color: inherit;
    cursor: pointer;
    font: inherit;
    font-size: 1.1em;
    line-height: 1;
    padding: 0 2px;
    opacity: 0.65;
}
.th-role-chip__remove-btn:hover { opacity: 1; }

/* Manage list — chips with Rename / Remove actions inline. Two-column
   grid on wider viewports so long lists stay scan-able; collapses to a
   single column on narrow screens. Add-row and the row currently being
   edited span the full width so their inputs have room to breathe. */
.th-role-list {
    list-style: none;
    padding: 0;
    margin: 0 0 var(--th-space-3);
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--th-space-1) var(--th-space-2);
}
@media (max-width: 640px) {
    .th-role-list { grid-template-columns: minmax(0, 1fr); }
}
.th-role-list__item {
    display: flex;
    align-items: center;
    gap: var(--th-space-2);
    padding: var(--th-space-1) var(--th-space-2);
    border: 1px solid var(--th-grey-light);
    border-radius: var(--th-radius);
}
/* Editing emphasis: the row being edited gets a purple frame so it
   reads as "this is the active focus" against the other rows. A box-
   shadow ring matches the border colour and adds the extra pixel of
   weight without shifting the layout (border-width changes would
   nudge sibling rows by 1px each render). Extra padding (vs the
   compact view rows) gives the frame breathing room so the inputs
   and chips don't sit right against the purple edge. */
.th-role-list__item--editing {
    grid-column: 1 / -1;
    border-color: var(--th-purple);
    box-shadow: 0 0 0 1px var(--th-purple);
    padding: var(--th-space-3);
    gap: var(--th-space-2);
}
/* Read-only layer (Platform / Space rows on the tour Crew roles tab) —
   visually flatter than tour-editable rows so the user reads the action
   affordance from the rows that have it. */
.th-role-list__item--readonly {
    background: var(--th-grey-light);
    color: var(--th-text-muted);
}
.th-role-list__actions {
    margin-left: auto;
    display: flex;
    align-items: center;
    gap: var(--th-space-1);
}
.th-role-list__actions form { display: inline; margin: 0; }
.th-role-list__edit {
    display: flex;
    align-items: center;
    gap: var(--th-space-1);
    flex: 1;
}
.th-role-list__edit input,
.th-role-list__edit select {
    padding: var(--th-space-1) var(--th-space-2);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-white);
    color: var(--th-text);
    font: inherit;
    line-height: 1.4;
}
/* Only the text input flexes — selects size to their content so the
   question name keeps most of the row. */
.th-role-list__edit input { flex: 1; }
.th-role-list__edit input:focus,
.th-role-list__edit select:focus {
    outline: 2px solid var(--th-purple);
    outline-offset: 1px;
    border-color: var(--th-purple);
}
/* Inline "Required" checkbox in the add/edit row — keep it compact so
   it doesn't push the type select off the line. */
.th-role-list__edit .th-checkbox {
    white-space: nowrap;
    font-size: 0.9em;
    color: var(--th-text-muted);
}

/* Stacked variant — the crew-info add/edit form has too many controls
   (name, type select, required toggle, save button) to fit on one row
   without horizontal crowding. Switch the form to a column and let each
   inner --edit-row keep the original horizontal flex behavior. */
.th-role-list__edit--stacked {
    flex-direction: column;
    align-items: stretch;
    gap: var(--th-space-2);
}
.th-role-list__edit-row {
    display: flex;
    align-items: center;
    gap: var(--th-space-1);
}
.th-role-list__edit-row > input { flex: 1; }
/* The labelled submit (Add) sits next to a select + checkbox that don't
   flex, so by default they cluster left. Push the primary action to the
   right edge of the row to advertise it as the row's commit step. The
   icon-button submits used by the rename row aren't affected — those
   already sit at the right because the rename input flexes. */
.th-role-list__edit-row > .th-button { margin-left: auto; }

/* Inline options editor for SingleChoice / MultiChoice crew-info
   questions. Lives inside the stacked add form, only visible when one
   of the two choice types is picked. Each row is a flex line so the
   "remove" button sits flush with the right edge of the input. */
.th-question-options {
    display: flex;
    flex-direction: column;
    gap: var(--th-space-1);
    padding: var(--th-space-2);
    background: var(--th-grey-light);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
}
/* The browser UA rule `[hidden] { display: none }` is `display: none`
   with low specificity, so our `display: flex` above wins and the
   section stays visible even when the type is Text / Long text. Force
   the hidden state to win for this specific selector. */
.th-question-options[hidden] { display: none; }
.th-question-options__list {
    display: flex;
    flex-direction: column;
    gap: var(--th-space-1);
}
.th-question-options__row {
    display: flex;
    align-items: center;
    gap: var(--th-space-1);
}
.th-question-options__row input[type="text"] {
    flex: 1;
    padding: var(--th-space-1) var(--th-space-2);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-white);
    color: var(--th-text);
    font: inherit;
}
.th-question-options__row input[type="text"]:focus {
    outline: 2px solid var(--th-purple);
    outline-offset: 1px;
    border-color: var(--th-purple);
}

/* Inline "add row" — same shell as a real role row, but the chip is
   replaced by the input. Dotted border distinguishes the add-affordance
   from existing rows. */
.th-role-list__item--add { grid-column: 1 / -1; border-style: dashed; }
.th-role-list__item--add .th-role-list__edit { flex: 1; margin: 0; }

/* Single-column list — crew-info questions stack one per row so long
   names + the Req/Draft chips + actions row have room to breathe.
   Defined as a modifier so unrelated lists keep their 2-column grid. */
.th-role-list--stacked { grid-template-columns: minmax(0, 1fr); }
/* Allow the row's body to keep its horizontal flex while the nested
   sub-list (choice options) wraps to a new line below the body. */
.th-role-list--stacked > .th-role-list__item { flex-wrap: wrap; }
.th-role-list--stacked > .th-role-list__item > .th-role-list--sub {
    flex: 0 0 100%;
    margin: var(--th-space-1) 0 0 var(--th-space-3);
}
/* `[hidden]` would lose to `.th-role-list { display: grid }` otherwise. */
.th-role-list--sub[hidden] { display: none; }
/* The body wrapper keeps name + chips + actions on one flex row, and
   lets the sub-list (rendered as a sibling of the body) wrap underneath. */
.th-role-list__item-body {
    flex: 1 1 auto;
    display: flex;
    align-items: center;
    gap: var(--th-space-2);
    min-width: 0;
}
/* Breathing room between the question text and its inline chips so the
   chip doesn't read as part of the label. */
.th-role-list__item-name .th-chip { margin-left: var(--th-space-2); }

/* Right-aligned add affordance — `Add question` sits at the row's far
   right edge, mirroring the desktop "primary action lives bottom-right"
   convention used elsewhere in settings. */
.th-add-row {
    display: flex;
    justify-content: flex-end;
    margin-top: var(--th-space-3);
}

/* Add-question card — full table width with the grey "chrome surface"
   feel of the EventCrew add-slot form. Distinguishes the staging area
   from the list above it without using the narrow .th-form-page--inline
   (32rem max-width) which leaves the right side of the table empty. */
.th-add-question-form {
    background: var(--th-grey-light);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    padding: var(--th-space-3);
    margin: 0 0 var(--th-space-5);
}
.th-add-question-form h3 {
    margin-top: 0;
}

/* Drag-to-reorder affordance for crew-info question rows. Handle is a
   grippy six-dot glyph at the left edge; cursor changes signal the
   row is draggable. Drop target shows a purple line at its top edge
   so the admin can see where the dragged row will land. */
.th-drag-handle {
    flex: 0 0 auto;
    color: var(--th-text-muted);
    cursor: grab;
    user-select: none;
    font-size: 1rem;
    line-height: 1;
    padding: 0 var(--th-space-1);
}
.th-role-list[data-th-reorderable] .th-role-list__item { cursor: grab; }
.th-role-list[data-th-reorderable] .th-role-list__item.is-dragging { opacity: 0.4; }
.th-role-list[data-th-reorderable] .th-role-list__item.is-drop-target {
    box-shadow: 0 -2px 0 var(--th-purple);
}
.th-role-list[data-th-reorderable] .th-role-list__item.is-drop-target-after {
    box-shadow: 0 2px 0 var(--th-purple);
}
/* Hide the handle on edit-mode rows — dragging a row mid-edit would
   lose the form state and confuse the user. */
.th-role-list__item--editing .th-drag-handle { display: none; }
.th-role-list__item--editing { cursor: default; }

/* Free-text combo input + suggestions panel (SuggestInput). The input
   itself is the search field; the caret button (right-aligned overlay)
   toggles the panel for users who want a visible dropdown affordance. */
.th-suggest { position: relative; display: block; }
.th-suggest__input {
    width: 100%;
    padding: var(--th-space-1) calc(var(--th-tap-target) + var(--th-space-1)) var(--th-space-1) var(--th-space-2);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-white);
    color: var(--th-text);
    font: inherit;
    box-sizing: border-box;
}
.th-suggest__input:focus {
    outline: 2px solid var(--th-purple);
    outline-offset: 1px;
    border-color: var(--th-purple);
}
.th-suggest__caret {
    position: absolute;
    top: 0;
    right: 0;
    height: 100%;
    width: var(--th-tap-target);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border: 0;
    background: transparent;
    color: var(--th-text-muted);
    font-size: 0.8em;
    cursor: pointer;
    padding: 0;
}
.th-suggest__panel {
    position: absolute;
    top: calc(100% + 4px);
    left: 0;
    right: 0;
    z-index: 40;
    margin: 0;
    padding: var(--th-space-1) 0;
    list-style: none;
    max-height: 14rem;
    overflow-y: auto;
    background: var(--th-white);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
}
.th-suggest__panel--up {
    top: auto;
    bottom: calc(100% + 4px);
}
.th-suggest__option {
    padding: var(--th-space-1) var(--th-space-2);
    cursor: pointer;
}
.th-suggest__option:hover { background: var(--th-grey-light); }

/* Compact square button used inside .th-role-list rows. 32×32 hit target,
   borderless by default with a soft hover. Variants add fill colors for
   the primary (save/add) and danger (remove) actions. SVGs inside are
   sized to 16px and inherit stroke from currentColor so the icon picks
   up the variant tint automatically. */
.th-icon-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 32px;
    height: 32px;
    padding: 0;
    margin: 0;
    border: 1px solid transparent;
    border-radius: var(--th-radius);
    background: transparent;
    color: var(--th-text-muted);
    cursor: pointer;
    text-decoration: none;
    /* Align by middle, not baseline. Inline-flex picks its baseline from
       inner content, so an SVG-icon button, a Unicode-glyph button (↑↓),
       and a larger "×" anchor end up on different baselines and visibly
       jitter up/down when clustered in a row (Setlist edit actions). */
    vertical-align: middle;
}
.th-icon-btn:where(:not(:disabled):not(.th-icon-btn--busy)):hover,
.th-icon-btn:where(:not(:disabled):not(.th-icon-btn--busy)):focus {
    background: var(--th-grey-light);
    border-color: var(--th-grey-border);
    color: var(--th-text);
}
.th-icon-btn svg { width: 16px; height: 16px; }

/* `display: inline-flex` above (author origin) beats the UA `[hidden]
   { display: none }`, so the `hidden` attribute alone leaves a button
   visible — the same hidden + display:flex trap restated for the audio
   player, crew filter, flight panel, etc. The live-show transport bar
   toggles its play/pause/skip/stop controls purely via `hidden`, so
   restate it here or the inactive controls keep showing (two play
   glyphs) and their clicks read as inert. */
.th-button[hidden],
.th-icon-btn[hidden] { display: none; }

/* Primary icon — main action (Save tick on an inline-edit row, Add).
   Lavender pill at rest with a purple glyph; hover escalates to the
   solid-purple "confidently interactive" look. Diverges deliberately
   from the text .th-button--primary (which stays solid purple) — small
   row-tool icons read better with quieter chrome, especially in dark
   mode where solid purple is loud against the dark surface. The
   --secondary icon below shares this resting skin; the two variants
   are kept as semantic labels rather than aliased so call-sites still
   express "this is THE action" vs. "this is an option". */
.th-icon-btn--primary {
    color: var(--th-purple);
    background: var(--th-lavender);
    border-color: color-mix(in srgb, var(--th-lavender) 85%, black);
}
.th-icon-btn--primary:where(:not(:disabled):not(.th-icon-btn--busy)):hover,
.th-icon-btn--primary:where(:not(:disabled):not(.th-icon-btn--busy)):focus {
    color: var(--th-icon-btn-hover-glyph);
    background: var(--th-icon-btn-hover-purple);
    border-color: var(--th-icon-btn-hover-purple);
}

/* Secondary icon — second-tier action. Lavender pill at rest mirrors
   the text-secondary resting skin; hover escalates to the solid-purple
   primary look so the click target reads as confidently interactive. */
.th-icon-btn--secondary {
    color: var(--th-purple);
    background: var(--th-lavender);
    border-color: color-mix(in srgb, var(--th-lavender) 85%, black);
}
.th-icon-btn--secondary:where(:not(:disabled):not(.th-icon-btn--busy)):hover,
.th-icon-btn--secondary:where(:not(:disabled):not(.th-icon-btn--busy)):focus {
    color: var(--th-icon-btn-hover-glyph);
    background: var(--th-icon-btn-hover-purple);
    border-color: var(--th-icon-btn-hover-purple);
}

/* Cancel icon — dismiss/abort a draft form. Idle glyph is muted toward
   the text color so the small icon doesn't read as vivid orange against
   the near-white surface (light mode only — dark-mode override keeps the
   bold amber). Hover dilutes the amber and switches the glyph to dark
   text so the 32×32 fill stays legible. */
.th-icon-btn--cancel {
    color: var(--th-icon-btn-cancel-rest);
    background: var(--th-alert-warning-bg);
    border-color: color-mix(in srgb, var(--th-alert-warning-bg) 85%, black);
    font-size: 1.2em;
    line-height: 1;
}
.th-icon-btn--cancel:where(:not(:disabled):not(.th-icon-btn--busy)):hover,
.th-icon-btn--cancel:where(:not(:disabled):not(.th-icon-btn--busy)):focus {
    color: var(--th-icon-btn-hover-glyph);
    background: var(--th-icon-btn-hover-warning);
    border-color: var(--th-icon-btn-hover-warning);
}

/* Danger icon — delete persisted data. Soft pink pill at rest carries
   the destructive cue without screaming; hover dilutes the red toward
   white so the small 32×32 fill doesn't read as a heavy block. */
.th-icon-btn--danger {
    color: var(--th-error);
    background: var(--th-alert-error-bg);
    border-color: color-mix(in srgb, var(--th-alert-error-bg) 85%, black);
}
.th-icon-btn--danger:where(:not(:disabled):not(.th-icon-btn--busy)):hover,
.th-icon-btn--danger:where(:not(:disabled):not(.th-icon-btn--busy)):focus {
    color: var(--th-icon-btn-hover-glyph);
    background: var(--th-icon-btn-hover-error);
    border-color: var(--th-icon-btn-hover-error);
}
/* Body.th-suppress-hover is toggled on by App.razor between an
   enhanced-nav swap and the next user input (mousemove / touch / key).
   It cancels the hover style so a stationary cursor over a freshly-
   swapped trash icon doesn't pop full red — the destructive cue would
   otherwise read as "this row is next on the chopping block". Reverts
   to the resting pink fill so the button still reads as destructive
   while the cursor sits motionless. */
body.th-suppress-hover .th-icon-btn--danger:hover {
    color: var(--th-error);
    background: var(--th-alert-error-bg);
    border-color: color-mix(in srgb, var(--th-alert-error-bg) 85%, black);
}

/* Invite / re-send mail icon — orange outline so the "send them a link"
   action stands out from the quiet muted Edit pencil beside it. Outlined
   (orange border + orange glyph over a transparent rest) rather than a
   soft pill so it reads as a distinct call-to-action; hover fills the
   pill with soft amber to confirm interactivity. */
.th-icon-btn--invite {
    color: var(--th-warning-text);
    background: transparent;
    border-color: var(--th-warning);
}
.th-icon-btn--invite:where(:not(:disabled):not(.th-icon-btn--busy)):hover,
.th-icon-btn--invite:where(:not(:disabled):not(.th-icon-btn--busy)):focus {
    color: var(--th-warning-text);
    background: var(--th-alert-warning-bg);
    border-color: var(--th-warning);
}

/* Round user thumbnail. Used inline beside names (EventCrew, Admin/Users)
   and as the standalone preview on the profile page. Three sizes — Sm 32px
   for table rows, Md 56px for compact headers, Lg 128px for the profile
   editor. Either renders an <img> over a gated /u/{id}/avatar request or
   falls back to coloured initials when the user hasn't uploaded a picture
   yet. The initials use an 8-colour palette indexed by the user id's hash
   so the same user always lands on the same tile colour. */
.th-avatar {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    /* Rounded square instead of a circle — 20% scales nicely from the
       32px table tile up to the 128px profile editor, leaving each size
       visibly squircle-shaped rather than a pill or a near-square. */
    border-radius: 20%;
    background: var(--th-grey-light);
    color: var(--th-text);
    font-weight: 600;
    overflow: hidden;
    flex-shrink: 0;
    vertical-align: middle;
}
img.th-avatar { object-fit: cover; }
.th-avatar--sm { width: 32px; height: 32px; font-size: 12px; }
.th-avatar--md { width: 56px; height: 56px; font-size: 18px; }
.th-avatar--lg { width: 128px; height: 128px; font-size: 40px; }
.th-avatar__initials { letter-spacing: 0.02em; line-height: 1; }
.th-avatar--c0 { background: #d7c5f5; color: #4a148c; }
.th-avatar--c1 { background: #c5e3f5; color: #0b4a6b; }
.th-avatar--c2 { background: #c5f5d8; color: #155724; }
.th-avatar--c3 { background: #f5e0c5; color: #7a4a00; }
.th-avatar--c4 { background: #f5c5cd; color: #7a1f2a; }
.th-avatar--c5 { background: #e0c5f5; color: #4a148c; }
.th-avatar--c6 { background: #c5f5ef; color: #0e5c52; }
.th-avatar--c7 { background: #f5e9c5; color: #6b5300; }

/* Inline avatar + name pair. Sets the row up as a flex container so the
   thumbnail and the (existing) name link share a baseline-friendly gap
   without leaking flex layout into the wider table cell. */
.th-avatar-row {
    display: inline-flex;
    align-items: center;
    gap: var(--th-space-2);
}

/* Profile page picture editor. Sits at the top of /account/profile/about
   above the "About you" subhead. The picture preview is full-width above
   the action buttons on mobile, falling into a row on wider viewports. */
.th-profile-picture {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--th-space-4);
    margin-bottom: var(--th-space-6);
}
.th-profile-picture__actions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--th-space-2);
}
.th-profile-picture__hidden-input { display: none; }

/* Read-only /users/{id}/profile header. The title row keeps the h1 inline
   with a small "Edit" affordance that only renders when viewing yourself. */
.th-user-profile__title {
    display: flex;
    align-items: baseline;
    gap: var(--th-space-3);
    flex-wrap: wrap;
}
.th-user-profile__edit {
    font-size: 0.9rem;
    margin-left: auto;
}

/* Avatar on the left, contact-basics key/value list on the right. Stacks
   at the standard 640px breakpoint so the avatar sits above the details
   on phones. */
.th-user-profile__head {
    display: grid;
    grid-template-columns: max-content 1fr;
    gap: var(--th-space-6);
    align-items: start;
    margin-bottom: var(--th-space-6);
}
@media (max-width: 640px) {
    .th-user-profile__head { grid-template-columns: 1fr; }
}

/* Cropper modal — backs the client-side square cropper. The <dialog> ships
   with a sensible default sizing that we just nudge to fit the cropper
   canvas comfortably on mobile. */
.th-cropper-dialog {
    width: min(92vw, 480px);
    padding: var(--th-space-4);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-surface);
    color: var(--th-text);
}
.th-cropper-dialog::backdrop { background: rgba(0, 0, 0, 0.55); }
.th-cropper-dialog__canvas {
    width: 100%;
    aspect-ratio: 1 / 1;
    background: var(--th-grey-light);
    overflow: hidden;
}
.th-cropper-dialog__canvas img { display: block; max-width: 100%; }
.th-cropper-dialog__actions {
    display: flex;
    justify-content: flex-end;
    gap: var(--th-space-2);
    margin-top: var(--th-space-3);
}

/* Form submit/cancel row — destructive action (Cancel) on the left, the
   primary action (Save / Create) far right. Aligns with the desktop
   convention of "primary action lives in the bottom-right corner". */
.th-form-actions {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: var(--th-space-2);
    margin-top: var(--th-space-4);
}

/* Variant for edit-mode form actions: Remove trash on the left, Cancel
   + Save icon pair flush right inside .th-form-actions__right. Matches
   the inline-editor standard (Remove visually separated from the
   destructive-adjacent Save). */
.th-form-actions--split { justify-content: flex-start; }
.th-form-actions--split .th-form-actions__right {
    margin-left: auto;
    display: flex;
    align-items: center;
    gap: var(--th-space-2);
}


/* Compact coordinate inputs sitting inline with Find/Clear buttons. No
   external label — the placeholder "Lat" / "Lng" shows when empty and the
   aria-label keeps screen readers happy. 8ch fits "-90.1234" / "-180.123"
   for a typical-precision display; the model still stores full precision
   if the user types more.

   The double-class selector wins specificity over the .th-field
   input[type="number"] { width: 100% } rule above, so these inputs stay
   compact instead of stretching full-width and wrapping to a new line. */
.th-field .th-map-field__coord.th-map-field__coord {
    /* 14ch comfortably shows full-precision coords like "-180.123456"
       on every viewport. flex-shrink:0 + the explicit width keep both
       inputs at exactly this width; the Clear pin button (above)
       absorbs the squeeze on narrow phones instead.

       Height + padding match the standard .th-field input rule above
       so the coord boxes line up flush with the Clear pin button and
       neighbouring text inputs — keeping them shorter (the old
       `height: auto`) left them visibly squatter than the rest of the
       row.

       appearance:none + box-sizing:border-box mirror the Clear pin
       button's reset so both elements skip the native browser chrome
       (Android's <input type="number"> in particular added a small
       internal offset that pushed the input's content lower than the
       button's, reading as the button being a couple of px higher). */
    box-sizing: border-box;
    -webkit-appearance: none;
    appearance: none;
    width: 14ch;
    flex: 0 0 14ch;
    min-width: 0;
    height: var(--th-tap-target);
    padding: 0 var(--th-space-2);
    font: inherit;
    font-family: ui-monospace, "SF Mono", Consolas, monospace;
    line-height: 1;
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-white);
    color: var(--th-text);
}
/* Hide spinner arrows — they eat width and nudging coords by 1 is useless. */
.th-map-field__coord::-webkit-outer-spin-button,
.th-map-field__coord::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.th-map-field__coord { -moz-appearance: textfield; }
.th-event-map {
    height: 320px;
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    /* z-index quirk: Leaflet's popup/control layers default to z 600-1000,
       which sits above anything else we render. Keep the map confined inside
       its own stacking context so it can't poke through the page header. */
    isolation: isolate;
}

/* ---------- Collapsible section ----------
   <details> wrapper with a <summary> styled as a button. Used on /tours so
   the "Add tour" form is hidden until the user explicitly opens it. The
   summary inherits .th-button styling — no +/− prefix decoration; the
   form appearing below is the "expanded" affordance. When open, the
   trigger picks up the purple accent so it's clear it's the active row. */
.th-collapse { margin: var(--th-space-4) 0; }
.th-collapse > .th-collapse__summary {
    list-style: none;
}
.th-collapse > .th-collapse__summary::-webkit-details-marker { display: none; }
.th-collapse > .th-collapse__summary::marker { content: ""; }
.th-collapse[open] > .th-collapse__summary {
    border-color: var(--th-purple);
    color: var(--th-purple);
}
.th-collapse__body {
    margin-top: var(--th-space-3);
    padding: var(--th-space-3) var(--th-space-4);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-grey-light);
}

.th-info {
    background: var(--th-grey-light);
    border-radius: var(--th-radius);
    padding: var(--th-space-3) var(--th-space-4);
    margin-bottom: var(--th-space-4);
}
.th-info h2 { margin: 0 0 var(--th-space-2); font-size: 1rem; color: var(--th-text); }

.th-kv {
    display: grid;
    grid-template-columns: max-content 1fr;
    gap: var(--th-space-1) var(--th-space-3);
    margin: 0;
}
.th-kv dt { font-weight: 600; color: var(--th-text-muted); }
.th-kv dd { margin: 0; word-break: break-word; }

.th-error-detail {
    margin: var(--th-space-2) 0 0;
    padding: var(--th-space-2);
    background: var(--th-white);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    font-family: ui-monospace, "SF Mono", Consolas, monospace;
    font-size: 0.85em;
    white-space: pre-wrap;
    word-break: break-word;
}
.th-text-muted { color: var(--th-text-muted); font-size: 0.9em; }
.th-input {
    height: var(--th-tap-target);
    padding: 0 var(--th-space-3);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    margin-right: var(--th-space-2);
}

/* Email templates admin page — list of known templates + edit form. */
.th-email-templates {
    list-style: none;
    padding: 0;
    margin: 0;
    display: flex;
    flex-direction: column;
    gap: var(--th-space-3);
}
.th-email-templates__item {
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    padding: var(--th-space-3);
    background: var(--th-white);
}
.th-email-templates__head {
    display: flex;
    align-items: center;
    gap: var(--th-space-2);
    margin-bottom: var(--th-space-1);
}
.th-email-templates__name {
    font-weight: 600;
    color: var(--th-text);
    text-decoration: none;
}
.th-email-templates__badge {
    font-size: 0.75rem;
    padding: 2px var(--th-space-2);
    border-radius: 999px;
    background: var(--th-grey-light);
    color: var(--th-text-muted);
    text-transform: uppercase;
    letter-spacing: 0.05em;
}
.th-email-templates__badge--custom {
    background: var(--th-purple);
    color: var(--th-white);
}
.th-email-templates__desc { margin: 0 0 var(--th-space-1); color: var(--th-text); }
.th-email-templates__meta { margin: 0; color: var(--th-text-muted); font-size: 0.9rem; }
.th-email-templates__hint { margin: 0 0 var(--th-space-2); color: var(--th-text-muted); font-size: 0.9rem; }
.th-email-templates__textarea {
    font-family: ui-monospace, "SF Mono", Consolas, monospace;
    font-size: 0.9rem;
}
.th-email-templates__textarea--code { min-height: 18rem; }
.th-email-templates__reset-hint { margin: var(--th-space-4) 0 var(--th-space-2); color: var(--th-text-muted); font-size: 0.9rem; }
.th-email-templates__defaults { margin-top: var(--th-space-4); }
.th-email-templates__defaults summary { cursor: pointer; font-weight: 600; padding: var(--th-space-2) 0; }
.th-email-templates__pre {
    margin: 0;
    padding: var(--th-space-2);
    background: var(--th-white);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    font-family: ui-monospace, "SF Mono", Consolas, monospace;
    font-size: 0.85rem;
    white-space: pre-wrap;
    max-height: 30rem;
    overflow: auto;
}

/* ---------- Mobile-first refinements ---------- */

@media (max-width: 640px) {
    .th-app__main { padding: var(--th-space-2) var(--th-space-3) var(--th-space-3); }
    .th-button { width: 100%; margin-right: 0; margin-bottom: var(--th-space-2); }
    .th-button + .th-button { margin-left: 0; }

    /* The desktop .th-event-chrome rule uses var(--th-space-4) (24px) for
       its negative side margins to cancel the same-sized .th-app__main
       padding. On mobile main shrinks to var(--th-space-3) (16px) of
       padding, so the desktop bleed extends 8px past the viewport on each
       side — that's the source of the "page slides left/right" overflow.
       Rebalance to the mobile gutter so the chrome ends flush with the
       viewport edge.

       Vertically, swap main's 8px top padding for the chrome's own 8px
       padding-top. The two cancel out, so the breathing strip above the
       title stays at a constant 8px in both the at-rest and sticky-docked
       positions — no visible slide when the user scrolls past the dock
       line. */
    .th-event-chrome {
        margin-inline: calc(var(--th-space-3) * -1);
        padding-inline: var(--th-space-3);
        margin-top: calc(var(--th-space-2) * -1);
        padding-top: var(--th-space-2);
    }
    .th-event-chrome h1 { margin-bottom: 0; }
    /* Tighter still on phones — pull the tab strip right up under the tour
       name (was --th-space-2). */
    .th-event-chrome .th-event-header { margin-bottom: var(--th-space-1); }
    /* Collapse the date→title gap on mobile so the small muted date
       sits flush above the H1 — the H1's line-height already adds a
       few px of optical breathing room above the cap. Saves vertical
       space in the sticky chrome. */
    .th-event-header__title-row { gap: 0; }
    /* Single-line venue + website on mobile — the row below already
       carries city/country, so truncation here doesn't lose info and
       keeps the header height predictable when names/URLs run long. */
    .th-event-header__venue-name,
    .th-event-header__website {
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        max-width: 100%;
        min-width: 0;
    }
    /* When the back link is hidden (single-tour tenant), the empty nav
       still carries its 8px margin-bottom and stacks on top of the
       chrome's 8px padding-top — total 16px above the title. Collapse
       the empty row so the title sits at a clean 8px below the page
       header dock line. */
    .th-event-header__nav:not(:has(*)) { margin: 0; }

    /* Hamburger drawer — show the toggle button, hide the inline nav
       until JS adds .th-app__nav--open. Open state docks the nav as a
       full-width panel anchored to the viewport below the header (not
       the .th-app__menu wrapper — that wrapper sits at the right edge
       of the header, so anchoring to it would push the drawer off the
       right side of the screen). The header stays single-row, so
       --th-header-h keeps its default 4.75rem. */
    .th-app__menu-trigger {
        display: inline-flex;
    }
    .th-app__menu-trigger:hover { border-color: var(--th-purple); color: var(--th-purple); }
    .th-app__menu-trigger:focus-visible {
        outline: 2px solid var(--th-purple);
        outline-offset: 1px;
        border-color: var(--th-purple);
    }
    .th-app__menu-trigger[aria-expanded="true"] {
        border-color: var(--th-purple);
        color: var(--th-purple);
        background: var(--th-lavender);
    }

    /* Hide inline nav until JS opens the drawer. */
    .th-app__nav { display: none; }
    .th-app__nav.th-app__nav--open {
        display: flex;
        flex-direction: column;
        align-items: stretch;
        gap: 0;
        position: fixed;
        top: var(--th-header-h);
        left: 0;
        right: 0;
        background: var(--th-white);
        border-top: 1px solid var(--th-grey-border);
        border-bottom: 1px solid var(--th-grey-border);
        /* Heavy two-stop downward shadow so the drawer reads as
           clearly hanging over the page below it. Dark-mode variant
           further down swaps black for brand purple — black-on-dark
           is invisible against the dark page background. */
        box-shadow:
            0 6px 12px rgba(0, 0, 0, 0.22),
            0 24px 48px rgba(0, 0, 0, 0.32);
        padding: var(--th-space-1);
        z-index: 150;
        max-height: calc(100dvh - var(--th-header-h));
        overflow-y: auto;
    }

    /* Dark-mode drawer lift: tint shadow brand-purple — luminous
       against the #161618 page where black shadows disappear. */
    :root[data-theme="dark"] .th-app__nav.th-app__nav--open {
        box-shadow:
            0 6px 16px rgba(194, 139, 224, 0.30),
            0 18px 40px rgba(194, 139, 224, 0.22);
    }
    @media (prefers-color-scheme: dark) {
        :root:not([data-theme]) .th-app__nav.th-app__nav--open {
            box-shadow:
                0 6px 16px rgba(194, 139, 224, 0.30),
                0 18px 40px rgba(194, 139, 224, 0.22);
        }
    }
    /* Drawer rows — each direct child becomes a full-width row at
       the 44px tap-target floor. The Theme / ViewAs / Profile
       dropdowns retain their internal layout; this rule just gives
       them the row container. */
    .th-app__nav.th-app__nav--open > * {
        min-height: var(--th-tap-target);
        display: flex;
        align-items: center;
        justify-content: flex-start;
        width: 100%;
        padding: 0 var(--th-space-3);
        border-radius: var(--th-radius);
    }
    .th-app__nav.th-app__nav--open > a {
        color: var(--th-text);
    }
    .th-app__nav.th-app__nav--open > a:hover,
    .th-app__nav.th-app__nav--open > a:focus {
        background: var(--th-lavender);
        color: var(--th-purple);
    }
    /* Lang <details> keeps its absolute popover inside the drawer (the
       menu is narrow enough to fit) — just anchor it at the left edge
       so it doesn't overflow the drawer's right side. Theme and ViewAs
       are handled below by collapsing the popover into an in-flow
       accordion row, so they're intentionally excluded here. */
    .th-app__nav.th-app__nav--open .th-lang__menu {
        right: auto;
        left: 0;
    }

    /* Theme / ViewAs render as full-width accordion drawer rows on
       phones instead of pill/icon triggers with floating popovers. The
       <details> itself loses the row container styling so the <summary>
       can become the row; tapping it expands the options in-flow below
       so every choice stays reachable inside the scrollable sheet. */
    .th-app__nav.th-app__nav--open > .th-theme {
        display: block;
        padding: 0;
        min-height: 0;
        border-radius: 0;
    }

    /* Strip the pill/icon-button chrome from the summary and restyle it
       as a labelled drawer row: icon | label | current value | chevron.
       Chevron rotates from ▸ to ▾ when the parent <details> is [open]. */
    .th-app__nav.th-app__nav--open .th-theme__current {
        display: flex;
        align-items: center;
        justify-content: flex-start;
        gap: var(--th-space-3);
        width: 100%;
        height: auto;
        min-height: var(--th-tap-target);
        padding: 0 calc(var(--th-space-3) + 18px) 0 var(--th-space-3);
        border: 0;
        border-radius: var(--th-radius);
        background-color: transparent;
        background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='9 6 15 12 9 18'/></svg>");
        background-repeat: no-repeat;
        background-position: right var(--th-space-3) center;
        background-size: 14px 14px;
        color: var(--th-text);
        font: inherit;
    }
    .th-app__nav.th-app__nav--open .th-theme[open] > .th-theme__current {
        background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
    }
    .th-app__nav.th-app__nav--open .th-theme__current:hover {
        background-color: var(--th-lavender);
        color: var(--th-purple);
    }

    /* Order the row contents so the icon stays on the left, the label
       sits next to it, and the current value floats to the right edge
       just before the chevron. The flex order overrides the natural
       ::before-then-children-then-::after document order. */
    .th-app__nav.th-app__nav--open .th-theme__current .th-theme__icon {
        order: 1;
    }
    .th-app__nav.th-app__nav--open .th-theme__current::after {
        order: 2;
        font: inherit;
    }
    .th-app__nav.th-app__nav--open .th-theme__current::before {
        order: 3;
        margin-left: auto;
        color: var(--th-text-muted);
        font: inherit;
        font-weight: 400;
    }

    /* Inject the row label ("Theme") via ::after so the summary stays
       a labelled drawer row even though its markup only holds an icon. */
    .th-app__nav.th-app__nav--open .th-theme__current::after  { content: "Theme"; }

    /* Theme has no value text in the markup; inject it via ::before
       keyed off the configured data-theme. The trigger reflects the
       chosen mode (System / Light / Dark), not the effective one — the
       icon to its left already shows what's actually rendered. */
    :root:not([data-theme]) .th-app__nav.th-app__nav--open .th-theme__current::before { content: "System"; }
    :root[data-theme="light"] .th-app__nav.th-app__nav--open .th-theme__current::before { content: "Light"; }
    :root[data-theme="dark"]  .th-app__nav.th-app__nav--open .th-theme__current::before { content: "Dark"; }

    /* Drop the popover layout entirely when open — the menu becomes a
       static block underneath the summary so its options push the rest
       of the drawer down rather than floating off-screen. */
    .th-app__nav.th-app__nav--open .th-theme[open]  > .th-theme__menu {
        position: static;
        top: auto;
        left: auto;
        right: auto;
        min-width: 0;
        width: 100%;
        border: 0;
        border-radius: 0;
        box-shadow: none;
        background: transparent;
        padding: 0;
    }

    /* Each option indents to align under the summary's label text so
       the group reads as a settings-style nested child of the row
       above. Width fills the drawer; the existing --active highlight
       selectors still apply because they target the option directly. */
    .th-app__nav.th-app__nav--open .th-theme__option {
        width: 100%;
        min-height: var(--th-tap-target);
        padding-left: calc(var(--th-space-3) * 2 + 22px);
        padding-right: var(--th-space-3);
        border-radius: var(--th-radius);
    }

    /* User-menu accordion override — mirrors the Theme block above. The
       <details> sheds the pill chrome and becomes a labelled drawer row;
       its menu options stack inline below when [open]. The chevron is
       painted as a background SVG on the summary so the rotated-on-open
       state matches Theme / ViewAs. */
    .th-app__nav.th-app__nav--open > .th-usermenu {
        display: block;
        padding: 0;
        min-height: 0;
        border-radius: 0;
    }
    .th-app__nav.th-app__nav--open .th-usermenu__current {
        display: flex;
        align-items: center;
        justify-content: flex-start;
        gap: var(--th-space-3);
        width: 100%;
        height: auto;
        min-height: var(--th-tap-target);
        padding: 0 calc(var(--th-space-3) + 18px) 0 var(--th-space-3);
        border: 0;
        border-radius: var(--th-radius);
        background-color: transparent;
        background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='9 6 15 12 9 18'/></svg>");
        background-repeat: no-repeat;
        background-position: right var(--th-space-3) center;
        background-size: 14px 14px;
        color: var(--th-text);
        font: inherit;
    }
    .th-app__nav.th-app__nav--open .th-usermenu[open] > .th-usermenu__current {
        background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
    }
    .th-app__nav.th-app__nav--open .th-usermenu__current:hover {
        background-color: var(--th-lavender);
        color: var(--th-purple);
    }
    /* Hide the inline chevron span — the drawer summary paints its own
       chevron as a background image at the right edge. */
    .th-app__nav.th-app__nav--open .th-usermenu__chevron { display: none; }

    /* Drop the popover layout when open — the menu becomes static and
       pushes the rest of the drawer down rather than floating. */
    .th-app__nav.th-app__nav--open .th-usermenu[open] > .th-usermenu__menu {
        position: static;
        top: auto;
        left: auto;
        right: auto;
        min-width: 0;
        width: 100%;
        border: 0;
        border-radius: 0;
        box-shadow: none;
        background: transparent;
        padding: 0;
    }
    .th-app__nav.th-app__nav--open .th-usermenu__option {
        width: 100%;
        min-height: var(--th-tap-target);
        padding-left: calc(var(--th-space-3) * 2 + 22px);
        padding-right: var(--th-space-3);
        border-radius: var(--th-radius);
    }

    /* Inside the drawer the whole sheet already scrolls, so a nested
       scroller on the tours menu would be confusing. Drop the cap and
       let the option list stack inline like every other accordion row. */
    .th-app__nav.th-app__nav--open .th-toursmenu__menu {
        max-height: none;
        overflow: visible;
    }

    /* Touch-target audit — icon buttons run at 28–32px on desktop where
       pointers are precise. On touch viewports bump every variant up to
       the full --th-tap-target so they meet the 44×44 floor regardless
       of the context they're embedded in (schedule editor, contacts
       row, role-list row, etc.). */
    .th-icon-btn,
    .th-schedule--with-form-cell .th-schedule__actions .th-icon-btn,
    .th-contacts-row__actions .th-icon-btn {
        width: var(--th-tap-target);
        height: var(--th-tap-target);
        min-width: var(--th-tap-target);
        min-height: var(--th-tap-target);
    }

    /* Suggestion-dropdown rows can dip below 44px when the line wraps to
       a single line. Bump the floor so each option is comfortably
       tappable on a phone. */
    .th-suggestion,
    .th-suggest__option {
        min-height: var(--th-tap-target);
        display: flex;
        align-items: center;
    }

    /* Reflow .th-table / .th-schedule to a stack of cards. Each <tr>
       becomes a card; each <td> becomes a label/value row. Labels come
       from data-label="ColumnName" on the <td>; cells without one just
       render their content without a label prefix.

       Exceptions:
        - .th-table__actions / .th-schedule__actions stay as a right-aligned
          button cluster.
        - .th-schedule__form-cell (inline-edit colspan cell) renders its
          inner form as a full-width block rather than as a label/value
          row — the .th-schedule-edit child already carries card chrome.
        - .th-daysheet__schedule (public daysheet) intentionally keeps its
          two-column table layout — time + item line up as compact rows so
          the whole show fits at a glance on a phone in a venue. */
    .th-table,
    .th-schedule {
        display: block;
        width: 100%;
    }
    .th-table thead,
    .th-schedule thead {
        display: none;
    }
    .th-table tbody,
    .th-schedule tbody {
        display: block;
    }
    .th-table tr,
    .th-schedule tr {
        display: block;
        border: 1px solid var(--th-grey-border);
        border-radius: var(--th-radius);
        margin-bottom: var(--th-space-3);
        padding: var(--th-space-3);
        background: var(--th-white);
    }
    /* The day divider is a full-width band, not a card. Strip the card chrome
       the rule above adds, and let its cell fill the table width (overriding
       the generic td width:auto below) so the dateline stretches edge to edge
       on a phone. */
    .th-schedule tr.th-schedule__day-row {
        border: 0;
        border-radius: 0;
        background: transparent;
        margin-bottom: 0;
        padding: 0;
    }
    .th-schedule td.th-schedule__day-cell {
        width: 100%;
        padding: var(--th-space-3) 0 0;
    }
    .th-table td,
    .th-schedule td {
        display: block;
        border: 0;
        padding: var(--th-space-1) 0;
        white-space: normal;
        /* Cells declare explicit widths (1%, 4.5rem etc.) for the desktop
           table layout — override to natural width inside the card. */
        width: auto;
    }
    .th-table td[data-label]::before,
    .th-schedule td[data-label]::before {
        content: attr(data-label);
        display: block;
        font-weight: 600;
        color: var(--th-text-muted);
        font-size: 0.75em;
        text-transform: uppercase;
        letter-spacing: 0.05em;
        margin-bottom: var(--th-space-1);
    }
    /* Action cells render their buttons in a right-aligned flex cluster,
       sized to natural width so the buttons don't stretch full-card.

       Negative inline-end margin bleeds the cluster into the card's right
       padding so the icon glyphs align with the card edge instead of
       floating ~30px inside it — the 44px tap target centers a 16px glyph,
       leaving 14px of dead padding inside each button that otherwise
       compounds with the card's own 16px right padding and reads as
       asymmetric vs. the labels on the left. Tight 4px gap keeps the row
       tools reading as one cluster rather than scattered hit zones. */
    .th-table__actions,
    .th-schedule__actions {
        display: flex;
        flex-wrap: wrap;
        justify-content: flex-end;
        gap: var(--th-space-1);
        margin-inline-end: calc(var(--th-space-3) * -1);
    }
    /* Buttons inside action cells go back to natural width on the card
       layout (the global @media rule at the top of this block stretches
       .th-button to 100%, which clashes with the right-aligned cluster). */
    .th-table__actions .th-button,
    .th-schedule__actions .th-button {
        width: auto;
        margin: 0;
    }
    /* Inline-edit colspan cell renders full-width with its own card chrome
       (the inner .th-schedule-edit), not as a label/value flex row. */
    .th-schedule__form-cell {
        display: block;
        padding: 0;
    }
    /* Move row-level highlight tints up to the <tr> card so the whole
       card reads as highlighted/now instead of each label/value strip.
       Selectors are scoped via .th-schedule so they outrank the generic
       `.th-schedule tr { background: var(--th-white) }` mobile-reflow
       rule above — without that scope, single-class specificity loses
       to class+tag and the card stays white, leaving the cells (which
       still carry the desktop --highlight / --now td paint) sitting on
       a white background. Matters most with the grid single-row layout
       below, where the column-gap between cells exposes the tr. */
    .th-schedule tr.th-schedule__row--highlight,
    .th-schedule tr.th-schedule__row--now {
        background-color: var(--th-highlight-bg);
    }
    .th-schedule tr.th-schedule__row--now {
        background-color: var(--th-lavender);
    }
    .th-schedule tr.th-schedule__row--comment {
        background-color: color-mix(in srgb, var(--th-purple) 12%, transparent);
    }
    /* Edit row in comment mode — must outrank the explicit white background
       set on .th-schedule--rows .th-schedule__row--editing below. */
    .th-schedule tr.th-setlist__editing-comment,
    .th-schedule .th-schedule__row--editing:has([data-th-setlist-comment-toggle]:checked) {
        background-color: color-mix(in srgb, var(--th-purple) 12%, transparent);
    }
    /* Clear the per-cell desktop tint — on mobile the background lives on
       the <tr> so the td paint would double-stack the opacity. */
    .th-schedule .th-schedule__row--comment td,
    .th-schedule .th-setlist__editing-comment td,
    .th-schedule .th-schedule__row--editing:has([data-th-setlist-comment-toggle]:checked) td {
        background: transparent;
    }

    /* Schedule + setlist read rows render as flat horizontal rows on
       mobile, not bordered cards — the user wants the same table feel
       as the desktop view (cells on one line, divider between items)
       instead of the generic card-stack reflow.

       Shared chrome lives on both modifier classes; the grid template
       columns differ per table shape so each modifier declares its own
       below. EventGuestlist (uses .th-schedule but has 6 columns
       including Notes) intentionally keeps the label/value stack —
       packing 6 cells on a phone row reads as cramped noise. */
    .th-schedule--with-form-cell tr:not(.th-schedule__row--editing):not(.th-schedule__day-row),
    .th-schedule--rows tr:not(.th-schedule__row--editing) {
        /* Strip card chrome from the mobile reflow defaults: no outer
           border / radius / margin, padding is vertical-only so the
           divider runs flush to the page edge like a list. Inherited
           white background stays so the highlight/now tints still
           replace it on the bumped-specificity rule above.

           The day-divider row is excluded (it's not a Time|Title|Actions
           row) so it can render as a full-width band — see its own rules
           further up. */
        border: 0;
        border-bottom: 1px solid var(--th-grey-border);
        border-radius: 0;
        margin: 0;
        padding: var(--th-space-2) 0;
    }
    .th-schedule--with-form-cell tr:not(.th-schedule__row--editing):not(.th-schedule__day-row) > td,
    .th-schedule--rows tr:not(.th-schedule__row--editing) > td {
        padding: 0;
    }
    .th-schedule--with-form-cell tr:not(.th-schedule__row--editing) > td[data-label]::before,
    .th-schedule--rows tr:not(.th-schedule__row--editing) > td[data-label]::before {
        display: none;
    }

    /* Schedule: Time | Title | Actions — three cells, Title flexes. The
       day-divider row is excluded so it stays a full-width block band. */
    .th-schedule--with-form-cell tr:not(.th-schedule__row--editing):not(.th-schedule__day-row) {
        display: grid;
        grid-template-columns: auto 1fr auto;
        align-items: baseline;
        column-gap: var(--th-space-3);
    }
    /* Setlist: # | Title | Duration | Actions — four cells, Title
       flexes. Reorder ↑↓ and Remove live inside the editing row's
       actions cluster (not on the read row) so the resting list reads
       as a clean roster of tracks. Center alignment because the action
       column is an icon button, not a text baseline. */
    .th-schedule--rows tr:not(.th-schedule__row--editing) {
        display: grid;
        /* Fixed Duration track (not auto) so every row's duration lines up —
           the first song has no cumulative total, which would otherwise make
           its column narrower and shove its content right. 6rem holds
           "m:ss (mm:ss)" (and most "m:ss (h:mm:ss)") on one line so the
           running total never wraps under the duration; the cell is
           right-aligned (below) so it hugs the edit icon and the reclaimed
           slack goes to the title. minmax(0, 1fr) on Title so the revealed
           player (a wide audio bar) can't blow the track past its share and
           push Duration off-screen — it shrinks/wraps within instead (see the
           audio min-width reset). */
        grid-template-columns: auto minmax(0, 1fr) 6rem auto;
        align-items: center;
        column-gap: var(--th-space-2);
    }
    /* Right-align the duration so its total sits next to the pencil instead of
       leaving a visible gap there — every row's total then lines up on the
       same right edge, and the title column keeps the freed width. The # cell
       (also .th-schedule__time) is content-sized in an auto track, so this
       doesn't shift it. */
    .th-schedule--rows tr:not(.th-schedule__row--editing) > td[data-label="Duration"] {
        text-align: right;
    }
    /* Drop the desktop fixed-height parity floor on mobile. Here the row is a
       grid that also carries `padding: space-2 0` + a 1px bottom divider; with
       box-sizing:border-box the fixed height left a content box of 1.5em − 1px,
       so the text line overflowed by ~1px and painted over the bottom border.
       Sizing to content (like the mobile schedule rows) puts the padding +
       border below the text. Same specificity as the floor rule, later in
       source, so it wins while the media query matches. */
    .th-schedule--setlist .th-schedule__row:not(.th-schedule__row--editing):not(.th-schedule__row--reorder):not(.th-schedule__row--comment) {
        height: auto;
    }
    /* With the fixed-height floor gone the row now sizes to its tallest item.
       The generic touch bump above stretches the lone read-row pencil to the
       44px tap floor, which alone made each one-line track ~60px tall. A
       single, well-spaced edit icon is low-risk at 32px (the inline-edit
       cluster already drops to 36px), so bring it back down and tighten the
       row's vertical padding so a resting track sits at roughly one text line
       — the compact "normal row" height, not a 60px block. */
    .th-schedule--setlist .th-schedule__row:not(.th-schedule__row--editing):not(.th-schedule__row--reorder) .th-schedule__actions .th-icon-btn {
        width: 32px;
        height: 32px;
        min-width: 32px;
        min-height: 32px;
    }
    .th-schedule--setlist .th-schedule__row:not(.th-schedule__row--editing):not(.th-schedule__row--reorder) {
        padding-top: var(--th-space-1);
        padding-bottom: var(--th-space-1);
    }
    /* Keep the running total on the duration's line, to the right of it, and
       never let the cell wrap to a second line — the duration column above is
       sized to hold "m:ss (h:mm:ss)". A second line here made every totalled
       row (comments included) twice as tall and left the # / total / pencil
       centering uneven against the taller cell. */
    .th-schedule--rows tr:not(.th-schedule__row--editing) > td.th-schedule__time {
        white-space: nowrap;
    }
    .th-setlist__cumulative {
        display: inline;
        margin-left: var(--th-space-1);
        margin-top: 0;
        font-size: 0.85em;
    }
    .th-setlist__cumulative::before { content: none; }

    /* During a live show the Duration cell also carries the per-row live
       readouts — the current row's "0:45 / 4:10 · 3:25 left" and every row's
       "Tot/Left: 8:20 / 12:30" — both far wider than the static "(7:55)" the
       5rem column is sized for. On a phone they spill past the column and
       collide with the actions / next row. While a show is live give the
       Duration column more room and drop each readout to its own line under
       the duration so it wraps in place instead of overflowing the row.

       :has(.th-setlist__row--current) is the live signal: setlist-show.js
       stamps --current on a row whenever the show is Running or Paused (and
       clears it on stop), so the column widens on a no-reload start too — no
       server-rendered table class to go stale. :not([hidden]) keeps the
       off-state spans collapsed: the block display below would otherwise beat
       the UA `[hidden] { display: none }` and reveal them outside play mode
       (the same author-display-vs-[hidden] trap noted on the show bar). */
    .th-schedule--rows:has(.th-setlist__row--current) tr:not(.th-schedule__row--editing) {
        grid-template-columns: auto minmax(0, 1fr) auto;
    }
    /* Play mode: drop the duration onto its own full-width line under the
       title. The live readouts (elapsed / total · remaining, and Tot/Left)
       are too wide for a narrow right-hand column on a phone, so # and the
       edit pencil stay on the title's line and the duration block sits
       beneath, spanning from the title column to the right edge. */
    .th-schedule--rows:has(.th-setlist__row--current) tr:not(.th-schedule__row--editing) > td[data-label="#"] {
        grid-column: 1;
        grid-row: 1;
    }
    .th-schedule--rows:has(.th-setlist__row--current) tr:not(.th-schedule__row--editing) > td[data-label="Title"] {
        grid-column: 2;
        grid-row: 1;
    }
    .th-schedule--rows:has(.th-setlist__row--current) tr:not(.th-schedule__row--editing) > td.th-schedule__actions {
        grid-column: 3;
        grid-row: 1;
    }
    .th-schedule--rows:has(.th-setlist__row--current) tr:not(.th-schedule__row--editing) > td[data-label="Duration"] {
        grid-column: 2 / -1;
        grid-row: 2;
        text-align: left;
        padding-top: var(--th-space-1);
    }
    /* Play mode: keep the running Tot/Left inline on the duration's line. The
       duration cell now sits full-width under the title, so "4:10 (8:30) 3:00
       left" reads as one row instead of stacking onto a second line. (Only
       visible during play mode — :not([hidden]) gates it.) */
    .th-setlist__countdown:not([hidden]) {
        display: inline;
        margin-left: var(--th-space-2);
        white-space: normal;
        font-size: 0.75em;
    }

    /* The desktop "greedy Title column" trick (width:100% on the title cell)
       is for the auto-layout table only — here the row is a grid sized by
       its template columns, and a 100%-wide grid item overflows the row.
       Hand sizing back to the grid track (minmax(0,1fr)). */
    .th-schedule--setlist thead th:nth-child(2),
    .th-schedule--setlist tbody td:nth-child(2) { width: auto; }

    /* On a phone the revealed player must fit its (narrow) title cell instead
       of forcing the row wider or overlapping the duration. Let every level of
       the player shrink (drop the audio bar's 12rem desktop floor), and when
       it's revealed give it the full cell line so the bar fills the width
       rather than rendering at its 16rem preferred size and spilling over the
       Duration/Actions columns. The L/R + download wrap beneath the bar. */
    .th-audio__el { min-width: 0; max-width: 100%; }
    .th-setlist__title-line .th-audio,
    .th-setlist__title-line .th-audio__player,
    .th-track-audio-line .th-audio,
    .th-track-audio-line .th-audio__player { min-width: 0; max-width: 100%; }
    /* Revealed on a phone: the audio bar gets its own full-width line so it's
       as wide as possible, and the controls (L/R + ×) + download wrap onto the
       line beneath it. */
    .th-setlist__title-line:has(.th-audio__player:not([hidden])) .th-audio,
    .th-track-audio-line:has(.th-audio__player:not([hidden])) .th-audio {
        flex-basis: 100%;
        flex-wrap: wrap;
    }
    .th-setlist__title-line .th-audio__player,
    .th-track-audio-line .th-audio__player { flex-basis: 100%; }

    /* Editing row uses the same single-line grid — # | Title input |
       Duration | Actions — so the in-place editor reads as a swap of
       the read row, not a stacked card. The generic mobile-reflow at
       the top of this block would otherwise turn each cell into its
       own block (number → input → mm:ss → 5-button cluster), eating
       four vertical rows for what desktop shows on one line.

       The action cluster is 5 icons on touch devices (↑ ↓ trash × ✓);
       see the per-cell overrides below for the compaction needed to
       fit them inline with the title + duration inputs on a phone. */
    .th-schedule--rows .th-schedule__row--editing {
        display: grid;
        grid-template-columns: auto minmax(0, 1fr) auto auto;
        align-items: center;
        column-gap: var(--th-space-1);
        border: 0;
        border-bottom: 1px solid var(--th-grey-border);
        border-radius: 0;
        margin: 0;
        padding: var(--th-space-2) 0;
        background: var(--th-white);
    }
    .th-schedule--rows .th-schedule__row--editing > td {
        padding: 0;
    }
    /* Title input fills the 1fr cell so it can shrink with the row.
       Match both explicit type="text" and bare <input> (Blazor's
       InputText renders without a type attribute, defaulting to text). */
    .th-schedule--rows .th-schedule__row--editing input[type="text"],
    .th-schedule--rows .th-schedule__row--editing input:not([type]) {
        width: 100%;
        min-width: 0;
    }
    /* Action cluster: keep all 5 icons inline (no wrap), kill the
       card-bleed margin, tighten the gap. The generic mobile rule on
       .th-icon-btn pins every icon to the 44px tap floor — that adds
       up to 220px just for the cluster, which is too much next to the
       title + duration inputs on a 390px phone. Drop edit-row icons
       to 36px so the cluster fits inline; the buttons are clustered
       (visual cue) and edit is a focused interaction, so the slightly
       smaller-than-recommended target is an acceptable trade.

       display:flex is restated here because the generic mobile
       `.th-schedule td { display: block }` (specificity 0,1,1)
       outranks the `.th-schedule__actions { display: flex }` rule
       (0,1,0), so without this the icons stack vertically. */
    .th-schedule--rows .th-schedule__row--editing .th-schedule__actions {
        display: flex;
        justify-content: flex-end;
        align-items: center;
        margin-inline-end: 0;
        flex-wrap: nowrap;
        gap: 2px;
    }
    .th-schedule--rows .th-schedule__row--editing .th-icon-btn {
        width: 36px;
        height: 36px;
        min-width: 36px;
        min-height: 36px;
    }
    /* Custom (non-catalogue) edit rows always use 2-row layout on mobile —
       both comment and non-comment mode for a consistent design.
         Row 1: # | Title (spans to right edge)
         Row 2: Duration/toggle (col 2, under title) | Actions (col 3)
       Comment off: mm:ss left, Comment right.
       Comment on:  only the toggle visible (mm:ss hidden). */
    .th-schedule--rows .th-schedule__row--editing.th-setlist__editing-custom {
        grid-template-columns: auto minmax(0, 1fr) auto;
    }
    .th-schedule--rows .th-schedule__row--editing.th-setlist__editing-custom > td:nth-child(2) {
        grid-column: 2 / -1;
        grid-row: 1;
    }
    .th-schedule--rows .th-schedule__row--editing.th-setlist__editing-custom > td:nth-child(3) {
        grid-column: 2;
        grid-row: 2;
        padding-top: var(--th-space-1);
    }
    .th-schedule--rows .th-schedule__row--editing.th-setlist__editing-custom > td:nth-child(4) {
        grid-column: 3;
        grid-row: 2;
        padding-top: var(--th-space-1);
    }
    /* Non-comment only: mm:ss on the left, Comment toggle on the right */
    .th-schedule--rows .th-schedule__row--editing.th-setlist__editing-custom:not(.th-setlist__editing-comment) .th-track-duration {
        order: -1;
    }
    /* Comment toggle hugs the right edge whether mm:ss is visible or not —
       in comment mode mm:ss is hidden and `space-between` would otherwise
       left-align the lone toggle. */
    .th-schedule--rows .th-schedule__row--editing.th-setlist__editing-custom .th-setlist__comment-toggle {
        margin-left: auto;
    }
    /* More breathing room between the three action buttons on row 2 */
    .th-schedule--rows .th-schedule__row--editing.th-setlist__editing-custom .th-schedule__actions {
        gap: var(--th-space-2);
    }
    /* Reorder mode — bigger up/down arrows so the tap targets are
       comfortable and the glyph reads at arm's length. */
    .th-schedule--rows .th-schedule__row--reorder .th-setlist__move-arrow {
        width: 48px;
        height: 48px;
        font-size: 1.5rem;
    }

    /* Time keeps tabular-nums + nowrap from the desktop rule. The generic
       mobile-reflow above resets white-space:normal on every <td>, which
       lets "20:00 – 22:00" break mid-range; restore nowrap here. */
    .th-schedule--with-form-cell .th-schedule__time,
    .th-schedule--rows .th-schedule__time {
        white-space: nowrap;
    }
    /* Actions icon stays flush against the page edge — the card-bleed
       margin from the generic mobile rule is overkill once the card
       chrome is gone (no inner padding to compensate for). */
    .th-schedule--with-form-cell tr:not(.th-schedule__row--editing) .th-schedule__actions,
    .th-schedule--rows tr:not(.th-schedule__row--editing) .th-schedule__actions {
        margin-inline-end: 0;
    }
    /* On touch devices the pencil button retains :focus/:active highlight
       after tap, leaving a visible grey square on the row. Scoped to the
       non-editing row only — the edit-mode cancel/save/trash buttons
       keep their colored focus states. */
    .th-schedule__row:not(.th-schedule__row--editing) .th-schedule__actions .th-icon-btn:focus,
    .th-schedule__row:not(.th-schedule__row--editing) .th-schedule__actions .th-icon-btn:active {
        background: transparent;
        border-color: transparent;
        outline: none;
    }

    /* Suppress label/value rows that would just render a "—" placeholder
       on the stacked card layout (e.g. crew member with no phone or email).
       On desktop the dash holds column alignment; on a card it's just dead
       lines that push the actionable rows further down. Opt-in via the
       --empty modifier on the <td> so cells with a meaningful empty state
       (Position / Crew member) keep rendering.

       Selector is scoped via .th-schedule / .th-table so it outranks the
       generic `.th-schedule td { display: block }` mobile-reflow rule
       above — without the parent scope, the block rule wins on
       specificity and the cell stays visible. */
    .th-schedule .th-schedule__cell--empty,
    .th-table .th-schedule__cell--empty {
        display: none;
    }

    /* Popovers → bottom sheets on phones. Any element marked
       .th-bottom-sheet pins to the bottom edge of the viewport
       instead of floating next to its trigger, with a rounded top
       edge and an extended max-height so long lists fit. New
       dropdowns just add the class — no need to extend a selector
       list here.

       --th-kbd-inset is updated in App.razor from window.visualViewport
       so the sheet rises above the OS keyboard when a text input has
       focus. Falls back to 0 on desktop and on browsers without
       visualViewport (where the keyboard either doesn't exist or
       reflows the layout anyway). */
    .th-bottom-sheet {
        position: fixed;
        top: auto;
        left: 0;
        right: 0;
        bottom: var(--th-kbd-inset, 0);
        width: 100%;
        max-width: none;
        /* Cap to the space visible above the on-screen keyboard.
           --th-kbd-inset is the keyboard height (set from visualViewport
           in App.razor). When a picker's search field is focused, a flat
           80vh sheet would push its own search box off the top of the
           screen — min() keeps the no-keyboard sheet at 80vh and shrinks
           it to the visible gap (less a small top margin) once the
           keyboard opens. */
        max-height: min(80vh, calc(100dvh - var(--th-kbd-inset, 0px) - var(--th-space-3)));
        border-radius: 1rem 1rem 0 0;
        box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.18);
        z-index: 200;
        overflow-y: auto;
    }

    /* Inside a bottom-sheet, each picker's inner list keeps the scroll
       container behaviour (the bottom-sheet's own overflow:auto collapses
       on iOS Safari when the inner list also scrolls), but expand its
       max-height from the desktop 14-16rem to a viewport-relative value
       so the bottom sheet actually fills the space the sheet promises. */
    .th-bottom-sheet > .th-phone-country__list,
    .th-bottom-sheet > .th-airport-picker__list,
    .th-bottom-sheet > .th-role-picker__list,
    .th-bottom-sheet > .th-country-picker__list,
    .th-bottom-sheet > .th-list-picker__list {
        /* Same keyboard-awareness as the sheet itself, minus ~8rem for
           the pinned search field + panel padding above the list, so the
           search box stays on-screen while the keyboard is open. */
        max-height: min(60vh, calc(100dvh - var(--th-kbd-inset, 0px) - 8rem));
    }

    /* Picker panels inside a bottom-sheet stretch to the parent grid /
       flex parent rather than the natural-content width that the desktop
       popover targets — without min-height the panel collapses around a
       search input that hasn't grown yet, so the inner list reads as a
       sliver above the keyboard. */
    .th-phone-country__panel.th-bottom-sheet,
    .th-airport-picker__panel.th-bottom-sheet,
    .th-role-picker__panel.th-bottom-sheet,
    .th-country-picker__panel.th-bottom-sheet,
    .th-list-picker__panel.th-bottom-sheet {
        /* Compound selector (two classes, specificity 0,2,0) so the fixed
           bottom-sheet positioning beats each picker's own
           `.thX__panel { position:absolute }` base rule. Those base rules
           are defined later in the file than the single-class
           `.th-bottom-sheet` block, so at equal specificity they'd win the
           cascade and leave the panel as an inline popover instead of a
           bottom sheet. Re-stating position here settles it. The `--up`
           flip modifier is also single-class, so this wins over it too —
           correct, since a bottom sheet never flips above its trigger. */
        position: fixed;
        top: auto;
        left: 0;
        right: 0;
        bottom: var(--th-kbd-inset, 0);
        min-height: 12rem;
    }

    /* SuggestInput's panel (the Position field's free-text dropdown) is also
       a th-bottom-sheet, but the shared picker JS gives it the --up flip
       class and it isn't in the compound group above — so on mobile it grew
       out of the field (or, where --up won the cascade, upward) and read as
       "fills the entire screen" instead of a bottom-anchored sheet next to
       the crew ListPicker. Pin it the same way. No search box sits above
       this list, so cap the panel itself (the others cap an inner list) and
       skip the 12rem min-height — a filtered 1-2 item list shouldn't balloon
       to a fixed sheet height. */
    .th-suggest__panel.th-bottom-sheet {
        position: fixed;
        top: auto;
        left: 0;
        right: 0;
        bottom: var(--th-kbd-inset, 0);
        max-height: min(60vh, calc(100dvh - var(--th-kbd-inset, 0px) - 8rem));
    }

    /* Event-schedule inline editor on mobile. The global .th-button rule
       above forces width:100% (kicking Assign-crew onto its own row away
       from the Highlight checkbox), and the touch-target audit pins
       .th-icon-btn to 44×44 (so trash/cancel/save tower over the ~34px
       text inputs they sit beside). Shrink both back to the compact input
       row height so the top toolbar fits Highlight + Assign-crew on one
       line and the action icons align with the inputs.

       Keep margin-left:auto on Assign-crew so the desktop "Highlight
       ……… Assign crew" alignment still reads right; the global
       .th-button { margin: 0 0 space-2 0 } rule above would otherwise
       collapse the auto-push. */
    .th-schedule-edit__assign {
        width: auto;
        margin: 0 0 0 auto;
        min-height: 0;
        padding: var(--th-space-1) var(--th-space-2);
        font-size: 0.875rem;
    }
    .th-schedule-edit .th-icon-btn {
        width: 34px;
        height: 34px;
        min-width: 0;
        min-height: 0;
    }
}

/* ---------- Modal (<dialog class="th-modal">) ----------
   Opens via .showModal() (small JS in App.razor). Backdrop is the native
   ::backdrop pseudo; clicks that land on the dialog element itself (not
   its inner shell) close the modal too. Form elements inside still
   belong to the surrounding <form>, so checkboxes submit with the
   parent EditForm. */
.th-modal[open] {
    border: 0;
    padding: 0;
    border-radius: var(--th-radius);
    box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
    background: var(--th-white);
    color: var(--th-text);
    max-width: min(28rem, 92vw);
    width: 100%;
}
.th-modal::backdrop { background: rgba(0, 0, 0, 0.45); }
.th-modal__shell {
    display: flex;
    flex-direction: column;
    max-height: 80vh;
}
.th-modal__header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--th-space-2);
    padding: var(--th-space-3);
    border-bottom: 1px solid var(--th-grey-light);
}
.th-modal__title { margin: 0; font-size: 1em; }
.th-modal__body {
    padding: var(--th-space-3);
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    gap: var(--th-space-2);
}
.th-modal__search {
    width: 100%;
    padding: var(--th-space-2);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-white);
    color: var(--th-text);
    font: inherit;
    box-sizing: border-box;
}
.th-modal__list {
    list-style: none;
    margin: 0;
    padding: 0;
    max-height: 24rem;
    overflow-y: auto;
}
.th-modal__option { border-radius: var(--th-radius); }
.th-modal__option:hover { background: var(--th-grey-light); }
.th-modal__option .th-checkbox {
    width: 100%;
    padding: var(--th-space-1) var(--th-space-2);
    box-sizing: border-box;
}

/* Special "Everyone" row at the top of the audience picker — sits
   above the search input so it stays visible regardless of filter,
   with a divider below to separate it from the per-person list. */
.th-modal__option-all {
    padding: var(--th-space-2);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-grey-light);
}
.th-modal__empty {
    color: var(--th-text-muted);
    padding: var(--th-space-2);
}
.th-modal__footer {
    display: flex;
    justify-content: flex-end;
    gap: var(--th-space-2);
    padding: var(--th-space-3);
    border-top: 1px solid var(--th-grey-light);
}

/* ---------- Accommodation auto-fill modal ---------- */
/* Roomier than the default 28rem people-picker — a room-type select, the
   check-in/out fields, and the crew list all stack in one form. */
.th-accom-autofill[open] { max-width: min(32rem, 94vw); }
.th-accom-autofill__hint { margin: 0; color: var(--th-text-muted); }

/* ---------- Fly-list modal (flight info) ---------- */
/* Roomier than the default 28rem people-picker — two leg sections, each with
   a name + airport picker + actions per row. */
.th-flight-modal[open] { max-width: min(36rem, 94vw); }

/* ---------- Roster editor popup (iframe embed) ---------- */

/* Bare page shell used when ?embed=1 — MainLayout drops the app header/footer
   so only this wraps the embedded editor inside the popup iframe. */
.th-embed-body { padding: var(--th-space-3); }

/* The flight editor in embed mode: rendered inline-open (the `open` attribute,
   not .showModal()), so strip the centered-modal geometry and let it fill the
   frame with no backdrop. The inner header is redundant (the outer popup owns
   the title + close), and its close × would blank the frame, so hide it. */
.th-flight-modal--embed[open] {
    position: static;
    max-width: none;
    width: 100%;
    box-shadow: none;
    margin: 0;
}
.th-flight-modal--embed .th-modal__header { display: none; }
.th-flight-modal--embed .th-modal__shell { max-height: none; }

/* The outer popup on the crew page — a large modal whose body is the iframe.
   Overrides the base .th-modal[open] max-width; comes later in source so it
   wins at equal specificity. */
.th-embed-modal[open] {
    max-width: min(60rem, 96vw);
    width: 100%;
    height: min(88vh, 60rem);
}
.th-embed-modal__shell { display: flex; flex-direction: column; height: 100%; }
.th-embed-modal__header { flex: 0 0 auto; }
.th-embed-modal__frame {
    flex: 1 1 auto;
    width: 100%;
    min-height: 0;
    border: 0;
    background: var(--th-white);
}

/* Bulk-add call to action, shown only while the fly list is empty: a hint over
   the "Add everyone to both legs" button. Replaces the old silent auto-seed —
   nothing is written until the button is pressed. */
.th-flight-seed {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: var(--th-space-2);
    margin-bottom: var(--th-space-3);
    padding-bottom: var(--th-space-3);
    border-bottom: 1px solid var(--th-grey-light);
}
.th-flight-seed__hint { margin: 0; }

/* Leg tabs — Outbound / Return — and the panel each reveals. One panel shows
   at a time; the active tab is toggled client-side (App.razor's flight-tab
   script) and re-applied after each POST morphs the dialog. Mirrors the page
   sub-tabs (underline-on-active) but scoped to the dialog. */
.th-flight-tabs {
    display: flex;
    gap: var(--th-space-2);
    border-bottom: 1px solid var(--th-grey-light);
    margin-bottom: var(--th-space-3);
}
.th-flight-tab {
    appearance: none;
    background: none;
    border: 0;
    border-bottom: 2px solid transparent;
    margin-bottom: -1px;
    padding: var(--th-space-1) var(--th-space-2);
    font: inherit;
    font-size: 0.95em;
    color: var(--th-text);
    cursor: pointer;
    min-height: calc(var(--th-tap-target) - 8px);
}
.th-flight-tab:hover { color: var(--th-purple); }
.th-flight-tab[aria-selected="true"] {
    color: var(--th-purple);
    border-bottom-color: var(--th-purple);
    font-weight: 600;
}
.th-flight-panel { display: flex; flex-direction: column; gap: var(--th-space-2); }
/* The panel sets display:flex, which beats the UA [hidden]{display:none}
   rule — so the inactive panel MUST re-assert display:none explicitly or it
   never hides. */
.th-flight-panel[hidden] { display: none; }
.th-flight-section__empty { color: var(--th-text-muted); margin: 0; }

/* One collapsible person per row: the name is the <summary>; its itinerary +
   booking footer sit inside the <details>. The two forms (edit + detached
   remove) are siblings inside the row; the remove form is invisible (just its
   hidden Id), reached by the trash button via form="…". The trash button is
   absolutely placed on the summary line so it stays visible while collapsed.
   Rows after the first carry a hairline separator. */
.th-flight-row { display: block; position: relative; }
.th-flight-row + .th-flight-row {
    margin-top: var(--th-space-1);
    padding-top: var(--th-space-1);
    border-top: 1px solid var(--th-grey-light);
}
.th-flight-row__form { display: block; }
.th-flight-row__details { min-width: 0; }

/* Summary = the clickable name line. Hide the native disclosure triangle; a
   chevron on the right shows open/closed. */
.th-flight-row__summary {
    list-style: none;
    display: flex;
    align-items: center;
    gap: var(--th-space-2);
    min-height: 2rem;
    padding: 0.2rem 0;
    cursor: pointer;
    border-radius: var(--th-radius);
}
.th-flight-row__summary::-webkit-details-marker { display: none; }
.th-flight-row__summary::marker { content: ""; }
.th-flight-row__summary:hover .th-flight-row__name { color: var(--th-purple); }
.th-flight-row__name { font-weight: 600; min-width: 0; overflow-wrap: anywhere; }
.th-flight-row__chev {
    margin-left: auto;
    color: var(--th-text-muted);
    font-size: 0.85em;
    transition: transform 0.15s ease;
}
.th-flight-row__details[open] .th-flight-row__chev { transform: rotate(180deg); }
/* The collapsed itinerary body. */
.th-flight-row__body {
    display: flex;
    flex-direction: column;
    gap: var(--th-space-2);
    padding: 2px 0 var(--th-space-2);
}
/* Trash rides the last column of the booking footer (airline / airport /
   reference / bags / trash), bottom-aligned with the inputs so it reads as
   part of the row. Only visible while the person is expanded. */
.th-flight-row__remove {
    justify-self: center;
    align-self: end;
}

/* Departure and Arrival sit side by side on desktop (each half-width), so the
   itinerary reads as one compact block; they stack on narrow screens. */
.th-flight-legs {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--th-space-3);
}
/* A Departure / Arrival segment: a small headline, then a bare date + time
   pair (no per-field captions — the native controls are self-evident). */
.th-flight-seg { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.th-flight-seg__title {
    font-size: 0.8em;
    font-weight: 700;
    color: var(--th-text-muted);
    letter-spacing: 0.01em;
}
.th-flight-seg__row { display: flex; gap: var(--th-space-1); min-width: 0; }
.th-flight-seg__row > input { min-width: 0; }
.th-flight-seg__row > input[type="date"] { flex: 1 1 auto; }
.th-flight-seg__row > input[type="time"] { flex: 0 0 auto; }
/* Booking footer — two stacked rows, set off by a divider. Row 1 carries
   airline / reference / bags; row 2 the From → To route + trash, given the
   full width so the airport pickers read comfortably wide. */
.th-flight-meta {
    display: flex;
    flex-direction: column;
    gap: var(--th-space-2);
    margin-top: 2px;
    padding-top: var(--th-space-2);
    border-top: 1px solid var(--th-grey-light);
}
.th-flight-meta__row {
    display: grid;
    align-items: end;
    gap: var(--th-space-2);
}
/* Row 1: airline gets the most room; reference next; bags stays compact. */
.th-flight-meta__row--booking {
    grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) minmax(2.1rem, 0.4fr);
}
/* Row 2: From / To split the width evenly; trash rides the end. */
.th-flight-meta__row--route {
    grid-template-columns: 1fr 1fr auto;
}
/* A captioned field: a tiny caption above a full-width input or picker. */
.th-flight-field { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.th-flight-field__cap { font-size: 0.72em; color: var(--th-text-muted); }
.th-flight-field > input { width: 100%; min-width: 0; }

/* Smaller, tighter controls throughout the fly-list edit form — shorter
   height, smaller text, slimmer padding on inputs and the picker triggers. */
.th-flight-row__form input,
.th-flight-modal .th-list-picker__trigger {
    min-height: 1.8rem;
    font-size: 0.85rem;
}
/* These are bare <input>s (not .th-field), so without this they'd fall back to
   the browser's default control background — which reads grey in dark mode while
   the ListPicker triggers use the app surface. Restate the app field skin so
   every field in the row matches the airline/airport pickers (and the rest of
   the app). */
.th-flight-row__form input {
    padding: 0.1rem 0.4rem;
    background: var(--th-white);
    color: var(--th-text);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
}
.th-flight-modal .th-list-picker__trigger {
    padding: 0.1rem 0.45rem;
    gap: var(--th-space-1);
    min-height: 1.8rem;
}

/* Narrow viewports: collapse the row to two compact lines.
   Line 1 = the itinerary — Departure + Arrival stay side by side (the desktop
   1fr 1fr legs grid is kept, just a tighter gap) so the dates read on one line.
   Line 2 = airport + reference + bags. The booking/route footer rows are
   dissolved (display: contents) so their fields become direct flex items of
   .th-flight-meta and can share a single wrap line; Airline is forced full-width
   above them. flex `order` re-sequences to From · To · Reference · Bags · trash. */
@media (max-width: 30rem) {
    .th-flight-legs { gap: var(--th-space-2); }

    .th-flight-meta { flex-direction: row; flex-wrap: wrap; align-items: end; }
    .th-flight-meta__row { display: contents; }

    /* Airline keeps its own full-width line above the airport/ref/bags line. */
    .th-flight-meta__row--booking > .th-flight-field:first-child {
        order: 0;
        flex: 1 1 100%;
    }
    /* Line 2, in reading order: From, To, Reference, Bags, then the trash. */
    .th-flight-meta__row--route > .th-flight-field--airport:first-of-type { order: 1; }
    .th-flight-meta__row--route > .th-flight-field--airport:last-of-type  { order: 2; }
    .th-flight-meta__row--booking > label.th-flight-field:not(.th-flight-field--bags) { order: 3; }
    .th-flight-field--bags { order: 4; }
    .th-flight-row__remove { order: 5; }

    /* Sizing on line 2: airports + reference share the width; bags compact;
       trash hugs the end. */
    .th-flight-meta__row--route > .th-flight-field--airport,
    .th-flight-meta__row--booking > label.th-flight-field:not(.th-flight-field--bags) {
        flex: 1 1 0;
        min-width: 0;
    }
    .th-flight-field--bags { flex: 0 0 2.6rem; }
    .th-flight-row__remove { flex: 0 0 auto; align-self: end; }
}

/* Add-person row under each leg. */
.th-flight-add {
    display: grid;
    grid-template-columns: 1fr auto;
    align-items: center;
    gap: var(--th-space-2);
    margin-top: var(--th-space-1);
}

/* Airline + airport use the canonical ListPicker. Its panel is position:absolute
   on desktop, so inside the modal's overflow:auto body it would be clipped. On
   DESKTOP the flight-autosave script positions it as a fixed overlay anchored to
   its trigger (thPositionFlightPicker sets position/left/top/width inline), so it
   floats by the field instead of being pinned to the viewport bottom. On MOBILE
   the shared .th-bottom-sheet rules turn it into a bottom sheet as usual. Either
   way it stays compact — capped height, not the full-height track-selector sheet. */
.th-flight-modal .th-list-picker__panel {
    min-height: 0;
    max-height: 22rem;
    z-index: 1000;
}
.th-flight-modal .th-list-picker__list { max-height: 15rem; }

/* Sibling forms used by edit-mode trash + replace icons via HTML5
   form="..." references — empty bodies, just the action URL +
   antiforgery token, no visible chrome. */
.th-resources__hidden-form { display: none; }

/* ---------- Resource-file Replace input ---------- */

/* The Replace icon in resource-row edit mode wraps a hidden file input
   that auto-submits a sibling <form> via the HTML5 form="..." attribute.
   Keeping the input visually hidden (but keyboard-focusable) lets the
   icon's <label> be the only thing the user sees. */
.th-documents__replace-form { display: inline-flex; }
.th-documents__replace-input {
    position: absolute;
    width: 1px; height: 1px;
    padding: 0; margin: -1px;
    overflow: hidden; clip: rect(0, 0, 0, 0);
    white-space: nowrap; border: 0;
}

/* Compact icon button for the inline "copy" affordance next to public
   URLs. Smaller than the row-level --small. */
.th-icon-btn--small {
    width: 1.5rem;
    height: 1.5rem;
    padding: 0.15rem;
}

/* ---------- Guestlist ---------- */

/* Inline State + Max strip on the manager view of the guestlist page.
   Aligns the two fields and the Save button on one row. */
.th-form-inline {
    display: flex;
    flex-wrap: wrap;
    align-items: end;
    gap: var(--th-space-3);
    margin-bottom: var(--th-space-3);
}

.th-form-inline .th-field {
    margin-bottom: 0;
}


.th-guestlist-counts {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--th-space-2);
    margin: 0 0 var(--th-space-3);
    color: var(--th-text-muted);
}

.th-guestlist-counts strong {
    color: var(--th-text);
}

/* Approved count tips into the warning colour once it passes the cap. */
.th-guestlist-counts__used--over strong {
    color: var(--th-warning-text);
}

.th-guestlist-counts__sep {
    color: var(--th-grey-border);
}

/* Top row above the list: info note on the left (grows to fill), the public
   "Share link" copy affordance pinned top-right. They share one row. */
.th-guestlist-top {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: var(--th-space-3);
    margin: 0 0 var(--th-space-4);
}
.th-guestlist-top__info { flex: 1 1 auto; min-width: 0; }
.th-guestlist-top > .th-public-link { flex: 0 0 auto; margin: 0; }
/* The info children carry their own bottom margin for the standalone layout;
   inside the row the wrapper owns the spacing, so zero theirs out. */
.th-guestlist-top__info > .th-guestlist-info,
.th-guestlist-top__info > .th-guestlist-info-edit,
.th-guestlist-top__info > .th-guestlist-info-add { margin-bottom: 0; }

/* Stack on narrow screens so the share link doesn't crush the info note. */
@media (max-width: 40rem) {
    .th-guestlist-top { flex-direction: column; align-items: stretch; }
    .th-guestlist-top > .th-public-link { justify-content: flex-start; }
}

.th-guestlist-section {
    margin: var(--th-space-4) 0 var(--th-space-2);
    font-size: 1rem;
    color: var(--th-text-muted);
}

/* Section heading row — title on the left, its per-section PDF download icon
   pinned right, sitting directly above the table it exports. The icon keeps
   the neutral muted glyph of a row tool (Edit pencil etc.) rather than the
   roster-bar's purple tint — sitting inline next to the section's other
   neutral actions, the purple read as an odd one-off. */
.th-guestlist-section-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--th-space-2);
}
.th-guestlist-section-head .th-guestlist-section { margin-right: auto; }
.th-guestlist-section-head__download { flex: 0 0 auto; }

/* Global info note shown at the top of the guestlist (pickup, door
   instructions). One per event; honours the line breaks typed into it. */
.th-guestlist-info {
    position: relative;
    background: var(--th-grey-light);
    padding: var(--th-space-3);
    border-radius: var(--th-radius);
    margin: 0 0 var(--th-space-4);
    white-space: pre-line;
}

/* Approvers get an inline edit pencil pinned top-right; pad the text so a
   long line never runs under it. */
.th-guestlist-info--editable { padding-right: 2.75rem; }
.th-guestlist-info__edit {
    position: absolute;
    top: var(--th-space-2);
    right: var(--th-space-2);
    color: var(--th-purple);
}

/* Inline editor (approver, ?editinfo) — full-width textarea + right-aligned
   actions, matching the add/edit guest forms. */
.th-guestlist-info-edit { margin: 0 0 var(--th-space-4); }
.th-guestlist-info-edit textarea { width: 100%; }
.th-guestlist-info-edit__actions {
    display: flex;
    justify-content: flex-end;
    gap: var(--th-space-2);
    margin-top: var(--th-space-2);
}

/* Inline guestlist-settings editor (approver, ?editsettings) — State + Max
   side by side, right-aligned actions, matching the info editor. */
.th-guestlist-settings-edit { margin: 0 0 var(--th-space-4); }
.th-guestlist-settings-edit .th-field-row { margin-bottom: var(--th-space-2); }

/* "+ Add info" affordance when no note is set yet — muted, low-key so it
   doesn't compete with the primary add-guest CTA. */
.th-guestlist-info-add {
    display: inline-block;
    margin: 0 0 var(--th-space-4);
    color: var(--th-text-muted);
    font-size: 0.95em;
    text-decoration: none;
}
.th-guestlist-info-add:hover { color: var(--th-text); text-decoration: underline; }

/* ---------- Feature-grant chips ---------- */

/* Compact toggle chip used on Admin/SpaceMembers for per-member feature
   grants. Off = neutral; on = success-tinted. Each chip is its own form
   so a single click toggles without JS. */
.th-feature-grants {
    display: flex;
    flex-wrap: wrap;
    gap: var(--th-space-2);
}

.th-feature-grants__chip-form {
    display: inline-block;
    margin: 0;
}

.th-chip {
    display: inline-block;
    padding: 0.15rem var(--th-space-2);
    border-radius: 999px;
    font-size: 0.78rem;
    background: var(--th-grey-light);
    color: var(--th-text-muted);
    border: 1px solid var(--th-grey-border);
    user-select: none;
}

/* Pointer + hover only when the chip is actually interactive — used on
   the feature-grant toggle in SpaceMembers.razor. Status chips render
   as <span> and should look inert (no hover, no text selection). */
button.th-chip,
a.th-chip {
    cursor: pointer;
}

button.th-chip:hover,
a.th-chip:hover {
    border-color: var(--th-text-muted);
}

/* The crew-info Publish button uses the warning chip as its own click
   target — "click Draft to publish". Override the neutral grey hover
   above so the chip stays yellow and deepens on hover instead of
   washing into grey, which would read as "disabled". */
button.th-chip.th-chip--warning:hover,
button.th-chip.th-chip--warning:focus {
    background: var(--th-icon-btn-hover-warning);
    color: var(--th-icon-btn-hover-glyph);
    border-color: var(--th-icon-btn-hover-warning);
}

.th-chip--on {
    background: var(--th-alert-success-bg);
    color: var(--th-success);
    border-color: var(--th-success);
}

/* Brief solid-green flash applied by App.razor's data-th-copy handler
   so a click-to-copy chip (Public link on tour files) gives visible
   "copied!" feedback for ~900ms before reverting. */
.th-chip.th-chip--on.th-just-copied {
    background: var(--th-success);
    color: var(--th-white);
    border-color: var(--th-success);
}

.th-chip--warning {
    background: var(--th-alert-warning-bg);
    color: var(--th-warning-text);
    border-color: var(--th-warning);
}

.th-chip--danger {
    background: var(--th-alert-error-bg);
    color: var(--th-error);
    border-color: var(--th-error);
}

/* Airport picker — same shape as the role picker: full-width <details>
   trigger that reads as a form field, full-width panel that floats over
   content below, IATA + city/country rows inside the panel. Trigger shows
   "IATA — city" when picked, placeholder text otherwise. */
.th-airport-picker { position: relative; }
.th-airport-picker__trigger {
    list-style: none;
    display: flex;
    align-items: center;
    gap: var(--th-space-2);
    min-height: var(--th-tap-target);
    padding: var(--th-space-1) var(--th-space-2);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-white);
    color: var(--th-text);
    cursor: pointer;
    font: inherit;
}
.th-airport-picker__trigger::-webkit-details-marker { display: none; }
.th-airport-picker__trigger::marker { content: ""; }
.th-airport-picker__trigger:hover,
.th-airport-picker[open] > .th-airport-picker__trigger { border-color: var(--th-purple); }

.th-airport-picker__code {
    font-variant-numeric: tabular-nums;
    font-weight: 600;
}
.th-airport-picker__placeholder { color: var(--th-text-muted); }
.th-airport-picker__caret {
    color: var(--th-text-muted);
    font-size: 0.8em;
    margin-left: auto;
}

.th-airport-picker__panel {
    position: absolute;
    top: calc(100% + 4px);
    left: 0;
    right: 0;
    z-index: 40;
    background: var(--th-white);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
    padding: var(--th-space-2);
    display: flex;
    flex-direction: column;
    gap: var(--th-space-2);
}
.th-airport-picker__panel--up {
    top: auto;
    bottom: calc(100% + 4px);
}
/* See comment on .th-phone-country__panel — same flash avoidance and
   scrollbar-pop fix while the JS toggle handler measures and flips. */
@media (min-width: 641px) {
    .th-airport-picker[open]:not(.th-picker--positioned) > .th-airport-picker__panel {
        visibility: hidden;
        position: fixed;
        top: 0;
        left: 0;
    }
}
.th-airport-picker__search {
    width: 100%;
    /* Bump to a proper tap-target height — the original space-1 vertical
       padding read as too thin next to the picker's full-tap-target
       trigger, especially on mobile bottom-sheets where the search lives
       above a wide list of options. */
    min-height: var(--th-tap-target);
    padding: var(--th-space-2);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-white);
    color: var(--th-text);
    font: inherit;
    box-sizing: border-box;
}
.th-airport-picker__list {
    list-style: none;
    margin: 0;
    padding: 0;
    /* Taller than the role/country lists because there are ~40 airports
       on offer and scrolling through them seven at a time felt cramped. */
    max-height: 22rem;
    overflow-y: auto;
}
.th-airport-picker__option {
    display: flex;
    align-items: baseline;
    gap: var(--th-space-2);
    padding: var(--th-space-1) var(--th-space-2);
    border-radius: var(--th-radius);
    cursor: pointer;
    user-select: none;
}
.th-airport-picker__option:hover { background: var(--th-grey-light); }
.th-airport-picker__option--active {
    background: var(--th-lavender);
    font-weight: 600;
}
.th-airport-picker__option .th-airport-picker__code {
    min-width: 2.75em;
}
.th-airport-picker__option .th-airport-picker__city {
    flex: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.th-airport-picker__option .th-airport-picker__country {
    color: var(--th-text-muted);
    font-size: 0.85em;
}

/* ---------- Country picker ----------
   Same shape as the airport picker. Used on the New/Edit event forms
   where the ~185-entry <select> was too dense to scan; the picker adds a
   search box and a Nordic header at the top of the list. Selectors and
   z-index settings mirror .th-airport-picker so the cross-cutting
   behaviour (--up flip, .th-picker--positioned flash guard, bottom-sheet
   on mobile) wires up via the same App.razor JS. */
.th-country-picker { position: relative; }
.th-country-picker__trigger {
    list-style: none;
    display: flex;
    align-items: center;
    gap: var(--th-space-2);
    min-height: var(--th-tap-target);
    padding: var(--th-space-1) var(--th-space-2);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-white);
    color: var(--th-text);
    cursor: pointer;
    font: inherit;
}
.th-country-picker__trigger::-webkit-details-marker { display: none; }
.th-country-picker__trigger::marker { content: ""; }
.th-country-picker__trigger:hover,
.th-country-picker[open] > .th-country-picker__trigger { border-color: var(--th-purple); }

.th-country-picker__flag {
    font-size: 1.1em;
    line-height: 1;
}
.th-country-picker__name {
    flex: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.th-country-picker__placeholder { color: var(--th-text-muted); }
.th-country-picker__caret {
    color: var(--th-text-muted);
    font-size: 0.8em;
    margin-left: auto;
}

.th-country-picker__panel {
    position: absolute;
    top: calc(100% + 4px);
    left: 0;
    right: 0;
    z-index: 40;
    background: var(--th-white);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
    padding: var(--th-space-2);
    display: flex;
    flex-direction: column;
    gap: var(--th-space-2);
}
.th-country-picker__panel--up {
    top: auto;
    bottom: calc(100% + 4px);
}
/* See comment on .th-phone-country__panel — same flash avoidance and
   scrollbar-pop fix while the JS toggle handler measures and flips. */
@media (min-width: 641px) {
    .th-country-picker[open]:not(.th-picker--positioned) > .th-country-picker__panel {
        visibility: hidden;
        position: fixed;
        top: 0;
        left: 0;
    }
}
.th-country-picker__search {
    width: 100%;
    min-height: var(--th-tap-target);
    padding: var(--th-space-2);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-white);
    color: var(--th-text);
    font: inherit;
    box-sizing: border-box;
}
.th-country-picker__list {
    list-style: none;
    margin: 0;
    padding: 0;
    /* Tall enough that the full Nordic group + a healthy slice of the
       alphabetical list are visible without a second scroll gesture. */
    max-height: 22rem;
    overflow-y: auto;
}
.th-country-picker__header {
    padding: var(--th-space-1) var(--th-space-2);
    color: var(--th-text-muted);
    font-size: 0.8em;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    pointer-events: none;
    cursor: default;
}
.th-country-picker__option {
    display: flex;
    align-items: baseline;
    gap: var(--th-space-2);
    padding: var(--th-space-1) var(--th-space-2);
    border-radius: var(--th-radius);
    cursor: pointer;
    user-select: none;
}
.th-country-picker__option:hover { background: var(--th-grey-light); }
.th-country-picker__option--active {
    background: var(--th-lavender);
    font-weight: 600;
}
.th-country-picker__option .th-country-picker__flag {
    min-width: 1.5em;
}
.th-country-picker__option .th-country-picker__name {
    flex: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Generic single-select picker — same shape as the country / airport
   pickers, minus the flag + group-header bits. The one canonical
   "pick one from a list" dropdown (ListPicker.razor); the shared picker
   JS in App.razor drives it via the th-list-picker class. */
.th-list-picker { position: relative; }
.th-list-picker__trigger {
    list-style: none;
    display: flex;
    align-items: center;
    gap: var(--th-space-2);
    min-height: var(--th-tap-target);
    padding: var(--th-space-1) var(--th-space-2);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-white);
    color: var(--th-text);
    cursor: pointer;
    font: inherit;
}
.th-list-picker__trigger::-webkit-details-marker { display: none; }
.th-list-picker__trigger::marker { content: ""; }
.th-list-picker__trigger:hover,
.th-list-picker[open] > .th-list-picker__trigger { border-color: var(--th-purple); }
.th-list-picker__name {
    flex: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.th-list-picker__placeholder { color: var(--th-text-muted); }
.th-list-picker__caret {
    color: var(--th-text-muted);
    font-size: 0.8em;
    margin-left: auto;
}
.th-list-picker__panel {
    position: absolute;
    top: calc(100% + 4px);
    left: 0;
    right: 0;
    z-index: 40;
    background: var(--th-white);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
    padding: var(--th-space-2);
    display: flex;
    flex-direction: column;
    gap: var(--th-space-2);
}
.th-list-picker__panel--up {
    top: auto;
    bottom: calc(100% + 4px);
}
/* Cap the popover width on desktop so a picker sitting in a full-width form
   line doesn't render a dropdown spanning the whole row for short labels
   (e.g. role names). Stays anchored at left:0; long labels ellipsise. The
   --wide variant below overrides this for pickers that need the room. The
   mobile bottom-sheet is full-width and unaffected. */
@media (min-width: 641px) {
    .th-list-picker__panel { max-width: min(90vw, 22rem); }
}
/* Wide variant — for pickers whose option labels (e.g. full crew names)
   can run long. The panel breaks out past the trigger's right edge to a
   roomier min-width instead of ellipsing names, while staying clamped to
   the viewport. Desktop only; the mobile bottom-sheet is already full-
   width. */
@media (min-width: 641px) {
    .th-list-picker--wide .th-list-picker__panel {
        right: auto;
        min-width: 24rem;
        max-width: min(90vw, 32rem);
    }
}
/* See comment on .th-phone-country__panel — same flash avoidance and
   scrollbar-pop fix while the JS toggle handler measures and flips. */
@media (min-width: 641px) {
    .th-list-picker[open]:not(.th-picker--positioned) > .th-list-picker__panel {
        visibility: hidden;
        position: fixed;
        top: 0;
        left: 0;
    }
}
.th-list-picker__search {
    width: 100%;
    min-height: var(--th-tap-target);
    padding: var(--th-space-2);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
    background: var(--th-white);
    color: var(--th-text);
    font: inherit;
    box-sizing: border-box;
}
.th-list-picker__list {
    list-style: none;
    margin: 0;
    padding: 0;
    max-height: 16rem;
    overflow-y: auto;
}
.th-list-picker__option {
    display: flex;
    align-items: baseline;
    gap: var(--th-space-2);
    padding: var(--th-space-1) var(--th-space-2);
    border-radius: var(--th-radius);
    cursor: pointer;
    user-select: none;
}
.th-list-picker__option:hover { background: var(--th-grey-light); }
.th-list-picker__option--active {
    background: var(--th-lavender);
    font-weight: 600;
}
.th-list-picker__option .th-list-picker__name {
    flex: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Multi-select mode (ListPicker Multiple) — the closed trigger shows a chip
   per picked option, and each panel row is a full-row checkbox label. Mirrors
   the retired th-role-picker styling so the two read identically. */
.th-list-picker__chips {
    flex: 1;
    display: flex;
    flex-wrap: wrap;
    gap: var(--th-space-1);
    align-items: center;
    min-width: 0;
}
.th-list-picker[data-th-multiple] .th-list-picker__option {
    display: block;
    padding: 0;
}
/* Padding lives on the label, not the <li>, so the full row clicks through
   to the checkbox (matches the old role-picker behaviour). */
.th-list-picker[data-th-multiple] .th-list-picker__option .th-checkbox {
    width: 100%;
    padding: var(--th-space-1) var(--th-space-2);
    box-sizing: border-box;
}

/* Multi-step wizard progress indicator — three dots + "Step X of N" text
   above the form. Active and prior steps render in --th-purple so the
   user can see progression at a glance. */
.th-stepper {
    display: flex;
    align-items: center;
    gap: var(--th-space-2);
    margin-bottom: var(--th-space-3);
    color: var(--th-text-muted);
    font-size: 0.9em;
}
.th-stepper__dots {
    display: inline-flex;
    gap: 6px;
}
.th-stepper__dot {
    display: inline-block;
    width: 10px;
    height: 10px;
    border-radius: 50%;
    background: var(--th-grey-border);
}
.th-stepper__dot--active,
.th-stepper__dot--done {
    background: var(--th-purple);
}
.th-stepper__label {
    font-weight: 600;
    color: var(--th-text);
}

/* TourOnboarding choice groups — radio + checkbox lists for SingleChoice
   and MultiChoice answers. Each option sits in its own row so the tap
   target is the full label width. Reuses .th-checkbox spacing. */
.th-choice-list {
    display: flex;
    flex-direction: column;
    gap: var(--th-space-1);
}

/* Small "eyebrow" line above a form-page <h1>. Lets the heading itself
   stay short (so it doesn't wrap on mobile) while still carrying
   context — typically the parent record the form belongs to, like the
   tour on the New event page. */
.th-form-page__eyebrow {
    color: var(--th-text-muted);
    font-size: 0.9em;
    margin: 0 0 var(--th-space-1) 0;
}
.th-form-page__eyebrow strong {
    color: var(--th-text);
    font-weight: 600;
}

/* Inline (optional) hint after a field label — same muted tone as the
   form-page lede so it reads as a soft annotation, not a header. */
.th-form-page__hint {
    color: var(--th-text-muted);
    font-weight: 400;
    font-size: 0.9em;
}

/* ---------- Native <select> appearance reset (app-wide) ----------
   iOS Safari renders a native <select> using the OS light/dark appearance.
   In the System theme (no data-theme attribute — dark driven only by
   prefers-color-scheme) it fails to inherit the dark color-scheme into the
   control and paints a WHITE background even though the page is dark. Picking
   Dark explicitly sets data-theme="dark" and the control renders correctly,
   which is why the bug only showed in System mode.

   Dropping the native appearance hands rendering to the authored
   background/color — both resolve through the theme tokens — so every styled
   select matches the page in every theme. A custom chevron (--th-select-caret)
   replaces the native arrow we remove; the shared right-padding leaves room
   for it.

   Listed per styled context rather than as a bare `select` on purpose: each
   selector's specificity then matches the rule that sets that select's
   background/padding, and this block sits LAST so it wins on source order —
   letting the authored `background:`/`padding:` shorthands keep their colour
   and left padding while we override only the image and right padding. Keep
   any NEW styled-select context in sync by adding its selector here. */
.th-field select,
.th-form-page select,
.th-schedule-edit select,
.th-schedule__filter-select,
.th-schedule__row--editing select,
.th-contacts-row--editing select,
.th-contacts-row--adding select,
.th-role-list__edit select {
    -webkit-appearance: none;
    appearance: none;
    background-image: var(--th-select-caret);
    background-repeat: no-repeat;
    background-position: right var(--th-space-2) center;
    background-size: 0.6em;
    padding-right: calc(var(--th-space-2) * 2 + 0.6em);
}

/* ---------- Pricing page ----------
   Public /pricing marketing page. All three plan cards render side by side in a
   responsive grid (no JS, no Blazor circuit — works on static SSR). The grid
   collapses to a single column on narrow screens. The full comparison table is
   desktop-only; under --th-bp-md it's hidden and the per-plan cards (which carry
   the same detail) stand in. All colours come from design tokens so dark mode is
   automatic. */
.th-pricing {
    max-width: 64rem;
    margin: 0 auto;
}

/* Three equal cards on desktop; auto-fit lets them wrap to 2-up / 1-up as the
   viewport narrows before the mobile single-column rule kicks in. align-items:
   stretch keeps every card the same height so the CTAs line up. */
.th-pricing__panels {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: var(--th-space-4);
    align-items: stretch;
    margin-bottom: var(--th-space-6);
}

/* "Most popular" pill, sitting at the top of the featured card. */
.th-pricing__popular {
    align-self: flex-start;
    font-size: 0.7rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--th-purple-vivid);
    background: var(--th-lavender);
    border-radius: 999px;
    padding: 0.1rem var(--th-space-2);
    margin-bottom: var(--th-space-2);
}

/* Cards are always visible now (no tab switching). Flex column so the feature
   list can grow and the CTA pins to the bottom edge, keeping buttons aligned
   across cards of differing content length. */
.th-pricing__panel {
    display: flex;
    flex-direction: column;
    margin: 0;
    padding: var(--th-space-5);
    background: var(--th-white);
    border: 1px solid var(--th-grey-border);
    border-radius: var(--th-radius);
}

/* Featured tint + slight lift on the most-popular plan. */
.th-pricing__panel--artist {
    border-color: var(--th-purple-vivid);
    background: var(--th-lavender);
}

.th-pricing__plan-name {
    margin: 0 0 var(--th-space-2);
}

.th-pricing__price {
    margin: 0 0 var(--th-space-3);
    font-size: 1.1rem;
}
.th-pricing__price strong { font-size: 1.8rem; }
.th-pricing__price span { color: var(--th-text-muted); }

.th-pricing__tagline {
    margin: 0 0 var(--th-space-4);
    color: var(--th-text-muted);
}

.th-pricing__features {
    list-style: none;
    margin: 0 0 var(--th-space-5);
    padding: 0;
    /* Grow to fill the card so the trailing CTA pins to the bottom edge and
       buttons line up across cards with different feature counts. */
    flex: 1 0 auto;
}
.th-pricing__features li {
    position: relative;
    padding-left: var(--th-space-4);
    margin-bottom: var(--th-space-2);
}
.th-pricing__features li::before {
    content: "✓";
    position: absolute;
    left: 0;
    color: var(--th-success);
    font-weight: 700;
}

/* ---------- Comparison table ---------- */
.th-pricing__compare-title {
    text-align: center;
    margin: 0 0 var(--th-space-4);
}

.th-pricing__table {
    width: 100%;
    border-collapse: collapse;
}
.th-pricing__table th,
.th-pricing__table td {
    padding: var(--th-space-2) var(--th-space-3);
    border-bottom: 1px solid var(--th-grey-border);
    text-align: center;
    vertical-align: top;
}
.th-pricing__table thead th {
    border-bottom: 2px solid var(--th-grey-border);
}
/* Highlight the most-popular column. */
.th-pricing__table th:nth-child(3),
.th-pricing__table td:nth-child(3) {
    background: var(--th-lavender);
}
/* First column = feature labels, left-aligned. */
.th-pricing__table tbody th,
.th-pricing__table tfoot th {
    text-align: left;
    font-weight: 400;
}
.th-pricing__table tfoot th,
.th-pricing__table tfoot td {
    border-top: 2px solid var(--th-grey-border);
    font-weight: 600;
}

.th-pricing__yes { color: var(--th-success); font-weight: 700; }
.th-pricing__no  { color: var(--th-text-muted); }
.th-pricing__price-sub {
    font-size: 0.8rem;
    font-weight: 400;
    color: var(--th-text-muted);
}

.th-pricing__note {
    margin: var(--th-space-3) 0 0;
    font-size: 0.85rem;
    color: var(--th-text-muted);
    text-align: center;
}

.th-pricing__footer-cta {
    margin-top: var(--th-space-6);
    text-align: center;
}

/* Tablet: three cards get cramped, so go two-up (the third wraps below). */
@media (max-width: 900px) {
    .th-pricing__panels { grid-template-columns: repeat(2, 1fr); }
}

/* Mobile: stack the plan cards in one column, and drop the side-by-side
   comparison table (it can't shrink to a readable width — the stacked cards
   above already carry the full feature list for each plan). */
@media (max-width: 640px) {
    .th-pricing__panels { grid-template-columns: 1fr; }
    .th-pricing__compare { display: none; }
}


