/*
 * Portal component kit — styles for templates/components/*.twig.
 * Consumes the global --portal-c-* tokens (portal-components.css).
 * Motion (pulse, entrances, flashes) lives in portal-motion.css.
 */

/* ── Page header ───────────────────────────────────────────────────
 * COMPACT BY DEFAULT (v3.1 decision e): one slim row — small title, actions
 * hard right, the subtitle as inline meta on the title's baseline. The
 * chrome's breadcrumb says WHERE, this row says WHAT YOU CAN DO. Destination
 * pages opt back into the big treatment with `hero: true` on the component
 * (.portal-page-header--hero below). Both variants share one DOM shape —
 * TV/kiosk CSS reaches in by class and page JS retexts by id. */
.portal-page-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: 0.75rem;
    margin-bottom: 0.875rem;
    /* Pages with flex-column <main> (QA, RI, sysmgmt) must not squash the header */
    flex: 0 0 auto;
    /* …and must not be WIDENED by it either: as a flex item, min-width:auto
       lets an unwrappable child (the v3 scrolling tabs strip) propagate its
       min-content through the header and push the whole page sideways. Zero
       breaks the propagation at the top of the chain; the strip's own
       overflow-x then does its job. */
    min-width: 0;
}

.portal-page-header-main {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    /* Also the v3 note: an unwrappable child (the tabs strip scrolls instead
       of wrapping now) must hand its overflow to its own scroller, not push
       the page wide — min-width: 0 is what breaks min-content propagation. */
    min-width: 0;
}

/* The title stack (crumbs / h1 / subtitle) flattens to one baseline row in
   compact: title first, the subtitle riding beside it as meta. Wraps rather
   than clips when a data subtitle runs long (support tickets do). */
.portal-page-header-titles {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    column-gap: 0.6rem;
    min-width: 0;
}

/* Page titles: strong quiet sans — the app identity lives in the mono
 * labels + geometry, not a display face. Joyride (--portal-font-display)
 * is reserved for big-screen/hero moments (TV pages already use it). */
.portal-page-title {
    font-size: var(--portal-text-lg, 1.05rem);
    font-weight: var(--portal-weight-strong);
    letter-spacing: -0.01em;
    line-height: var(--portal-leading-tight, 1.2);
    margin-bottom: 0;
    color: var(--portal-c-text);
}

.portal-page-subtitle {
    font-size: var(--portal-text-xs);
    color: var(--portal-c-text-muted);
    min-width: 0;
}

/* Compact shrinks the icon badge to the row's height; the full 2.5rem chip
   is the hero's (and qa-mgmt-icon keeps its own rule untouched). */
.portal-page-header:not(.portal-page-header--hero) .portal-icon-badge {
    width: 1.75rem;
    height: 1.75rem;
    font-size: var(--portal-text-sm);
}

.portal-page-header-actions {
    /* min-width: 0, so an unwrappable child (the v3 tabs strip scrolls
       instead of wrapping now) hands its overflow to its own scroller
       instead of pushing the page wide. Flex's min-width: auto default is
       exactly the wrong answer for a header that can host a tab strip. */
    min-width: 0;
    display: flex;
    align-items: center;
    gap: 0.5rem;
    flex-wrap: wrap;
}

/* ── The hero variant: today's big treatment, by request only ────── */
.portal-page-header--hero {
    margin-bottom: 1.25rem;
}

.portal-page-header--hero .portal-page-header-titles {
    display: block;
}

.portal-page-header--hero .portal-page-title {
    font-size: var(--portal-text-xl, 1.35rem);
}

.portal-page-header--hero .portal-page-subtitle {
    font-size: var(--portal-text-sm);
}

/* Step the hero title down on phones (compact is already small) */
@media (max-width: 575.98px) {
    .portal-page-header--hero .portal-page-title {
        font-size: var(--portal-text-lg);
    }
}

/* ── Breadcrumb (detail pages) ───────────────────────────────────── */
.portal-breadcrumb {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    margin-bottom: 0.15rem;
    font-family: var(--portal-font-mono);
    font-size: var(--portal-text-2xs);
    font-weight: var(--portal-weight-medium);
    letter-spacing: var(--portal-label-tracking);
    text-transform: uppercase;
}

.portal-breadcrumb-link {
    color: var(--portal-c-text-muted);
    text-decoration: none;
    transition: color var(--portal-dur-1) var(--portal-ease-out);
}

.portal-breadcrumb-link:hover,
.portal-breadcrumb-link:focus-visible {
    color: var(--portal-c-accent);
}

.portal-breadcrumb-sep {
    color: var(--portal-c-text-muted);
    opacity: 0.5;
}

.portal-breadcrumb-current {
    color: var(--portal-c-text-muted);
    opacity: 0.75;
}

/* ── Stat card ───────────────────────────────────────────────────── */
.portal-stat-card {
    display: flex;
    align-items: center;
    gap: 0.85rem;
    padding: 0.85rem 1rem;
    background-color: var(--portal-c-surface);
    border: 1px solid var(--portal-c-border);
    /* Radius rides the shared token scale — lg = card tier. */
    border-radius: var(--bs-border-radius-lg);
    box-shadow: var(--portal-c-edge-card), var(--portal-c-shadow);
    min-width: 0;
    height: 100%;
}

.portal-stat-icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    width: 2.5rem;
    height: 2.5rem;
    border-radius: var(--bs-border-radius);
    font-size: var(--portal-text-base);
    background-color: var(--portal-c-accent-muted);
    color: var(--portal-c-accent);
    border: 1px solid var(--portal-c-accent-border);
}

.portal-stat-body {
    min-width: 0;
}

.portal-stat-label {
    font-family: var(--portal-font-mono);
    font-size: var(--portal-text-2xs);
    font-weight: var(--portal-weight-medium);
    letter-spacing: var(--portal-label-tracking);
    text-transform: uppercase;
    color: var(--portal-c-text-muted);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.portal-stat-value {
    font-family: var(--portal-font-mono);
    /* -xl, not -lg: the ladder re-tuned -lg down to 18px for titles; the
       stat figure keeps its 21.6px, which is -xl's new home. */
    font-size: var(--portal-text-xl);
    font-weight: var(--portal-weight-label);
    line-height: 1.2;
    color: var(--portal-c-text);
    font-variant-numeric: tabular-nums;
}

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

/* Accent variants tint the icon chip */
.portal-stat-accent-success .portal-stat-icon {
    background-color: var(--portal-c-badge-success-bg);
    color: var(--portal-c-badge-success-color);
    border-color: transparent;
}

.portal-stat-accent-warning .portal-stat-icon {
    background-color: var(--portal-c-badge-warning-bg);
    color: var(--portal-c-badge-warning-color);
    border-color: transparent;
}

.portal-stat-accent-danger .portal-stat-icon {
    background-color: var(--portal-c-badge-danger-bg);
    color: var(--portal-c-badge-danger-color);
    border-color: transparent;
}

/* ── Empty state ─────────────────────────────────────────────────── */
.portal-empty-state {
    max-width: 26rem;
    margin: 0 auto;
    padding: var(--portal-space-7, 3rem) var(--portal-space-4, 1rem);
}

/* Without this the title renders at whatever heading level the document
   outline needed — an <h3> is ~1.75rem, i.e. LARGER than the page title
   (1.35rem), so an empty panel shouted louder than the page it sat in. The
   class owns the size; the markup keeps whatever level it needs. */
.portal-empty-state-title {
    font-size: var(--portal-text-lg);
    font-weight: var(--portal-weight-label);
    color: var(--portal-c-text);
}

.portal-empty-state-img {
    height: 250px;
    width: 250px;
    object-fit: contain;
}

.portal-empty-state-icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 4rem;
    height: 4rem;
    margin-bottom: 1.25rem;
    border-radius: 50%;
    font-size: var(--portal-text-2xl);
    background-color: var(--portal-c-badge-muted-bg);
    color: var(--portal-c-text-muted);
    border: 1px solid var(--portal-c-border);
}

/* ── Live dot ──────────────────────────────────────────────────────
   Only for indicators whose JS can turn them OFF — the -idle/-danger states
   below are the point of the component. There is no page-title rule any more:
   page-header.twig and stat-card.twig both dropped their always-on `live`
   param on 2026-08-13, because a dot that cannot go out reports that a page
   refreshes itself, not that its data is current. Portal-wide live health is
   the footer's #portal-live-indicator. */
.portal-live-dot {
    display: inline-block;
    width: 0.5rem;
    height: 0.5rem;
    margin-right: 0.15rem;
    border-radius: 50%;
    vertical-align: middle;
    background: var(--portal-c-accent);
    box-shadow: 0 0 0 3px var(--portal-c-accent-muted);
}

.portal-live-dot-success {
    --portal-pulse-color: var(--portal-c-badge-success-bg);
    background: var(--portal-c-badge-success-color);
    box-shadow: 0 0 0 3px var(--portal-c-badge-success-bg);
}

.portal-live-dot-warning {
    --portal-pulse-color: var(--portal-c-badge-warning-bg);
    background: var(--portal-c-badge-warning-color);
    box-shadow: 0 0 0 3px var(--portal-c-badge-warning-bg);
}

.portal-live-dot-danger {
    --portal-pulse-color: var(--portal-c-badge-danger-bg);
    background: var(--portal-c-badge-danger-color);
    box-shadow: 0 0 0 3px var(--portal-c-badge-danger-bg);
}

.portal-live-dot-idle {
    background: var(--portal-c-text-muted);
    box-shadow: none;
}

/* ── Filter bar (components/table-card.twig toolbar; standalone use OK) ──
 * Promoted from encoder-management's .em-filterbar — the house pattern for
 * search + selects + switches above a table. Wraps on phones with the
 * search field going full-width. */
/* flex: none matters as much as the layout. A filter bar is normally a
 * .card-body, and Bootstrap gives that flex: 1 1 auto — so inside a card a page
 * has made a flex column (binding a table to the viewport), the bar grows into
 * the free height and centres its controls in a tall empty band while the rows
 * get what's left. It is chrome: it never grows, and only the scroll region may.
 * Three pages patched this locally before it lived here — Fight History, User
 * Management (worst on the usually-empty Deleted tab) and Sparkcast. Harmless
 * where the parent isn't a flex container. */
.portal-filterbar {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--portal-space-2, 0.5rem) var(--portal-space-3, 0.75rem);
    flex: none;
}

.portal-filter-search {
    position: relative;
}

.portal-filter-search .fa-magnifying-glass {
    position: absolute;
    left: 0.6rem;
    top: 50%;
    transform: translateY(-50%);
    font-size: var(--portal-text-sm);
    color: var(--portal-c-text-muted, var(--bs-secondary-color));
    pointer-events: none;
}

.portal-filter-search .form-control {
    padding-left: 1.9rem;
    min-width: 16rem;
}

.portal-filter-select {
    width: auto;
}

/* Filter disclosure — the compact companion to a filter bar when the full
 * set of choices would overpower the content. Native <details> keeps the
 * trigger keyboard/touch accessible with no page-owned open/close JS. */
.portal-filter-menu {
    position: relative;
}

.portal-filter-menu > summary {
    display: inline-flex;
    align-items: center;
    list-style: none;
    cursor: pointer;
}

.portal-filter-menu > summary::-webkit-details-marker {
    display: none;
}

.portal-filter-menu-value {
    margin-left: var(--portal-space-2, 0.5rem);
    color: var(--portal-c-text-muted);
    font-weight: var(--portal-weight-regular);
}

.portal-filter-menu-panel {
    position: absolute;
    z-index: var(--portal-z-top);
    top: calc(100% + var(--portal-space-2, 0.5rem));
    left: 0;
    width: min(38rem, calc(100vw - 3rem));
    max-height: min(65vh, 32rem);
    overflow-y: auto;
    padding: var(--portal-space-3, 0.75rem);
    border: 1px solid var(--portal-c-border);
    border-radius: var(--bs-border-radius-lg);
    background-color: var(--portal-c-overlay-opaque);
    box-shadow: var(--portal-c-edge-card), var(--portal-c-elev-overlay);
}

.portal-filter-menu-option {
    display: flex;
    align-items: center;
    gap: var(--portal-space-2, 0.5rem);
    min-height: var(--portal-control-h);
    margin-bottom: var(--portal-space-2, 0.5rem);
    cursor: pointer;
}

.portal-filter-menu-heading {
    margin-bottom: var(--portal-space-2, 0.5rem);
    color: var(--portal-c-text-muted);
    font-size: var(--portal-label-size);
    font-weight: var(--portal-weight-label);
    letter-spacing: var(--portal-label-tracking);
    text-transform: uppercase;
}

@media (max-width: 575.98px) {
    .portal-filter-search,
    .portal-filter-search .form-control {
        width: 100%;
        min-width: 0;
    }

    /* …except in a page header, where it shares the row with the header's
     * buttons instead of claiming one of its own. A phone header holds the
     * automatic Docs link and rarely more, so the full width above was
     * spending a whole row to sit beside 60px of button.
     *
     * flex-basis 8rem, not 0: the basis is the width the search ASKS for, so
     * a header carrying enough buttons to leave less than that wraps the
     * search to its own line and the rule above takes back over — the graceful
     * end of this, rather than a 20px-wide search crushed against them. */
    .portal-page-header-actions .portal-filter-search {
        flex: 1 1 8rem;
    }

    /* A phone disclosure becomes a bottom sheet above the system bar instead
     * of a narrow anchored popover that can spill off-screen. */
    .portal-filter-menu-panel {
        position: fixed;
        inset: auto 1rem calc(var(--portal-phone-bar-height, 4rem) + 1rem) 1rem;
        width: auto;
        max-height: 60vh;
    }
}

@media (pointer: coarse) {
    .portal-filter-menu-option {
        min-height: 44px;
    }
}

/* ── Dock (a pinned bar) ──────────────────────────────────────────────
 * A bar that stays put while its own page scrolls under it: the Inspection
 * Wizard's step bar, the judge's running score, a QA run's progress. Three
 * pages hand-rolled this shape independently and the fourth got it wrong,
 * which is what a component is for.
 *
 * A dock is OPAQUE, and that is the whole point. Card glass is translucent by
 * design; a translucent surface with content sliding beneath it shimmers, so
 * the contract (dev-setup §style-contract) puts pinned elements on the opaque
 * family. Never backdrop-filter either — a blur inside a scroll container
 * re-renders per frame on exactly the hardware the degrade gates protect.
 *
 * So a dock is deliberately NOT a .card, and must never carry the class:
 * `.portal-page-themed .card` is (0,2,0) and out-specifies this single-class
 * rule, so adding `card` for the border-and-shadow paints the translucent
 * surface straight back over the top. That is why the dock brings its own
 * border, radius and shadow rather than borrowing a card's. `kit-css.spec.js`
 * fails if a dock stops computing opaque or picks the class back up.
 *
 * Layout is a flex row because every dock so far is one; a page that needs
 * another shape overrides display and keeps the material. --portal-dock-top is
 * the knob: the default clears the fixed topbar, and a dock inside a scroll
 * container (rather than the page) re-points it to its own container's edge.
 *
 * `display: flex` is safe next to the `hidden` attribute the wizard's dock
 * toggles — Bootstrap's reboot carries `[hidden] { display: none !important }`,
 * which is the only reason an author display declaration doesn't resurrect it. */
.portal-dock {
    position: sticky;
    top: var(--portal-dock-top, calc(var(--portal-chrome-offset-top, 60px) + var(--portal-space-2, 0.5rem)));
    z-index: 10;
    display: flex;
    align-items: center;
    gap: var(--portal-space-3, 0.75rem);
    padding: 0.55rem var(--portal-space-3, 0.75rem);
    margin-bottom: var(--portal-space-4, 1rem);
    border: 1px solid var(--portal-c-border);
    border-radius: var(--bs-border-radius-lg);
    background-color: var(--portal-c-surface-opaque, var(--portal-c-surface));
    /* NOT `0 2px 8px var(--portal-c-shadow)`: that token is itself a
       two-layer shadow LIST, so the longhand was invalid at computed-value
       time and the dock cast nothing at all. Compose bundles instead —
       raised, because a dock is by definition above the content scrolling
       under it. */
    box-shadow: var(--portal-c-edge-card), var(--portal-c-shadow-raised);
}

/* A dock wraps rather than ellipsing on a phone: its contents are a status
 * readout and the controls that act on it, and there is no order in which one
 * of those is discardable. Each page decides what to hide instead. */
@media (max-width: 575.98px) {
    .portal-dock {
        flex-wrap: wrap;
        gap: var(--portal-space-2, 0.5rem);
    }
}

/* ── Scroll region (.portal-scroll-region) ────────────────────────────
 * A region that scrolls INSIDE its group instead of growing the page: the
 * roster on /user-management, Sparkcast's devices, Academy's two panels,
 * Robot Inspection's user list and its history tables, the fight log.
 * Six regions across five stylesheets repeated these five declarations
 * before this existed.
 *
 * WHAT THE CALLER OWES IT: the parent must be a flex COLUMN with
 * `min-height: 0`, and every sibling must be `flex-shrink: 0`, so this
 * element is the only grower. When a card became a group the title moved
 * OUTSIDE the surface, so the flex chain runs group → list → region; a
 * page that kept pointing the chain at `> .card-header` silently stopped
 * matching (hit on /sparkcast-management and /user-management).
 *
 * `min-height: 0` is the whole trick. A flex item's automatic minimum size
 * is its content, so without it the region reports its full content height
 * as a floor, grows the page, and the overflow never engages — the bug
 * looks like "the scroll box does nothing" rather than a sizing fault.
 *
 * A nested `.table-responsive` must not be a scroller on the axis this one
 * owns: two scrollers on the same axis means the inner one swallows the
 * wheel, and a sticky header sticks to the inner box instead of the one you
 * can see. */
.portal-scroll-region {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    overflow-x: hidden;
    /* Rubber-banding at the end of this list must not chain to the page
       behind it — the iOS tell that a scroll box is not really a pane. */
    overscroll-behavior: contain;
    -webkit-overflow-scrolling: touch;
}

.portal-scroll-region .table-responsive {
    overflow-x: auto;
    overflow-y: visible;
    -webkit-overflow-scrolling: touch;
}

/* Sticky header, opt-in. Opaque per the sticky rule — rows pass beneath it,
 * so the translucent surface token would let them ghost through. This is
 * also where the portal stopped disagreeing with itself: the six regions
 * shipped two different fills, `--portal-c-tab-track` (a black wash meant
 * for tab strips) on /academy and /user-management against the opaque
 * surface everywhere else. The opaque surface is the sticky-element token —
 * it is what .portal-dock takes — so it wins. */
.portal-scroll-region--sticky-head thead th {
    position: sticky;
    top: 0;
    z-index: 1;
    background-color: var(--portal-c-surface-opaque, var(--portal-c-surface));
}

/* …and the header has to actually PIN, which until 2026-09-06 it never did.
 *
 * `overflow-x: auto; overflow-y: visible` on the nested .table-responsive is
 * not a thing CSS will give you: when one axis is not `visible` the other
 * computes to `auto` (css-overflow-3 §3), so that wrapper was a vertical
 * scrollport too. It never scrolls — the outer region is what has the height
 * — so `position: sticky; top: 0` resolved against a box that was already at
 * its top, and the header simply rode the rows up and out of sight. Measured
 * on the Inspection Report: at scrollTop 250 the thead sat 250px ABOVE the
 * region it was supposed to be pinned to. Every one of the six --sticky-head
 * regions had it, and no gate saw it: they all asserted `position: sticky`,
 * which was true and did nothing.
 *
 * A region that opted into a pinned header gets the horizontal scroll moved
 * out to the region itself, so the wrapper stops being a scrollport on either
 * axis. The header then pins vertically and travels sideways with its own
 * columns, which is what it should do. Plain .portal-scroll-region regions
 * are untouched. */
.portal-scroll-region--sticky-head {
    overflow-x: auto;
}

.portal-scroll-region--sticky-head .table-responsive {
    overflow: visible;
}

/* Edge fade — the tab strip's affordance turned 90°.
 *
 * A region caps at a pixel height and its rows are ~59px, so the cap lands
 * mid-row essentially always: the last row is sliced by a hard, straight edge
 * flush with the surface's bottom. On a Mac with overlay scrollbars there is
 * nothing else to see, and the panel does not read as "there is more below" —
 * it reads as a box that was cut off, which is what Brian saw on the
 * Inspection Report (2026-09-06).
 *
 * A partial row IS the affordance every native list uses; it just has to look
 * deliberate. The fade is what makes it so, and it is the same mechanism
 * .portal-tabs[data-overflow] already carries — a MASK, not an overlay
 * gradient: an overlay would need to know the surface colour behind it (which
 * is translucent, over a page background that moves with the theme), while a
 * mask lets whatever is behind show through the fading row itself.
 *
 * portal-kit.js stamps data-overflow, so a region that fits paints nothing.
 * Only the BOTTOM edge fades, at any state where content is hidden below: the
 * top of a scrolled region is where --sticky-head parks its header, and a mask
 * there would fade the column titles as the rows pass under them.
 *
 * Values match the tab strip's and name where the HIDDEN content is. */
.portal-scroll-region[data-overflow="end"],
.portal-scroll-region[data-overflow="both"] {
    -webkit-mask-image: linear-gradient(180deg, #000 calc(100% - 1.75rem), transparent 100%);
    mask-image: linear-gradient(180deg, #000 calc(100% - 1.75rem), transparent 100%);
}

/* ── Table card (components/table-card.twig) ─────────────────────────
 * A GROUP since 2026-08-13, so the rules below address .portal-ios-list's
 * children rather than a card's. The table is untouched — it pads its own
 * cells — and only the chrome around it moved. */
.portal-table-card .portal-table-card-hint {
    font-size: var(--portal-text-xs, 0.75rem);
}

/* Toolbar strip. Was a .card-body, which supplied the padding; the list has
 * none of its own (rows carry theirs), so the bar brings its own gutter and the
 * hairline that separates it from the table it filters.
 *
 * UNSCOPED, deliberately. It used to sit under `.portal-table-card`, and four
 * of the pages that write this strip by hand never carry that class: a page
 * that cannot use the embed (because the backend swaps its whole table, or
 * because a scroll body and a pager don't fit the embed's fixed structure)
 * builds the group itself, and `portal-table-card` is decoration on the group
 * rather than something its own markup needs — so it gets left off. The strip
 * then rendered with NO gutter and NO hairline: the search field flush against
 * the list's left border with the sticky <thead> starting right underneath.
 * /user-management, /sparkcast-management and both of /portal-api's log tabs
 * shipped that way, and every string-matching gate passed the entire time —
 * the class is spelled correctly and it is on the right element, the rule just
 * never reached it. (The same trap caught .portal-table-card-hint, which
 * notification-ops works around by not using the class at all.)
 *
 * Safe unscoped because the class name is the component's own: nothing wears it
 * except this strip, unlike .card-body, which is why the ancestor was never
 * doing work here. tests/visual/user-management.spec.js measures it. */
.portal-table-card-toolbar {
    padding: var(--portal-space-2, 0.5rem) var(--portal-ios-gutter, 1rem);
    border-bottom: 1px solid var(--portal-c-border);
}

/* Footer strip (table-card `footer` block) — pagination / totals / "showing
 * N of M". Deliberately echoes the group's header surface and hairline so it
 * reads as chrome bracketing the table, not as one more data row.
 *
 * Direct child of the LIST, not of the card: the footer moved inside
 * .portal-ios-list so the list's overflow:hidden keeps it inside the rounded
 * corners it used to get from .card. A `>` against .portal-table-card would
 * have silently stopped matching, and the only symptom is a footer that reads
 * as a table row.
 *
 * Still scoped to the class the component emits, NOT to every .card-footer:
 * hand-rolled table cards predate this block (e.g. account/schedule.twig) and
 * their footers hold a full-width pagination wrapper. Turning those into flex
 * items collapses the wrapper to its max-content width, which defeats
 * .portal-pagination's justify-content: space-between and yanks the pager off
 * the right edge. */
.portal-table-card .portal-ios-list > .portal-table-card-footer {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--portal-space-2, 0.5rem) var(--portal-space-3, 0.75rem);
    padding: var(--portal-space-2, 0.5rem) var(--portal-ios-gutter, 1rem);
    background-color: var(--portal-c-surface-header);
    border-top: 1px solid var(--portal-c-border);
    font-size: var(--portal-text-sm, 0.8125rem);
    color: var(--portal-c-text-muted);
}

/* Opt-in phone table stack. The semantic table remains intact; only its
 * visual formatting changes. portal-kit.js derives each data-label from the
 * real column header and repeats that work when a poll replaces rows. */
@media (max-width: 767.98px) {
    .portal-table--stack thead {
        position: absolute;
        width: 1px;
        height: 1px;
        padding: 0;
        margin: -1px;
        overflow: hidden;
        clip: rect(0, 0, 0, 0);
        white-space: nowrap;
        border: 0;
    }

    .portal-table--stack,
    .portal-table--stack tbody,
    .portal-table--stack tr,
    .portal-table--stack td {
        display: block;
        width: 100%;
    }

    .portal-table--stack tbody tr {
        content-visibility: auto;
        contain-intrinsic-size: auto 12rem;
        padding-block: var(--portal-space-1, 0.25rem);
        border-bottom: 1px solid var(--portal-c-border-subtle);
    }

    .portal-table--stack tbody tr:last-child { border-bottom: 0; }

    .portal-table--stack tbody td {
        display: grid;
        grid-template-columns: minmax(6rem, 38%) minmax(0, 1fr);
        align-items: center;
        gap: var(--portal-space-3, 0.75rem);
        min-height: 2.75rem;
        padding: var(--portal-space-2, 0.5rem) var(--portal-ios-gutter, 1rem);
        text-align: start !important;
        border: 0;
    }

    .portal-table--stack tbody td[data-label]::before {
        content: attr(data-label);
        font-family: var(--portal-font-mono);
        font-size: var(--portal-label-size);
        font-weight: var(--portal-label-weight);
        letter-spacing: var(--portal-label-tracking);
        text-transform: uppercase;
        color: var(--portal-c-text-muted);
    }

    .portal-table--stack tbody td[colspan] {
        display: block;
        min-height: 0;
        text-align: center !important;
    }

    .portal-table--stack tbody td[colspan]::before { content: none; }
}

/* Reduced-data is explicit: broadcast walls intentionally autoplay. App
 * pages opt in around previews or decoration and provide the adjacent note. */
.portal-savedata-message { display: none; }

html[data-portal-save-data="on"] .portal-savedata-media > video,
html[data-portal-save-data="on"] .portal-savedata-media > img[data-data-heavy] {
    display: none;
}

html[data-portal-save-data="on"] .portal-savedata-media > .portal-savedata-message {
    display: flex;
}

/* ── Truncation (.portal-truncate) ────────────────────────────────────
 * Contract (dev-setup §style-contract): names truncate, numbers and status
 * never do.
 *
 * text-overflow: ellipsis only fires on a box that (a) is not inline and
 * (b) has a width ceiling to overflow. ONE carrier shape satisfies both,
 * and it is the only supported one:
 *
 *   <td><div class="portal-truncate">…</div></td>   — inside a cell, and
 *   <div class="portal-truncate">…</div>            — anywhere else.
 *
 * NOT the cell itself. `max-width` on a display: table-cell box is undefined
 * in CSS 2.1 §17.5.2, and browsers feed it to the column-width algorithm
 * instead of using it as a ceiling — absolute units do not help. Measured in
 * Chrome against this stylesheet: a <td class="portal-truncate"> computing
 * max-width: 288px rendered 542px wide with the full string and no ellipsis,
 * and a --portal-truncate-max: 6rem (96px) override still rendered 168px. A
 * cell only ever clips when the table is already over-constrained, at an
 * arbitrary viewport-dependent width that is never the ceiling. Honoring a
 * ceiling on a cell would take table-layout: fixed on the whole table, which
 * re-sizes every other column — not something a single utility class may do.
 *
 * So the rule is scoped OFF cells. A td/th carrier is an inert no-op (the
 * cell wraps, exactly as it did before this class existed) rather than
 * nowrap-with-no-ellipsis, which would only widen the column and push
 * .table-responsive into horizontal scroll sooner.
 *
 * display: block gives the carrier a non-inline box — a bare inline never
 * clips. min-width: 0 is a no-op outside flex and lets a flex child shrink
 * below its content width, which is the other place ellipsis silently dies.
 *
 * --portal-truncate-max is the knob: set it on the carrier itself, or on any
 * ancestor (row, table, card) to retune a column — it inherits like any
 * custom property (e.g. --portal-truncate-max: 12rem). */
.portal-truncate:not(td):not(th) {
    display: block;
    min-width: 0;
    max-width: var(--portal-truncate-max, 18rem);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Contract guard: numbers never truncate. If .portal-truncate lands on a
 * numeric carrier by mistake the value stays whole rather than becoming a
 * misleading half-number. Higher specificity (0,2,0) than the rule above. */
.portal-col-num.portal-truncate {
    max-width: none;
    overflow: visible;
    text-overflow: clip;
}

/* ── Section loading (components/section-loading.twig) ────────────────
 * Tier 1 of the loading model: a region's first paint (page body, tab
 * pane, fragment container awaiting its first fetch). Tier 2 is
 * .portal-skeleton / loadingRow below (row shape known); tier 3 is
 * spinner-in-button. Replaces legacy startLoading() — frozen pages only
 * (see js/site/helper.js).
 *
 * Anti-flash: starts invisible and fades in after ~200ms, so fast
 * responses (e.g. the check-in 409 that opens a modal) tear it down
 * before it ever paints. Teardown is scoped: the owning JS overwrites
 * its container — never a global selector. Static opacity is 1, so the
 * reduced-motion kill in portal-motion.css shows it immediately instead
 * of never. */
.portal-section-loading {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--portal-space-3, 0.75rem);
    padding: var(--portal-space-7, 3rem) var(--portal-space-4, 1rem);
    color: var(--portal-c-text-muted, var(--bs-secondary-color));
    animation: portal-section-loading-in var(--portal-dur-2, 200ms) var(--portal-ease-out, ease-out) both;
    animation-delay: var(--portal-dur-2, 200ms);
}

.portal-section-loading-label {
    font-size: var(--portal-text-sm, 0.8125rem);
}

@keyframes portal-section-loading-in {
    from { opacity: 0; }
    to   { opacity: 1; }
}

/* ── Skeleton loading (panels/tables awaiting data) ───────────────────
 * Tier 2 of the loading model: content areas whose row/bar shape is
 * known (tier 1 = .portal-section-loading above, tier 3 =
 * spinner-in-button). Shimmer joins the reduced-motion kill list in
 * portal-motion.css. */
.portal-skeleton {
    background: var(--portal-c-skeleton-bg);
    border-radius: var(--bs-border-radius);
    min-height: 0.85rem;
    position: relative;
    overflow: hidden;
}

.portal-skeleton::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(90deg, transparent, var(--portal-c-skeleton-sheen), transparent);
    animation: portal-shimmer 1.4s var(--portal-ease-in-out, ease-in-out) infinite;
}

/* A round placeholder (avatars, status pucks) — same material, one axis.
   It carries its own size: these usually stand in a flex row next to text
   lines, where a width-less item collapses to nothing and a shrinkable one
   goes oval. Retune per use with --portal-skeleton-circle-size. */
.portal-skeleton-circle {
    width: var(--portal-skeleton-circle-size, 1.375rem);
    border-radius: 50%;
    aspect-ratio: 1;
    flex-shrink: 0;
}

@keyframes portal-shimmer {
    from { transform: translateX(-100%); }
    to   { transform: translateX(100%); }
}

/* ── Compact empty state (inside tables/panels) ───────────────────────
 * Icon + one line, no mascot, no CTA. Sparky is for destination pages
 * (home, my-event, access-denied) — never inside a data panel. */
.portal-empty-state--compact {
    padding: var(--portal-space-5, 1.5rem) var(--portal-space-4, 1rem);
}

.portal-empty-state--compact .portal-empty-state-icon {
    width: 2.75rem;
    height: 2.75rem;
    font-size: var(--portal-text-base);
    margin-bottom: 0.75rem;
}

.portal-empty-state--compact .portal-empty-state-title {
    font-size: var(--portal-title-card);
    font-weight: var(--portal-weight-label);
}

.portal-empty-state--compact .portal-empty-state-message {
    font-size: var(--portal-text-sm, 0.8125rem);
}

/* ── Tile (a quiet inset card, usually in a grid) ─────────────────────
 * A small linkable block — title, one line of description — laid out in a
 * grid INSIDE a section. Quieter than .card on purpose: it sits on the tab
 * track rather than on glass, because a card grid nested inside a section
 * that is itself a surface reads as boxes in boxes.
 *
 * This is not the same shape as a page's own launcher. /tools is a whole page
 * of tap targets and its cards are .card, per the style contract's "a discrete
 * object repeated among siblings is a card". A tile is the INLINE case: a few
 * links inside a documentation section or a Start Here category.
 *
 * It arrives in the kit because it had already been written twice —
 * .docs-tool-card in documentation.css and .gs-tool-card in
 * getting-started.css, whose own comment said it was "the same visual
 * language as documentation.css's .docs-tool-card, kept local to this page
 * rather than cross-linking a different feature's stylesheet". That instinct
 * was right (a page must not link another feature's stylesheet) and the
 * conclusion was wrong: the answer to two pages needing one shape is the kit,
 * which every page already loads. The two copies were byte-identical, so
 * nothing had drifted YET — this is the cheap moment to merge them.
 *
 * Two things the copies got wrong and the kit version does not: their radius
 * was a literal 0.5rem, which is not a tier on the portal's scale (4/6/10/14/20
 * — a tile nested inside a section takes the 6px control tier, so this is a 2px
 * change on both pages), and their translateY hover had no reduced-motion
 * answer at all. */
.portal-tile-grid {
    --portal-tile-min: 220px;
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
    gap: 0.75rem;
}

.portal-tile {
    /* Positioned so a .stretched-link inside resolves against the tile. */
    position: relative;
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
    height: 100%;
    padding: 0.875rem 1rem;
    border-radius: var(--bs-border-radius);
    background: var(--portal-c-tab-track);
    border: 1px solid var(--portal-c-border-subtle);
    color: inherit;
    text-decoration: none;
    transition: border-color var(--portal-dur-1, 120ms) var(--portal-ease-out, ease),
                background var(--portal-dur-1, 120ms) var(--portal-ease-out, ease),
                transform var(--portal-dur-2, 200ms) var(--portal-ease-spring, ease);
}

/* Two hover paths, because a tile is interactive in two different ways: the
   whole thing is an <a> (a playbook), or it is a <div> holding a
   .stretched-link plus a second link of its own (a tool with a Docs link).
   In the second case the <div> is not the interactive element, so :has() is
   what notices the link inside it being hovered. */
a.portal-tile:hover,
a.portal-tile:focus-visible,
.portal-tile:has(.stretched-link:hover),
.portal-tile:has(.stretched-link:focus-visible) {
    color: inherit;
    border-color: var(--portal-c-accent-border);
    background: var(--portal-c-accent-muted);
    /* No lift on hover. .portal-hoverable is the one sanctioned lift, and a
       tile sits in a tight grid where a 1px rise reads as the grid shifting
       rather than as one tile responding. The tint is the hover. */
}

/* It presses, though — a tile is a small discrete object and presses like one.
   Scale rather than the card's lift-and-drop, for the same grid reason. */
a.portal-tile:active,
.portal-tile:has(.stretched-link:active) {
    transform: scale(0.98);
    transition-duration: var(--portal-dur-0, 80ms);
}

/* Tile states (v3 §2.8) — the vocabulary infra's cage tiles proved out,
   promoted so mission-control (and successors) adopt it in their
   sub-commits. The tile is a STATEFUL SURFACE: the poll retags data-state
   and every part re-inks from that one attribute. */
.portal-tile[data-state="on"]       { background: var(--portal-c-badge-success-bg); border-color: var(--portal-c-badge-success-color); }
.portal-tile[data-state="degraded"] { background: var(--portal-c-badge-warning-bg); border-color: var(--portal-c-badge-warning-color); }
.portal-tile[data-state="down"]     { background: var(--portal-c-badge-danger-bg);  border-color: var(--portal-c-badge-danger-color); }

.portal-tile-head { display: flex; align-items: center; gap: var(--portal-space-2); }

.portal-tile-meta {
    font-family: var(--portal-font-mono);
    font-size: var(--portal-text-2xs);
    font-variant-numeric: tabular-nums;
    color: var(--portal-c-text-muted);
}

.portal-tile-message { font-size: var(--portal-text-xs); color: var(--portal-c-badge-danger-color); }
.portal-tile[data-state="ok"] .portal-tile-message { color: var(--portal-c-text-muted); }

.portal-tile-state { font-size: var(--portal-text-xs); font-weight: var(--portal-weight-label); }
.portal-tile[data-state="on"] .portal-tile-state { color: var(--portal-c-badge-success-color); }

.portal-tile.is-loading { opacity: 0.65; }

/* A stateful tile PRESSES with a wash, not a transform — it may be mid-poll,
   and a surface that scales while its own text is being replaced flickers. */
.portal-tile[data-state]:active {
    background: var(--portal-c-accent-muted);
    background: color-mix(in srgb, var(--portal-c-accent) 18%, transparent);
    transform: none;
}

.portal-tile-title {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    font-weight: var(--portal-weight-label);
    font-size: var(--portal-text-md);
}

.portal-tile-title .stretched-link {
    color: inherit;
    text-decoration: none;
}

/* Grows so anything after it (a progress badge, a Docs link) sits at the
   bottom of the tile and the row of tiles lines its feet up. */
.portal-tile-desc {
    flex-grow: 1;
    font-size: var(--portal-text-sm);
    line-height: 1.45;
    color: var(--portal-c-text-muted);
}

/* A second link inside a tile whose title already holds a .stretched-link.
   The stretched-link's ::after covers the whole tile, so anything meant to stay
   independently clickable has to be lifted above it — the standard Bootstrap
   pattern, and fiddly enough (position + z-index, neither obvious from the
   markup) that every consumer would otherwise re-derive it. */
.portal-tile-link {
    position: relative;
    z-index: 2;
    align-self: flex-start;
    font-size: var(--portal-text-xs);
    color: var(--portal-c-text-muted);
    text-decoration: none;
}

.portal-tile-link:hover,
.portal-tile-link:focus-visible {
    color: var(--portal-c-accent);
}

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

    a.portal-tile:active,
    .portal-tile:has(.stretched-link:active) {
        transform: none;
    }
}

/* ── Banner (explainers, degraded/error notices) ──────────────────────
 * Quiet surface; the icon carries severity — never a loud full-color box
 * (the impersonation banner stays loud: it's a mode, not a message).
 * Replaces stock .alert-info/.alert-secondary explainers. */
.portal-banner {
    display: flex;
    gap: 0.6rem;
    align-items: baseline;
    background: var(--portal-c-surface);
    border: 1px solid var(--portal-c-border);
    /* It is a card-tier surface, so it takes the card-tier radius and edge.
       It had no border-radius rule at all until now — square corners in a
       system where every other surface is rounded. */
    border-radius: var(--bs-border-radius-lg);
    box-shadow: var(--portal-c-edge-card), var(--portal-c-shadow);
    padding: 0.6rem 1rem;
    font-size: var(--portal-text-sm, 0.8125rem);
    color: var(--portal-c-text-muted);
}

.portal-banner > i {
    color: var(--portal-c-accent);
    flex: 0 0 auto;
}

.portal-banner-warning {
    /* The severity is a TINT of the whole surface now — the old
       border-left-color repainted one pixel of a 1px keyline, which on a
       venue panel is no signal at all. Real property, so the plain surface
       line above it is the no-color-mix fallback (declaration order). */
    background: var(--portal-c-surface);
    background: color-mix(in srgb, var(--portal-c-badge-warning-color) 8%, var(--portal-c-surface));
}

.portal-banner-warning > i {
    color: var(--portal-c-badge-warning-color);
}

.portal-banner-danger {
    /* The severity is a TINT of the whole surface now — the old
       border-left-color repainted one pixel of a 1px keyline, which on a
       venue panel is no signal at all. Real property, so the plain surface
       line above it is the no-color-mix fallback (declaration order). */
    background: var(--portal-c-surface);
    background: color-mix(in srgb, var(--portal-c-badge-danger-color) 8%, var(--portal-c-surface));
}

.portal-banner-danger > i {
    color: var(--portal-c-badge-danger-color);
}

/* Success completes the set. Added when the raw .alert templates were
   converted: four of them were success messages with nowhere to land, which
   is the usual reason a page reaches back for a stock Bootstrap box. */
.portal-banner-success {
    /* The severity is a TINT of the whole surface now — the old
       border-left-color repainted one pixel of a 1px keyline, which on a
       venue panel is no signal at all. Real property, so the plain surface
       line above it is the no-color-mix fallback (declaration order). */
    background: var(--portal-c-surface);
    background: color-mix(in srgb, var(--portal-c-badge-success-color) 8%, var(--portal-c-surface));
}

.portal-banner-success > i {
    color: var(--portal-c-badge-success-color);
}

/* ── Status badge extras (components/status-badge.twig) ─────────────── */
.portal-status-badge .portal-live-dot {
    /* The ring keeps scale with the smaller dot and its tighter halo. */
    --portal-pulse-radius: 4px;
    width: 0.4rem;
    height: 0.4rem;
    margin-right: 0.25rem;
    box-shadow: 0 0 0 2px var(--portal-c-accent-muted);
}

.portal-status-badge i {
    font-size: 0.9em;
    margin-right: 0.25rem;
}

/* ── Switch ───────────────────────────────────────────────────────────
 * The portal's ON/OFF control, everywhere. ONE size: 42x25 track, 21px knob,
 * 17px travel. Every colour is a portal token, so it reads as a phone switch
 * but stays teal rather than importing Apple's green.
 *
 * It lives in the GLOBAL kit rather than with the settings-list family in
 * portal-ios.css because pages that are not built on that family still need
 * one ON/OFF control — the /style-guide gallery renders the flag partial with
 * no page CSS at all, and any future settings screen gets the switch for free.
 *
 * Was the literal iOS 51x31 until 2026-08-12. On a phone that size is a thumb
 * target and the whole row is 44px; in the portal the rows are 54px on a
 * desktop pointer, and the switch ended up the heaviest thing in the row —
 * visually louder than the icon tile beside it, for a control that is usually
 * just reporting state. The proportions are unchanged (2px inset all round,
 * same knob-to-track ratio), so it is the same switch drawn smaller.
 *
 * There is deliberately no size modifier. --sm existed for dense non-44px
 * lists, never had a caller, and is exactly this geometry — two names for one
 * control is how a kit starts drifting. A genuinely denser list can add one
 * back when something actually needs it. */
.portal-switch {
    appearance: none;
    -webkit-appearance: none;
    flex: 0 0 auto;
    position: relative;
    width: 2.625rem;
    height: 1.5625rem;
    margin: 0;
    border: 0;
    border-radius: var(--bs-border-radius-pill);
    /* Derived from the muted ink, NOT --portal-c-tab-track: that token is
       rgba(0,0,0,.3) on dark, which is tuned to sit on the page CANVAS. On a
       card or list surface — and especially inside a sheet, where the surface
       is already rgba(0,0,0,.25) — it composited to nothing, and an off switch
       rendered as a white knob floating in mid-air with no track behind it.
       A mix of the text ink reads on every surface in both themes. */
    /* Declaration-order fallback, not a gate: this is a real property, so an
       engine without color-mix drops the line below and falls back to this one.
       (The parse floor exists for CUSTOM properties, which are not
       parse-validated and so cannot fall back at all.) A neutral grey at the
       same alpha reads on both themes — plainer than the derived mix, but a
       visible track rather than none. */
    background-color: rgba(128, 128, 128, 0.32);
    background-color: color-mix(in srgb, var(--portal-c-text-muted) 32%, transparent);
    box-shadow: var(--portal-c-tab-track-inset);
    cursor: pointer;
    transition: background-color var(--portal-dur-2, 200ms) var(--portal-ease-out, ease-out);
}

.portal-switch::after {
    content: "";
    position: absolute;
    top: 0.125rem;
    left: 0.125rem;
    width: 1.3125rem;
    height: 1.3125rem;
    border-radius: 50%;
    /* White in BOTH themes, on purpose — the knob is the moving part and has
       to stay the brightest thing in the control however dark the track goes.
       Every phone switch does this; a token here would break it in light. */
    background-color: #fff;
    box-shadow: var(--portal-c-shadow-pressed);
    transition: transform var(--portal-dur-2, 200ms) var(--portal-ease-out, ease-out);
}

/* .is-on is the twin of :checked for a switch that is NOT an input.
   The Appearance menu's glass row is a .dropdown-item with
   role="menuitemcheckbox" — a `switch` role is not a valid child of `menu` —
   so its knob is a decorative <span> keyed off the ROW's aria-checked. Same
   declarations, so the shapes cannot drift apart.

   [aria-checked="true"] > is the third twin, and it is what actually paints
   the Sidebar and Dock rows: only the glass row ever gets .is-on (portal-
   theme.js's syncGlassRows), so the other menuitemcheckbox rows sat with the
   state right and the knob permanently off — the row's aria flip is the one
   thing every writer already does, so the track now reads it directly. Child
   combinator on purpose: the row's own knob, never a switch nested deeper in
   some future row's markup, and theme rows (menuitemradio) carry a swatch,
   not a .portal-switch, so they never match. */
.portal-switch:checked,
.portal-switch.is-on,
[aria-checked="true"] > .portal-switch {
    background-color: var(--portal-c-accent);
    box-shadow: none;
}

.portal-switch:checked::after,
.portal-switch.is-on::after,
[aria-checked="true"] > .portal-switch::after {
    transform: translateX(1.0625rem);
}

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

.portal-switch:disabled {
    opacity: 0.45;
    cursor: not-allowed;
}

/* In flight: the control is saving, so it stops accepting input without
   flipping back — a disabled attribute here would read as "not allowed". */
.portal-switch.is-saving {
    pointer-events: none;
    opacity: 0.6;
}

@media (prefers-reduced-motion: reduce) {
    .portal-switch,
    .portal-switch::after {
        transition: none;
    }
}

/* A labelled switch OUTSIDE a settings list (components/switch.twig; JS twin
   portalKit.switchField). No portal-switch--* modifiers — settings-list.spec
   forbids them; the FIELD wrapper owns the layout, the switch stays itself. */
.portal-switch-field {
    display: flex;
    align-items: center;
    gap: var(--portal-space-3);
    min-height: var(--portal-ios-row-min, 2.75rem);
    cursor: pointer;
}

.portal-switch-field-text {
    flex: 1 1 auto;
    min-width: 0;
    font-size: var(--portal-text-md);
    line-height: var(--portal-leading-snug);
}

.portal-switch-field-help {
    display: block;
    font-size: var(--portal-text-xs);
    color: var(--portal-c-text-muted);
}

/* ── Sortable headers ─────────────────────────────────────────────────
   FA glyphs in a fixed-width ::after so the label never reflows when the
   direction flips. .sortable/.sort-asc/.sort-desc stay the JS contract
   (pages toggle them); templates add portal-sortable beside sortable.
   Pages with many tight columns retune --portal-sortable-gap locally. */
.portal-page-themed th.portal-sortable {
    cursor: pointer;
    user-select: none;
    position: relative;
    padding-right: calc(0.75rem + var(--portal-sortable-gap, 1.375rem));
}

.portal-page-themed th.portal-sortable::after {
    content: "\f0dc";
    font-family: "Font Awesome 7 Free";   /* the vendor face is FA 7 — "6" fell back to tofu boxes */
    font-weight: 900;
    font-size: var(--portal-text-2xs);
    position: absolute;
    right: 0.75rem;
    top: 50%;
    translate: 0 -50%;
    width: 1ch;
    text-align: center;
    color: var(--portal-c-text-muted);
    opacity: 0.55;
}

.portal-page-themed th.portal-sortable.sort-asc::after  { content: "\f0de"; color: var(--portal-c-accent); opacity: 1; }
.portal-page-themed th.portal-sortable.sort-desc::after { content: "\f0dd"; color: var(--portal-c-accent); opacity: 1; }

/* ── Chip (pill: tag / filter / count / role) ─────────────────────────
   The BADGE keeps the 6px rectangle, uppercase, tracked — "state of a
   record". The CHIP is the pill. .portal-ios-status folds in here as an
   alias for one release (its 7 consumers rename in the page sweeps); its
   old bs-emphasis colours become the badge-token family so the page's one
   green is one green. JS twin: portalKit.chip(). */
.portal-chip,
.portal-ios-status {
    display: inline-flex;
    align-items: center;
    gap: var(--portal-space-1);
    padding: 0.15rem 0.55rem;
    min-height: 1.375rem;
    font-size: var(--portal-text-xs);
    font-weight: var(--portal-weight-label);
    line-height: 1.2;
    font-feature-settings: "case" 1;
    border-radius: var(--bs-border-radius-pill);
    border: 1px solid var(--portal-c-edge-control-subtle);
    background: var(--portal-c-badge-muted-bg);
    color: var(--portal-c-badge-muted-color);
    box-shadow: var(--portal-c-edge-chip);
    white-space: nowrap;
}

.portal-chip[data-state="success"], .portal-ios-status[data-state="success"] { background: var(--portal-c-badge-success-bg); color: var(--portal-c-badge-success-color); }
.portal-chip[data-state="warning"], .portal-ios-status[data-state="warning"] { background: var(--portal-c-badge-warning-bg); color: var(--portal-c-badge-warning-color); }
.portal-chip[data-state="danger"],  .portal-ios-status[data-state="danger"]  { background: var(--portal-c-badge-danger-bg);  color: var(--portal-c-badge-danger-color); }
.portal-chip[data-state="info"],    .portal-ios-status[data-state="info"]    { background: var(--portal-c-accent-muted);     color: var(--portal-c-accent); }
.portal-chip[data-state="live"],    .portal-ios-status[data-state="live"],
.portal-chip[data-state="accent"],  .portal-ios-status[data-state="accent"]  { background: var(--portal-c-badge-live-bg);    color: var(--portal-c-badge-live-color); }
.portal-chip[data-state="running"], .portal-ios-status[data-state="running"] { background: var(--portal-c-badge-running-bg); color: var(--portal-c-badge-running-color); }
.portal-chip[data-state="brand"],   .portal-ios-status[data-state="brand"]   { background: var(--portal-c-badge-brand-bg);   color: #fff; border-color: var(--portal-c-badge-brand-border); }

/* ── Weight class (data-w) ────────────────────────────────────────────────
   `data-w="3|12|30|fs"` is the universal weight indicator upstream — it
   colours chips, card stripes and bracket leaves across BrettZone and MI.
   Here it is the same SHAPE as data-state above and deliberately not the
   same LOUDNESS: the tokens resolve to the muted chip in dark, light and
   matrix, and only portal-sport-theme.css gives them BrettZone's teal,
   amber, red and lime. Writing data-w costs a template nothing in a theme
   that does not use it, which is what lets every match list carry it. */
.portal-chip[data-w="3"]  { background: var(--portal-c-w3-bg);  color: var(--portal-c-w3-color); }
.portal-chip[data-w="12"] { background: var(--portal-c-w12-bg); color: var(--portal-c-w12-color); }
.portal-chip[data-w="30"] { background: var(--portal-c-w30-bg); color: var(--portal-c-w30-color); }
.portal-chip[data-w="fs"] { background: var(--portal-c-wfs-bg); color: var(--portal-c-wfs-color); }

/* The stripe utility: a left edge for a row or a tile. It reserves its own
   width in every theme so switching to sport re-colours the edge rather than
   reflowing the row it is on. */
.portal-w-stripe {
    position: relative;
    padding-left: 0.85rem;
}

.portal-w-stripe::before {
    content: "";
    position: absolute;
    inset: 0 auto 0 0;
    width: 3px;
    border-radius: var(--bs-border-radius-sm);
    background: var(--portal-c-w-stripe);
}

.portal-w-stripe[data-w="3"]::before  { background: var(--portal-c-w3-color); }
.portal-w-stripe[data-w="12"]::before { background: var(--portal-c-w12-color); }
.portal-w-stripe[data-w="30"]::before { background: var(--portal-c-w30-color); }
.portal-w-stripe[data-w="fs"]::before { background: var(--portal-c-wfs-color); }

.portal-chip--mono {
    font-family: var(--portal-font-mono);
    font-size: var(--portal-text-2xs);
    letter-spacing: var(--portal-label-tracking);
    text-transform: uppercase;
}

/* A chip that is a SENTENCE rather than a label. The pill is nowrap because a
   tag, a count or a role is one or two words and a wrapped label reads as two
   chips — but a page that puts a clause in a chip ("Also on: <bot> — separate
   check-in") gets it clipped mid-word inside any list that clips its overflow,
   and the half that disappears is the half carrying the meaning. Opt in per
   chip; the pill radius and padding are unchanged, only the line breaks.
   Align to the start so a two-line chip's icon sits with the first line
   instead of centring against the whole block. */
.portal-chip--wrap {
    white-space: normal;
    align-items: flex-start;
}

/* A chip rendered as a link: the chip is the affordance, so the anchor
   underline is noise on top of it — is-interactive carries the hover/focus
   states instead. */
a.portal-chip { text-decoration: none; }

.portal-chip.is-interactive {
    cursor: pointer;
    transition: background-color var(--portal-dur-1) var(--portal-ease-out),
                border-color var(--portal-dur-1) var(--portal-ease-out),
                color var(--portal-dur-1) var(--portal-ease-out);
}

.portal-chip.is-interactive:hover { border-color: var(--portal-c-edge-control); color: var(--portal-c-text); }

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

/* Toggle idiom: a pressed chip RECESSES (selected is never hover-flat). */
.portal-chip.is-interactive[aria-pressed="true"] {
    background: var(--portal-c-accent-muted);
    color: var(--portal-c-accent);
    border-color: var(--portal-c-edge-control);
    box-shadow: var(--portal-c-tab-track-inset);
}

.portal-chip-remove {
    appearance: none;
    border: 0;
    background: none;
    padding: 0 0 0 0.1rem;
    color: inherit;
    opacity: 0.6;
    cursor: pointer;
    font-size: 0.9em;
}

.portal-chip-remove:hover { opacity: 1; }

@media (pointer: coarse) {
    .portal-chip.is-interactive { min-height: 2rem; padding-inline: 0.7rem; }
}

/* ── Feature flag switch (components/flag-switch.twig) ────────────────
 * Component skin, moved here from system-management.css 2026-08-09 so the
 * shared partial styles itself wherever it renders (system management,
 * the /style-guide gallery, future settings pages). Page CSS loads after
 * the kit, so page-level refinements (e.g. system-management's
 * .sysmgmt-spend-guard variant) still win the cascade.
 *
 * The row itself is .portal-ios-row (portal-ios.css) — geometry, hairline,
 * hover and press all come from the family. What is left here is only what a
 * FLAG row needs on top of one, so a page that adopts the family gets the
 * flag list for free and cannot drift from it. .sysmgmt-flag-item itself
 * carries NO layout: the first cut gave it flex-wrap so a long label could
 * break, and because the text block's flex-basis is `auto` — i.e. its
 * max-content width — the block claimed the whole line and threw the switch
 * onto a second one, 500px from where it belonged, on every row wide enough to
 * matter. Without wrap the block simply shrinks and the label wraps inside it,
 * which is what was wanted. Leave the class as a hook and let the row lay
 * itself out. */

/* ── Settings sub-head ────────────────────────────────────────────────
 * A labelled break between two inset lists inside ONE settings group —
 * used by components/flag-run.twig for a run of switches, and by
 * /system-management for the stat lists that hang off a group.
 *
 * The `note` slot is the reason the flag version exists: the visibility
 * run's switches read ON = hidden, the opposite of every other switch on
 * the card, and a heading is the honest place to say so rather than
 * renaming the flags to match the UI.
 *
 * Sits ABOVE its list, in the group-note's gutter, so it reads as a
 * sub-head of the group title rather than as a row inside the list. Not
 * .portal-ios-group-title's mono uppercase: that voice is already spoken
 * one level up by the group title, and two of them stacked flattens the
 * hierarchy instead of showing it. */
.sysmgmt-subhead {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: 0.4rem;
    padding: 0 var(--portal-ios-gutter, 1rem) 0.3rem;
    margin-top: 1rem;
}

/* The first sub-head sits directly under the group title, which brought its
   own bottom margin. */
.sysmgmt-subhead:first-child {
    margin-top: 0;
}

.sysmgmt-subhead-label {
    font-size: var(--portal-text-sm);
    font-weight: var(--portal-weight-label);
    color: var(--portal-c-text);
}

/* ── Child switch (flag.parent) ───────────────────────────────────────
 * A channel row hanging off a master. The rule it expresses — effective =
 * master AND channel — used to live only in a paragraph under the card,
 * because all five rows rendered identically. The indent and the rule line
 * say it structurally; .is-parent-off dims the children while the master is
 * off WITHOUT disabling them, since you may want to set a channel before
 * turning the master back on. */
/* Doubled class on purpose. .portal-ios-row sets `padding` in portal-ios.css,
   which pages load AFTER the kit — so a single-class padding-left here loses
   the cascade at equal specificity and the indent silently does nothing while
   the rule line still draws, which reads as a stray vertical stroke rather
   than as a bug. */
.portal-ios-row.sysmgmt-flag-child {
    /* Indents past the row's own gutter; the rule line sits in the gap. The
       hairline separator is inset to match, so the whole child block reads as
       one branch rather than as rows that happen to start further right. */
    padding-left: calc(var(--portal-ios-gutter, 1rem) + 1.6rem);
    --portal-ios-sep-inset: calc(var(--portal-ios-gutter, 1rem) + 1.6rem);
}

.sysmgmt-flag-child::before {
    content: "";
    position: absolute;
    left: calc(var(--portal-ios-gutter, 1rem) + 0.55rem);
    top: 0;
    bottom: 0;
    border-left: 1px solid var(--portal-c-border);
}

/* Muted, not disabled: still clickable, visibly not in effect — so the TEXT
   dims and the switch does not. The switch used to dim too, at 0.45, which is
   below .portal-ios-row.is-disabled's 0.5: the most-dimmed control in the run
   was the one that still worked. 0.55 is .is-dim, "yours, but paused", which is
   what this row is. Twin of .sysmgmt-page-inherited one tab away, and the state
   the four Notifications channel switches enter the moment their master goes
   off — same screen, same fix. */
.sysmgmt-flag-child.is-parent-off .portal-ios-row-main {
    opacity: 0.55;
}

/* Quiet, but not so quiet it reads as decoration — on a flag run this is the
   polarity warning. Mono because it is a rule about values, not prose. */
.sysmgmt-subhead-note {
    font-family: var(--portal-font-mono, var(--bs-font-monospace));
    font-size: var(--portal-text-2xs);
    letter-spacing: var(--portal-tracking-caps);
    color: var(--portal-c-text-muted);
    opacity: 0.85;
}

/* The label IS the row's title, so it takes the family's title voice and adds
   only the pointer — tapping the text works the switch. */
.sysmgmt-flag-item .form-check-label {
    cursor: pointer;
    color: var(--portal-c-text);
}

/* .portal-ios-row-sub carries size and colour; this only tightens the
   measure, because flag help is a full sentence rather than a status. */
.sysmgmt-flag-help {
    line-height: 1.45;
}

/* Locked (SystemFlags `locked`): the switch is genuinely disabled, so
   .portal-switch:disabled already dims it and shows not-allowed. All that is
   left is the label, which the family gives `cursor: pointer` because tapping
   the text works the switch — on a locked row it works nothing, and a pointer
   over dead text is the cheapest kind of lie. */
.sysmgmt-flag-locked .form-check-label {
    cursor: default;
}

/* Revealed after toggling a flag the sidebar only re-reads on page load.
   align-self because the row's text block is a column flex container, whose
   default stretch would run this link the full width of the row. */
.sysmgmt-flag-reload {
    align-self: flex-start;
    padding: 0;
    font-size: var(--portal-text-xs);
    font-weight: var(--portal-weight-label);
    text-decoration: none;
}

.sysmgmt-flag-reload:hover,
.sysmgmt-flag-reload:focus-visible {
    text-decoration: underline;
}

/* Permission badges inside flag labels/buttons (Developer, NHRL Staff) */
.sysmgmt-perm-badge {
    font-size: var(--portal-text-xs);
    vertical-align: middle;
}

@media (max-width: 575.98px) {
    .sysmgmt-flag-item .form-check-label {
        font-size: var(--portal-text-md);
    }

    /* The rule line costs a third of the phone's remaining measure, so the
       child rows keep the indent and drop the gutter it was added to. */
    .portal-ios-row.sysmgmt-flag-child {
        padding-left: 1.5rem;
        --portal-ios-sep-inset: 1.5rem;
    }

    .sysmgmt-flag-child::before {
        left: 0.5rem;
    }
}

/* ── Responsive page header ───────────────────────────────────────────
 * Below tablet width the actions row drops under the title instead of
 * squeezing it; taps stay comfortable. */
@media (max-width: 767.98px) {
    .portal-page-header {
        flex-direction: column;
        align-items: stretch;
        gap: var(--portal-space-2, 0.5rem);
    }

    .portal-page-header-actions {
        justify-content: flex-start;
        /* Belt to the min-width braces above: stacked, the actions row must
           never exceed the header it stretched to — whatever its content
           thinks its min-content is. The tabs strip scrolls inside it. */
        max-width: 100%;
        width: 100%;
    }
}

/* ── Launcher grid ─────────────────────────────────────────────────
 * tools/_tool-grid.twig — the LAUNCHPAD (the palette's browse grid, the
 * portal's one navigation surface since v3.1.1) and the /tools + home
 * launcher pages, all the same partial. Kit CSS because the palette is on
 * every page; this block lived in page CSS (tools.css) while the grid was
 * a page. Cards are the tap targets, so they stay generous on touch. */

/* The pack. A 12-column Bootstrap row could only step at four fixed
 * breakpoints, so above 1200px every launcher drew four ~340px cards and
 * left the rest of the measure empty — on /tools, on the home page, and in
 * the ⌘K panel, all three fed by the same partial. auto-fill takes the
 * column count from the measure instead: six across a desktop, one on a
 * phone, and whatever the panel affords in between, with no breakpoint to
 * keep in step with three call sites.
 *
 * The minimum is the real decision. 13.5rem is the widest registry label
 * ("Judges Interface (Legacy)") on two lines beside a 40px badge — narrower
 * and the labels start wrapping to three; wider and a 1440px display loses a
 * whole column. Tiles that carry a second line take --wide instead (the
 * palette's Now group), which is the same grid with more measure per cell.
 *
 * Gap, not gutter: .row's g-3 is a negative-margin trick that overhangs its
 * parent by half a gutter on each side, which is why every host of this
 * partial needed padding to absorb it. */
.tools-card-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(var(--tools-card-min, 13.5rem), 1fr));
    gap: var(--portal-space-2, 0.5rem);
}

/* Tiles that carry a SECOND LINE — the palette's Now group (a live number
 * under the name) and the documentation hub (a one-line description). Both
 * need measure the default does not have to spend: "34 of 68 competitor…" is
 * a tile that has forgotten what it is for, and a docs tile whose subtitle
 * ellipsises is a tile you have to click to read.
 *
 * 20rem is the widest of the two asks, and it costs nothing: inside the ⌘K
 * panel it still packs three across, exactly as 17rem did. */
.tools-card-grid--wide {
    --tools-card-min: 20rem;
}

/* /tools, where every card also carries a pin star. The star reserves 52px
 * of the card's measure (.tools-card-pinnable below) and the label pays for
 * it: at the default minimum "Tournament Buddy" broke mid-word. Buy the
 * 52px back rather than letting the labels wrap — this page is a directory
 * you read, and a hyphenated tool name is a slower read than one more row. */
.tools-card-grid--pinnable {
    --tools-card-min: 15.5rem;
}

/* Below sm the pack is one column whatever the minimum says (a 320px phone
 * would otherwise force a horizontal overflow rather than a second row). */
@media (max-width: 575.98px) {
    .tools-card-grid {
        grid-template-columns: 1fr;
        gap: 0.625rem;
    }

    .tools-page .portal-page-header-main {
        align-items: flex-start;
    }

    .tools-page .portal-page-title {
        font-size: var(--portal-text-lg, 1.05rem);
    }

    .tools-page .portal-page-subtitle {
        font-size: var(--portal-text-xs, 0.75rem);
        line-height: var(--portal-leading-snug, 1.3);
    }

    .tools-page .portal-page-header-actions,
    .tools-page .portal-filter-search {
        width: 100%;
    }

    .tools-page .tools-card {
        min-height: 3.5rem;
        padding: 0.625rem 0.75rem;
    }

    /* Keep the pin lane after the phone density rule resets card padding.
       Without this equally scoped override, the shorthand above erases the
       desktop reservation and labels, WIP badges and external-link glyphs all
       run underneath the absolutely positioned star. */
    .tools-page .tools-card-pinnable {
        padding-right: 3.25rem;
    }

    .tools-page .tools-card-label {
        font-size: var(--portal-text-md, 0.95rem);
    }
}

/* Sections stack tighter than mb-4 now that the rows pack: the space between
 * two category labels was tuned when a category was one or two tall rows. */
.tools-category + .tools-category {
    margin-top: var(--portal-space-4, 1rem);
}

.tools-card {
    display: flex;
    flex-direction: row;
    align-items: center;
    gap: var(--portal-space-2, 0.5rem);
    padding: var(--portal-space-2, 0.5rem) var(--portal-space-3, 0.75rem);
    /* 3rem = 48px, comfortably over the 44px touch floor. It was 3.5rem when
     * a row held four cards; at six across, height is the other axis of the
     * same "fit more on the screen" retune. */
    min-height: 3rem;
    color: var(--portal-c-text);
    text-decoration: none;
}

/* ── See-through launcher cards ────────────────────────────────────────
 * The card family's surface sits at 0.72 alpha, which over the aurora reads
 * as a solid plate. These take the SYSTEM BAR's tint instead — the glass the
 * chrome is made of, ~0.2 in dark and 0.85 in light — so the canvas comes
 * through them the way it comes through the bar at the bottom of the page.
 *
 * A token re-point, not a background declaration, and that is the whole
 * trick: .card paints --portal-c-surface, and --portal-c-surface-seethrough
 * is a rung of the same ladder (portal-components.css) that degrades with
 * it — so all four gates at the end of that file (can't blur / reduced
 * transparency / ?glass=off / the parse floor) land here, AND a theme that
 * redefines the token set gets a say. That second half is not theoretical:
 * this rule named the chrome tint directly for an afternoon, and Matrix —
 * which knows nothing about the chrome — served its cards in dark theme's
 * purple-grey over a green portal. The edge bundle is deliberately NOT
 * swapped: the hover lift composes --portal-c-edge-card-raised over the
 * resting bundle, and that gesture is the kit's, not this surface's.
 *
 * No backdrop-filter, and that is a decision rather than an omission. There
 * are thirty-odd of these on a screen and the same partial paints the ⌘K
 * panel on every page — thirty compositing layers and thirty per-frame GPU
 * passes is precisely the venue hardware the blur budget exists to protect
 * (BlurBudgetTest). Nothing scrolls behind a launcher card either: its
 * backdrop is a smooth gradient, and a blurred gradient is the same
 * gradient. The translucency is the whole effect; the blur would only be
 * the bill. */
.tools-card-grid {
    --portal-c-surface: var(--portal-c-surface-seethrough);
}

/* The resting shadow, restated only to carry a TRANSPARENT wash layer that
   :hover below fades in. box-shadow interpolates layer-for-layer and falls
   back to a discrete swap when the counts differ, so the placeholder is what
   buys the fade — the same reason the hover rule can't just append a layer.
   (background-image was the obvious home for the wash and it does preserve
   the surface, but gradients don't interpolate at all: measured, it snapped
   to full strength inside 40ms while the lift was still travelling.)
   Weight matters: `.portal-page-themed .card` sets this at (0,2,0), so a bare
   `.tools-card` would lose the tie and the rule would do nothing. */
.portal-page-themed .tools-card {
    box-shadow: var(--portal-c-edge-card), inset 0 0 0 100px transparent, var(--portal-c-shadow);
}

/* Wash only — no accent rim. A launcher card is a row that happens to be tall,
 * and rows answer with background ("Interactive row states — background only"
 * on --portal-c-row-hover). The teal border drew a lit box around whichever
 * card the pointer crossed, which on a 30-card grid is the loudest thing on
 * the page. The sidebar this page annexes has always hovered with a wash and
 * nothing else; --portal-c-tab-hover is that wash, and it has to carry the
 * whole affordance now, so it takes the tappable-surface strength rather than
 * the fainter table-row one.
 *
 * :focus-visible keeps the global 2px accent ring from portal-theme.css — it
 * was never what this rule was overriding. */
.tools-card:hover,
/* Hover, press and the lift are the KIT's now (.portal-hoverable,
   portal-motion.css). This page had its own copy — the same -2px, the same
   composed shadow, the same inset wash — written here because the utility only
   worked on .portal-stat-card at the time. It works on any card now, so the
   copy is gone: a second implementation of the one sanctioned lift is a
   second thing to retune, and the retune always reaches only one of them.

   The wash it pioneered was right and went upstream with it, as
   --portal-c-surface-lift. */
.tools-card:focus-visible {
    color: var(--portal-c-text);
    text-decoration: none;
}

/* The icon chip is a fixed 40px square, not a thing to squeeze. Flex shrinks
   every item in proportion to its base size, so a card whose content is wider
   than the card takes the overflow out of the ICON as well as the text — 40px
   chips measured 22px on a 375px phone. Nothing on /tools is long enough to
   trigger it, which is why it sat here unnoticed; the documentation hub, the
   first surface to use .tools-card-sub below, is. */
.tools-card > .portal-icon-badge {
    flex-shrink: 0;
}

.tools-card-label {
    font-weight: var(--portal-weight-label);
    font-size: var(--portal-text-sm, 0.85rem);
    min-width: 0;
}

/* A tile with something to say under its name. The label alone is a flex
   child; when a second line joins it they stack together, so the pair keeps
   the icon's vertical centring instead of pushing it off. */
.tools-card-text {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    min-width: 0;
}

.tools-card-sub {
    font-size: var(--portal-text-2xs, 0.6875rem);
    color: var(--portal-c-text-muted);
    line-height: var(--portal-leading-snug, 1.3);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.tools-card-ext {
    margin-left: auto;
    font-size: var(--portal-text-xs);
    color: var(--portal-c-text-muted);
}

/* Registry badges on a launcher card — the sidebar's old duty (WIP, the
   pending-registrations count). */
.tools-card-badge {
    margin-left: auto;
    padding: 0.05rem 0.35rem;
    border-radius: 999px;
    font-size: var(--portal-label-size-sm);
    font-weight: var(--portal-weight-strong);
    letter-spacing: var(--portal-label-tracking);
    text-transform: uppercase;
    background: var(--portal-c-badge-muted-bg, rgba(255, 255, 255, 0.1));
    color: var(--portal-c-badge-muted-color, inherit);
    flex-shrink: 0;
}

/* A badge and the new-tab glyph can share a card edge. */
.tools-card-badge + .tools-card-ext {
    margin-left: 0.4rem;
}

/* Star (pin-to-launcher) toggle — /tools only (showPin). The wrapper, rather
 * than the link, is the position context: interactive content cannot be nested
 * inside an <a>, and keeping the two as siblings gives the external-link glyph
 * and the pin two genuinely separate trailing lanes on every viewport. */
.tools-card-wrap {
    position: relative;
    min-width: 0;
}

.tools-card-wrap > .tools-card {
    height: 100%;
}

.tools-card-pinnable {
    padding-right: 3.25rem; /* reserve the pin's 40px lane plus its inset */
}

.tools-pin {
    position: absolute;
    top: 50%;
    right: var(--portal-space-2, 0.5rem);
    transform: translateY(-50%);
    width: 2.5rem;
    height: 2.5rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border: 0;
    border-radius: var(--bs-border-radius);
    background: transparent;
    color: var(--portal-c-text-muted);
    font-size: var(--portal-text-sm);
    cursor: pointer;
}

.tools-pin:hover,
.tools-pin:focus-visible {
    color: var(--portal-c-text);
    background-color: var(--portal-c-row-hover);
}

.tools-pin.is-pinned {
    color: var(--portal-c-accent);
}

/* The category label used to be redefined here, scoped to .tools-category, as a
 * near-copy of .portal-ios-group-title. It drifted (lighter, wider-tracked,
 * further from its grid) and two other pages used the class name where this
 * file never loads. portal-ios.css owns .portal-section-label now. */

/* ── Shortcuts HUD (keymap.js) ─────────────────────────────────────────
 * Layout only. The panel is a stock Bootstrap modal, so portal-components.css
 * already owns its material (overlay glass, edge light, elevation) and its
 * header — this file just arranges the rows inside the body. Nothing here
 * blurs: adding a second blurred surface for a panel that is mutually
 * exclusive with the palette would spend the venue's GPU budget twice for
 * one visible sheet. */
.portal-keymap-body {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
    gap: var(--portal-space-5) var(--portal-space-6);
    max-height: min(70vh, 34rem);
    overflow-y: auto;
}

.portal-keymap-group { min-width: 0; }

.portal-keymap-row {
    display: flex;
    align-items: baseline;
    gap: var(--portal-space-3);
    padding: 0.2rem 0;
    font-size: var(--portal-text-sm);
}

/* One column for the caps so the labels line up down the group — the map is
 * scanned for a key, not read as prose. */
.portal-keymap-caps {
    display: inline-flex;
    gap: 0.15rem;
    flex: 0 0 auto;
    min-width: 6rem;
}

.portal-keymap-label {
    color: var(--portal-c-text);
    min-width: 0;
}

.portal-keymap-empty {
    margin: 0;
    color: var(--portal-c-text-muted);
    font-size: var(--portal-text-sm);
}

/* ── Surface switcher (switcher.js) ────────────────────────────────────
 * The HUD behind ⌥⇥ (Mac) / ⌥` (Windows) and the bar's switcher button: the
 * pages you have actually been on, pre-selected on the previous one, so a
 * flick is a bounce and a longer hold is a choice.
 *
 * It takes the OVERLAY tier — the same glass as the ⌘K panel, read from the
 * same two tokens. This block used to argue the opposite at length, and the
 * argument was half right: a compositing pass and a per-frame backdrop sample
 * are a poor trade for a ~200ms flick. But ⌥⇥ is only one of its two doors.
 * The bar's switcher button opens the same HUD and LEAVES it open until you
 * pick something, which is a persistent topmost overlay by any measure — and
 * the app already spends this exact tier on every dropdown for the same
 * reason. One overlay at a time, by construction, so the budget is unchanged
 * in the only dimension it measures; BlurBudgetTest carries the entry.
 *
 * No scrim, though — that half of the old note stands. macOS's own switcher
 * does not dim the desktop, and a full-viewport dimmer is the largest painted
 * area in the app. The elevation does the separating. */
.portal-switcher {
    position: fixed;
    inset: 0;
    z-index: var(--portal-z-modal, 1055);
    display: flex;
    align-items: center;
    justify-content: center;
    padding: var(--portal-space-5);
    /* The HUD is a picture of a decision, not a surface to interact with by
       pointer except on its tiles — but a click anywhere still dismisses, so
       the field has to receive it. */
    pointer-events: auto;
}

.portal-switcher[hidden] { display: none; }

.portal-switcher-panel {
    display: flex;
    flex-direction: column;
    gap: var(--portal-space-3);
    max-width: min(46rem, 100%);
    padding: var(--portal-space-4);
    border-radius: var(--bs-border-radius-xl, 0.875rem);
    border: 1px solid var(--portal-c-border);
    background: var(--portal-c-overlay);
    backdrop-filter: var(--portal-blur-overlay);
    -webkit-backdrop-filter: var(--portal-blur-overlay);
    /* Same cold-start fix as the palette and the generic modal: build the
       blur while the panel is still transparent, before the fade starts.
       It matters more here than anywhere — the fade is dur-0 (80ms), so a
       blur that arrives a beat late arrives after the animation is over. */
    will-change: backdrop-filter;
    box-shadow: var(--portal-c-edge-dialog), var(--portal-c-shadow-raised);
    animation: portal-fade-in var(--portal-dur-0, 80ms) var(--portal-ease-out, ease-out) both;
}

/* One row, scrolled rather than wrapped: the trail is ordered, and a second
   line puts "four pages ago" above "one page ago" on the screen. */
.portal-switcher-tiles {
    display: flex;
    flex-wrap: nowrap;
    gap: var(--portal-space-2);
    overflow-x: auto;
    outline: 0;
}

.portal-switcher-tile {
    display: flex;
    flex-direction: column;
    align-items: center;
    /* flex-start, not centre: the labels are one line or two ("Mission
       Control", "Event Check-In") and centring a taller content block inside
       a stretched box lifts that tile's ICON above its neighbours'. The icons
       are what the eye tracks while cycling, so they align and the label slot
       below absorbs the difference. */
    justify-content: flex-start;
    gap: var(--portal-space-2);
    flex: 0 0 auto;
    width: 6.5rem;
    padding: var(--portal-space-3) var(--portal-space-2);
    border: 0;
    border-radius: var(--bs-border-radius-lg, 0.625rem);
    background: transparent;
    color: var(--portal-c-text-muted);
    text-align: center;
    transition: background-color var(--portal-dur-1, 120ms) var(--portal-ease-out, ease),
                color var(--portal-dur-1, 120ms) var(--portal-ease-out, ease);
}

/* The tile wears the registry's own icon badge, UNRESIZED, so the switcher
   shows the portal's actual app icons rather than grey glyphs — same chip,
   same tint, same 2.5rem as the Launchpad tile it came from. Half the icon
   set is clipboards and documents, and the tint is what tells them apart at
   the speed this thing is used. Nothing to declare but the flex behaviour:
   a tile is a flex column and the badge must not be squashed by a long name. */
.portal-switcher-tile .portal-icon-badge {
    flex-shrink: 0;
}

.portal-switcher-label {
    font-size: var(--portal-text-sm, 0.8125rem);
    line-height: 1.2;
    /* Two lines, then ellipsis: "Competitor Management" must not push the
       row's height around as you cycle past it. The slot is exactly two lines
       tall whether or not the name needs both, so every tile is the same
       shape and nothing shifts under the selection as it moves. */
    height: 2.4em;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

/* Selection is the only colour in the panel, because there is exactly one
   thing to know: which tile releasing the key will open. */
.portal-switcher-tile.is-selected {
    background: var(--portal-c-accent-muted);
    color: var(--portal-c-text);
    box-shadow: inset 0 0 0 1px var(--portal-c-accent-border);
}

.portal-switcher-tile:hover {
    color: var(--portal-c-text);
}

.portal-switcher-hint {
    margin: 0;
    text-align: center;
    font-size: var(--portal-text-xs, 0.75rem);
    color: var(--portal-c-text-muted);
}

.portal-switcher-hint kbd {
    font-size: inherit;
}

@media (prefers-reduced-motion: reduce) {
    .portal-switcher-panel { animation: none; }

    .portal-switcher-tile { transition: none; }
}

/* ═══ Broadcast data displays ═════════════════════════════════════════════
   Tale of the tape, head-to-head and bracket path. BrettZone has all three
   and brettzone-kit.css:478 records them as deliberately not ported, because
   the port carries the corner cut and DIN Next into every surface that uses
   it. These are the same three displays written in the kit's own vocabulary
   instead — Inter, portal chips, no ornament — which is the cheaper half of
   that trade: portal-sport-theme.css already restyles kit components
   generically, so under the sport theme these arrive wearing BrettZone's
   finish without a single rule here knowing that theme exists.

   All three are markup-only. They are the neutral half of a Track 2 gap;
   the loud half is weight colour above, and it is opt-in via data-w. */

/* ── Tale of the tape ─────────────────────────────────────────────────────
   A comparison, so it is a real table: label in the middle, one competitor
   either side. The middle column is the row header, which is why it is a
   <th scope="row"> — a screen reader then reads "Record, 12-3, 9-6" rather
   than three loose numbers. */
.portal-tape {
    width: 100%;
    border-collapse: collapse;
}

.portal-tape caption {
    padding: 0 0 0.6rem;
    color: var(--portal-c-text);
    font-size: var(--portal-text-md);
    font-weight: var(--portal-weight-strong);
    text-align: center;
    caption-side: top;
}

.portal-tape th,
.portal-tape td {
    padding: 0.45rem 0.6rem;
    border-top: 1px solid var(--portal-c-border-subtle);
    vertical-align: middle;
}

.portal-tape tr:first-child th,
.portal-tape tr:first-child td { border-top: 0; }

/* The middle column is the label, not a value: quiet, centred, and the only
   thing that is not a numeral. */
.portal-tape th[scope="row"] {
    width: 34%;
    color: var(--portal-c-text-muted);
    font-size: var(--portal-label-size);
    font-weight: var(--portal-weight-label);
    letter-spacing: 0.06em;
    text-align: center;
    text-transform: uppercase;
}

.portal-tape td {
    width: 33%;
    color: var(--portal-c-text);
    font-family: var(--portal-font-mono);
    font-size: var(--portal-text-md);
    font-variant-numeric: tabular-nums;
}

.portal-tape td:first-child { text-align: right; }
.portal-tape td:last-child { text-align: left; }

/* Which side won the row. Weight, not colour — a tale of the tape is read at
   a glance and a dozen green numbers is not a glance. */
.portal-tape td[data-better] { font-weight: var(--portal-weight-strong); }
.portal-tape td:not([data-better]) { color: var(--portal-c-text-muted); }

/* The competitor names above the rows. */
.portal-tape thead th:not([scope="row"]) {
    color: var(--portal-c-text);
    font-size: var(--portal-text-sm);
    font-weight: var(--portal-weight-strong);
}

.portal-tape thead th:first-child { text-align: right; }
.portal-tape thead th:last-child { text-align: left; }

/* ── Head-to-head ─────────────────────────────────────────────────────────
   The record between two bots: a tally, a split bar, and the meetings. */
.portal-h2h {
    display: flex;
    flex-direction: column;
    gap: 0.6rem;
}

.portal-h2h-tally {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 0.75rem;
}

.portal-h2h-score {
    color: var(--portal-c-text);
    font-family: var(--portal-font-mono);
    font-size: var(--portal-text-2xl);
    font-variant-numeric: tabular-nums;
    font-weight: var(--portal-weight-strong);
    line-height: 1;
}

.portal-h2h-label {
    color: var(--portal-c-text-muted);
    font-size: var(--portal-label-size);
    letter-spacing: 0.06em;
    text-align: center;
    text-transform: uppercase;
}

/* One bar, two shares. --portal-h2h-share is the LEFT competitor's, set
   inline because it is data; the remainder paints the right. A zero-fight
   pairing leaves the track visible rather than collapsing to nothing. */
.portal-h2h-bar {
    display: flex;
    overflow: hidden;
    height: 6px;
    border-radius: var(--bs-border-radius-sm);
    background: var(--portal-c-surface-sunken);
}

.portal-h2h-bar::before,
.portal-h2h-bar::after {
    content: "";
    display: block;
}

.portal-h2h-bar::before {
    width: var(--portal-h2h-share, 50%);
    background: var(--portal-c-corner-pink);
}

.portal-h2h-bar::after {
    flex: 1 1 auto;
    background: var(--portal-c-corner-blue);
}

.portal-h2h-log {
    margin: 0;
    padding: 0;
    list-style: none;
}

.portal-h2h-log li {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    padding: 0.35rem 0;
    border-top: 1px solid var(--portal-c-border-subtle);
    color: var(--portal-c-text-muted);
    font-size: var(--portal-text-sm);
}

.portal-h2h-log li > :first-child {
    flex: 1 1 auto;
    min-width: 0;
    color: var(--portal-c-text);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.portal-h2h-log time {
    flex: 0 0 auto;
    font-family: var(--portal-font-mono);
    font-size: var(--portal-text-xs);
    font-variant-numeric: tabular-nums;
}

/* ── Bracket path ─────────────────────────────────────────────────────────
   A bot's route through a bracket, round by round. An ordered list, because
   the order is the meaning; the rail is drawn from the marker column so a
   step can be any height without the line breaking. */
.portal-path {
    margin: 0;
    padding: 0 0 0 1.4rem;
    list-style: none;
}

.portal-path-step {
    position: relative;
    padding: 0 0 0.9rem 0.25rem;
}

.portal-path-step:last-child { padding-bottom: 0; }

/* The rail, and the node that sits on it. */
.portal-path-step::before {
    content: "";
    position: absolute;
    top: 0.45rem;
    bottom: -0.45rem;
    left: -0.9rem;
    width: 1px;
    background: var(--portal-c-border-subtle);
}

.portal-path-step:last-child::before { display: none; }

.portal-path-step::after {
    content: "";
    position: absolute;
    top: 0.3rem;
    left: -1.15rem;
    width: 0.55rem;
    height: 0.55rem;
    border: 1px solid var(--portal-c-border);
    border-radius: 50%;
    background: var(--portal-c-surface);
}

.portal-path-step[data-result="win"]::after {
    border-color: var(--portal-c-badge-success-color);
    background: var(--portal-c-badge-success-color);
}

.portal-path-step[data-result="loss"]::after {
    border-color: var(--portal-c-badge-danger-color);
    background: var(--portal-c-badge-danger-color);
}

.portal-path-round {
    display: block;
    color: var(--portal-c-text-muted);
    font-size: var(--portal-label-size);
    letter-spacing: 0.06em;
    text-transform: uppercase;
}

.portal-path-bout {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 0.45rem;
    color: var(--portal-c-text);
    font-size: var(--portal-text-sm);
}

/* The step a bot has not fought yet: present, so the path shows how far the
   bracket goes, but visibly not a result. */
.portal-path-step[data-result="pending"] .portal-path-bout { color: var(--portal-c-text-muted); }
.portal-path-step[data-result="pending"]::after { background: var(--portal-c-surface-sunken); }

/* ═══ Board ════════════════════════════════════════════════════════════════
   A wall of like things you watch at once — cages, devices, encoders — with a
   filter row and a search above it. Modelled on Match Interpreter's home-page
   cage board (havocManager app/mi/css/sport.css, .mi-board-* and .cage-card),
   and written the way 3.15.0 wrote the broadcast displays: the STRUCTURE here,
   in the kit's own vocabulary, and the MI finish in portal-sport-theme.css.
   Nothing in this block knows the sport theme exists.

   Markup-only. Filtering is page-owned: the page counts, the page hides cards
   (the `hidden` attribute — reboot's [hidden] beats the display below), and
   the page moves aria-pressed. The kit styles the state from aria-pressed
   ALONE, so the attribute a screen reader hears and the one that paints are
   the same attribute — the lesson of 3.12.1's switch.

     .portal-board-toolbar          filters on the left, search on the right
       .portal-board-filters        role="group" + aria-label
         .portal-board-filter       <button aria-pressed>, label + count
           .portal-board-filter-count
       .portal-filter-search        the kit's existing search field, reused
     .portal-board-grid             up to 4 columns, down to 1
       .portal-board-card          [.is-live | .is-attention | .is-dormant]
         .portal-board-card-head
           .portal-board-card-title (h2/h3 or a link) [.portal-board-card-num]
           .portal-board-card-meta   status badge, live dot, count, buttons
         .portal-board-card-body    content; goes flush when it holds rows
         .portal-board-card-foot    actions, pinned to the bottom
   ═════════════════════════════════════════════════════════════════════════ */

.portal-board-toolbar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: var(--portal-space-2) var(--portal-space-3);
    margin-bottom: var(--portal-space-3);
}

.portal-board-filters {
    display: flex;
    flex-wrap: wrap;
    gap: var(--portal-space-1);
}

/* A filter is a button with a label and a number. Not .portal-segmented: that
   is a track with a pill in it, one control; a board's filters are separate
   buttons whose counts change under you, and on a phone they need to wrap
   rather than squeeze into one track. The ACTIVE voice is the segmented one,
   though — the same tab tokens — so in dark, light and matrix a chosen filter
   reads exactly like a chosen segment. */
.portal-board-filter {
    display: inline-flex;
    align-items: center;
    gap: var(--portal-space-2);
    min-height: var(--portal-control-h);
    padding: 0.25rem 0.75rem;
    border: 1px solid transparent;
    border-radius: var(--bs-border-radius);
    background: transparent;
    color: var(--portal-c-tab-inactive-color);
    font-size: var(--portal-text-sm);
    font-weight: var(--portal-weight-label);
    line-height: 1.2;
    white-space: nowrap;
    cursor: pointer;
    transition: color var(--portal-dur-1) var(--portal-ease-out),
                background-color var(--portal-dur-1) var(--portal-ease-out),
                box-shadow var(--portal-dur-1) var(--portal-ease-out);
}

/* The count is data, so it is mono and tabular: "Offline 1" becoming
   "Offline 12" must not shove the buttons after it sideways by a glyph. */
.portal-board-filter-count {
    color: var(--portal-c-text-muted);
    font-family: var(--portal-font-mono);
    font-size: var(--portal-text-xs);
    font-variant-numeric: tabular-nums;
}

.portal-board-filter:hover:not(:disabled):not([aria-pressed="true"]) {
    color: var(--portal-c-tab-active-color);
    background: var(--portal-c-tab-hover);
}

.portal-board-filter[aria-pressed="true"] {
    color: var(--portal-c-tab-active-color);
    background: var(--portal-c-tab-active-bg);
    border-color: var(--portal-c-tab-active-border);
    box-shadow: var(--portal-c-tab-active-shadow);
}

.portal-board-filter[aria-pressed="true"] .portal-board-filter-count { color: inherit; }

.portal-board-filter:focus-visible { outline: 2px solid var(--portal-c-accent); outline-offset: -2px; }

.portal-board-filter:disabled { opacity: 0.5; cursor: default; }

/* ── The grid ─────────────────────────────────────────────────────────────
   Intrinsic, not breakpoint-keyed: a board sits in a full-width dashboard
   downstream and beside a rail here, and it is the BOARD's width that decides
   how many cages fit, not the window's. Each column is at least
   --portal-board-min and at least a 1/--portal-board-cols share, so the grid
   tops out at four columns and drops a column whenever a card would go under
   the floor — down to one on a phone. min(100%, …) is the floor that keeps a
   single column from overflowing a container narrower than the minimum.

   auto-fill, not auto-fit: three cages on a wide screen stay a quarter wide
   each with an empty slot, which is MI's behaviour and the honest one — a
   filter that hides five cards should not balloon the other two. */
.portal-board-grid {
    --portal-board-min: 15rem;
    --portal-board-cols: 4;
    --portal-board-gap: var(--portal-space-4);
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(min(100%, max(var(--portal-board-min), (100% - (var(--portal-board-cols) - 1) * var(--portal-board-gap)) / var(--portal-board-cols))), 1fr));
    gap: var(--portal-board-gap);
}

/* ── The card ─────────────────────────────────────────────────────────────
   The list material (.portal-ios-list's surface, sheen, keyline and shadow),
   because a board card is a list that happens to have a header — and
   deliberately NOT a .card: `.portal-page-themed .card` out-specifies any
   single-class rule, so a board card wearing `card` would lose every state
   below to the themed card surface.

   No overflow: hidden. A card routinely hosts a dropdown or a Tom Select menu
   in its meta slot and clipping would amputate it; rows inside the body round
   their own corners instead (see the flush body below).

   --portal-board-cap is the state stripe along the top edge. An INSET shadow
   rather than a border-top: MI's 3px border pushes the live card's content
   2px lower than its neighbours, and a board is read across a row. */
.portal-board-card {
    --portal-board-cap: transparent;
    display: flex;
    flex-direction: column;
    min-width: 0;
    background-color: var(--portal-c-surface);
    background-image: var(--portal-c-surface-sheen);
    border: 1px solid var(--portal-c-border);
    border-radius: var(--bs-border-radius-lg);
    box-shadow: inset 0 3px 0 var(--portal-board-cap), var(--portal-c-edge-card), var(--portal-c-shadow);
    color: var(--portal-c-text);
}

/* Live is on the air. The badge-live token is the accent in dark and light
   and BrettZone's live red under sport, so this one declaration is MI's
   `.cage-card.is-live { border-top: 3px solid var(--bz-live) }` in sport and
   an ordinary accent cap everywhere else. */
.portal-board-card.is-live { --portal-board-cap: var(--portal-c-badge-live-color); }

/* Attention is "somebody should look at this" — a device offline, a cage
   stuck. Warning, not danger: a board that turns red is a board that is
   ignored by the second event. */
.portal-board-card.is-attention { --portal-board-cap: var(--portal-c-badge-warning-color); }

/* Dormant is empty: a cage with no match, a slot with no device. It loses the
   surface rather than the contrast — MI's older 0.45 opacity also greyed the
   text a person still has to read — and takes the dashed keyline the kit
   already uses for "nothing here yet". */
.portal-board-card.is-dormant {
    border-style: dashed;
    background: transparent;
    box-shadow: none;
}

.portal-board-card.is-dormant .portal-board-card-title { color: var(--portal-c-text-muted); }

.portal-board-card-head {
    display: flex;
    align-items: center;
    gap: var(--portal-space-2);
    min-height: calc(var(--portal-control-h) + 1rem);
    padding: 0.5rem 1rem;
    border-bottom: 1px solid var(--portal-c-border-subtle);
}

.portal-board-card-title {
    display: flex;
    align-items: baseline;
    gap: var(--portal-space-2);
    min-width: 0;
    margin: 0;
    color: var(--portal-c-text);
    font-size: var(--portal-text-md);
    font-weight: var(--portal-weight-strong);
    line-height: 1.2;
    overflow-wrap: anywhere;
    text-decoration: none;
}

a.portal-board-card-title:hover { color: var(--portal-c-accent); }

/* The number a cage is known by, set as data. Optional. */
.portal-board-card-num {
    font-family: var(--portal-font-mono);
    font-size: var(--portal-text-xl);
    font-variant-numeric: tabular-nums;
    line-height: 1;
}

.portal-board-card-meta {
    display: flex;
    flex-shrink: 0;
    align-items: center;
    gap: var(--portal-space-2);
    margin-left: auto;
}

/* flex: 1 so every card in a grid row is as tall as the tallest and every
   foot lines up along the bottom — the grid stretches the cards, the body
   takes up the slack. */
.portal-board-card-body {
    display: flex;
    flex: 1 1 auto;
    flex-direction: column;
    gap: var(--portal-space-2);
    min-width: 0;
    padding: 0.875rem 1rem;
}

/* A body holding .portal-ios-rows goes flush, because rows carry their own
   gutter — the same split as .portal-ios-list vs .portal-ios-panel, without a
   modifier to remember. The last row rounds itself into the card's bottom
   corners when there is no foot below it, since the card does not clip. */
.portal-board-card-body:has(> .portal-ios-row) {
    gap: 0;
    padding: 0;
}

.portal-board-card-body:last-child > .portal-ios-row:last-child {
    border-radius: 0 0 calc(var(--bs-border-radius-lg) - 1px) calc(var(--bs-border-radius-lg) - 1px);
}

.portal-board-card-foot {
    display: flex;
    flex-wrap: wrap;
    gap: var(--portal-space-2);
    margin-top: auto;
    padding: 0.75rem 1rem;
    border-top: 1px solid var(--portal-c-border-subtle);
}

.portal-board-card-foot > .btn { flex: 1 1 auto; }

@media (max-width: 575.98px) {
    /* The filters take the row and share it; with four of them they wrap to
       two lines of two rather than scrolling one off the edge. */
    .portal-board-filters { width: 100%; }

    .portal-board-filter {
        flex: 1 1 auto;
        justify-content: center;
    }
}

@media (pointer: coarse) {
    .portal-board-filter { min-height: 44px; }

    a.portal-board-card-title {
        align-items: center;
        min-height: 44px;
    }
}

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