/*
 * Mtik UI layer on top of AdminLTE 4 / Bootstrap 5.3.
 *
 * Everything here must work in BOTH themes. Bootstrap 5.3 flips its own custom
 * properties when `data-bs-theme` changes on <html>, so the rule of thumb is:
 * build from --bs-* variables, never from a hard-coded light/dark value.
 *
 * The two exceptions are the .stat-card gradients and .btn-brand, which sit on a
 * saturated colour with white text - that pairing reads the same in either theme,
 * which is exactly why it is safe to hard-code.
 */

/* ---------------------------------------------------------------------------
 * Luxury shell — gilt accent, depth, and touch-friendly controls app-wide
 * ------------------------------------------------------------------------- */
:root {
    --mtik-gilt: #b89b6a;
    --mtik-gilt-soft: color-mix(in srgb, var(--mtik-gilt) 14%, transparent);
    --mtik-gilt-line: color-mix(in srgb, var(--mtik-gilt) 38%, var(--bs-border-color));
    --mtik-ink: #1c1a16;
    --mtik-panel-shadow: 0 0.55rem 1.35rem rgba(28, 24, 18, 0.07);
    --mtik-panel-shadow-hover: 0 0.75rem 1.75rem rgba(28, 24, 18, 0.11);
}

[data-bs-theme="dark"] {
    --mtik-panel-shadow: 0 0.65rem 1.5rem rgba(0, 0, 0, 0.42);
    --mtik-panel-shadow-hover: 0 0.85rem 1.85rem rgba(0, 0, 0, 0.52);
}

.mtik-app .app-header {
    border-bottom: 1px solid var(--mtik-gilt-line);
    box-shadow: 0 1px 0 rgba(255, 255, 255, 0.35);
    background:
        linear-gradient(180deg, color-mix(in srgb, var(--bs-body-bg) 92%, var(--mtik-gilt)), var(--bs-body-bg));
}

.mtik-app .mtk-page-back {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 2.5rem;
    min-height: 2.5rem;
    padding-inline: 0.55rem;
    line-height: 1;
}

.mtik-app .app-sidebar {
    border-right: 1px solid var(--mtik-gilt-line);
    background:
        linear-gradient(180deg, color-mix(in srgb, var(--bs-body-bg) 94%, var(--mtik-gilt)), var(--bs-body-bg));
}

.mtik-app .app-main {
    background:
        radial-gradient(ellipse 120% 80% at 50% -20%, var(--mtik-gilt-soft), transparent 55%),
        var(--bs-body-tertiary-bg, var(--bs-tertiary-bg));
}

.mtik-app .app-content-header {
    border-bottom: 1px solid color-mix(in srgb, var(--bs-border-color) 85%, var(--mtik-gilt));
}

.mtik-app .app-content > .container-fluid {
    padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
}

@media (max-width: 767.98px) {
    .mtik-app .app-content > .container-fluid {
        padding-left: max(0.75rem, env(safe-area-inset-left, 0px));
        padding-right: max(0.75rem, env(safe-area-inset-right, 0px));
    }
}

/* ---------------------------------------------------------------------------
 * The page itself never scrolls sideways
 *
 * On a phone the panel is a column of cards and a swipe left or right should do
 * nothing. Anything wider than the screen belongs inside its own scroller - that
 * is what .table-responsive is for, and a wide table is meant to be swiped within
 * its card - but a single control with a fixed minimum width, a long unbroken
 * voucher code or an over-wide chart used to drag the whole document across and
 * take the header and sidebar with it.
 *
 * `overflow-x: clip` rather than `hidden`: hidden turns the element into a scroll
 * container, which would break any sticky positioning inside it. Both are named so
 * an old WebView that has never heard of clip still gets the containment.
 * ------------------------------------------------------------------------- */
@media (max-width: 991.98px) {
    html,
    body {
        max-width: 100%;
        overflow-x: hidden;
        overflow-x: clip;
    }

    .app-wrapper,
    .app-main,
    .app-content,
    .app-content-header,
    .app-footer {
        max-width: 100%;
        min-width: 0;
    }

    /* A grid or flex child will not shrink below the width of its own content unless
     * it is told it may, which is how one wide card ends up widening the page. */
    .app-content > .container-fluid > .row > [class^="col"],
    .app-content > .container-fluid > .row > [class*=" col"] {
        min-width: 0;
    }

    /* Codes, MAC addresses, tunnel keys and e-mail addresses: no space to break at,
     * so they set the width of whatever card they land in. */
    .card-body,
    .card-header,
    .list-group-item {
        overflow-wrap: anywhere;
    }

    /* Inside a table the same rule would be wrong - the table has its own scroller
     * and a broken-up MAC address is harder to read than a swipe. */
    .table td,
    .table th {
        overflow-wrap: normal;
    }
}

/* ---------------------------------------------------------------------------
 * Theme-adaptive buttons
 *
 * `btn-outline-dark` is the trap: "dark" is near-black in both themes, so in dark
 * mode it renders black-on-black and the button effectively disappears. These use
 * --bs-emphasis-color, which flips to near-white in dark mode.
 * ------------------------------------------------------------------------- */
/* The app's secondary action: Back, Cancel, Clear, "All vouchers". Filled dark with
 * light ink, the same pairing in both themes, so it matches the row actions further
 * down this file rather than being the one pale control on a page of solid ones.
 *
 * It keeps the -outline- name it was born with because it is used in a hundred places
 * and the name is not what it looks like - what matters is that it is the quiet
 * neutral next to a coloured primary, which a slate fill is.
 *
 * Deliberately NOT applied to .btn-outline-secondary: that one is the *unselected*
 * half of the filter groups (All / Unused / In use), where being quieter than the
 * selected button is the entire job. */
.btn-outline-neutral,
.btn-outline-primary {
    --bs-btn-font-weight: 600;
    --bs-btn-color: #fff;
    --bs-btn-hover-color: #fff;
    --bs-btn-active-color: #fff;
    --bs-btn-disabled-color: rgba(255, 255, 255, .65);
    --bs-btn-focus-shadow-rgb: var(--bs-secondary-rgb);
}

.btn-outline-neutral {
    --bs-btn-bg: #343d47;
    --bs-btn-border-color: #343d47;
    --bs-btn-hover-bg: #434e5b;
    --bs-btn-hover-border-color: #434e5b;
    --bs-btn-active-bg: #232a32;
    --bs-btn-active-border-color: #232a32;
    --bs-btn-disabled-bg: #545e6b;
    --bs-btn-disabled-border-color: #545e6b;
}

[data-bs-theme="dark"] .btn-outline-neutral {
    --bs-btn-bg: #414b57;
    --bs-btn-border-color: #566270;
    --bs-btn-hover-bg: #4e5a68;
    --bs-btn-hover-border-color: #687584;
    --bs-btn-active-bg: #343d47;
    --bs-btn-active-border-color: #566270;
}

/* Search, Refresh, Copy - a page-level action that is not destructive and not the
 * page's single primary. Indigo, the same accent .btn-brand and the Edit row action
 * carry, so an accent means one thing across the app. */
.btn-outline-primary {
    --bs-btn-bg: #4338ca;
    --bs-btn-border-color: #4338ca;
    --bs-btn-hover-bg: #4f46e5;
    --bs-btn-hover-border-color: #4f46e5;
    --bs-btn-active-bg: #3730a3;
    --bs-btn-active-border-color: #3730a3;
    --bs-btn-disabled-bg: #7b74d8;
    --bs-btn-disabled-border-color: #7b74d8;
    --bs-btn-focus-shadow-rgb: 99, 102, 241;
}

[data-bs-theme="dark"] .btn-outline-primary {
    --bs-btn-bg: #4b41d6;
    --bs-btn-border-color: #6b63e0;
    --bs-btn-hover-bg: #574fe0;
    --bs-btn-hover-border-color: #8079e8;
    --bs-btn-active-bg: #3f36bd;
    --bs-btn-active-border-color: #6b63e0;
}

/* Filled accent button that keeps white text on a saturated fill in both themes. */
.btn-brand {
    --bs-btn-color: #fff;
    --bs-btn-bg: #4f46e5;
    --bs-btn-border-color: #4f46e5;
    --bs-btn-hover-color: #fff;
    --bs-btn-hover-bg: #4338ca;
    --bs-btn-hover-border-color: #4338ca;
    --bs-btn-active-color: #fff;
    --bs-btn-active-bg: #3730a3;
    --bs-btn-active-border-color: #3730a3;
    --bs-btn-focus-shadow-rgb: 99, 102, 241;
}

/* Neutral chip/badge. `text-bg-light` is white-on-white in dark mode. */
.badge-neutral {
    background-color: var(--bs-secondary-bg);
    color: var(--bs-emphasis-color);
    border: 1px solid var(--bs-border-color);
    font-weight: 500;
}

/* Low-key callout. `alert-light` washes out to near-invisible in dark mode. */
.alert-subtle {
    background-color: var(--bs-secondary-bg);
    border: 1px solid var(--bs-border-color);
    color: var(--bs-body-color);
}

/* ---------------------------------------------------------------------------
 * Page switcher
 *
 * Every set of sub-pages in the panel uses this: router Configure, the subscriber's
 * screens, the Landing Page screens, one's own profile, and the two ways of filling
 * in a voucher batch. One switcher everywhere, so "these are the pages of this
 * thing" looks the same wherever it appears.
 *
 * Bootstrap's `.nav-tabs` and `.nav-pills` draw an inactive tab as a plain blue link
 * on a flat strip, so a row of them sitting under a form reads as a line of body text
 * rather than as controls - operators were missing the switcher entirely. These are
 * solid buttons with a real border, and the selected one is filled, so which page you
 * are on is legible at a glance.
 *
 * Works on both a <button> (panes swapped in place) and an <a> (a page of its own);
 * `.active` is the current one either way.
 *
 * Built from --bs-* vars rather than `btn-dark`, per the note at the top of this
 * file: --bs-emphasis-color is near-black in light mode and near-white in dark, so
 * the filled state stays high-contrast instead of going black-on-black.
 * ------------------------------------------------------------------------- */
.page-switch {
    display: flex;
    flex-wrap: wrap;
    gap: .4rem;
    border-bottom: 0;
    margin-bottom: .85rem;
}

.page-switch .nav-link {
    display: flex;
    align-items: center;
    gap: .35rem;
    padding: .45rem .85rem;
    border: 1px solid var(--bs-border-color);
    border-radius: .5rem;
    background-color: var(--bs-secondary-bg);
    color: var(--bs-emphasis-color);
    font-size: .875rem;
    font-weight: 600;
    line-height: 1.25;
    transition: background-color .15s ease-in-out, border-color .15s ease-in-out, color .15s ease-in-out;
}

.page-switch .nav-link:hover,
.page-switch .nav-link:focus-visible {
    background-color: var(--bs-tertiary-bg);
    border-color: var(--bs-emphasis-color);
    color: var(--bs-emphasis-color);
}

.page-switch .nav-link.active {
    background-color: var(--bs-emphasis-color);
    border-color: var(--bs-emphasis-color);
    color: var(--bs-body-bg);
}

/* The switcher is detached from the panel below it, so the panel needs its own
 * full border rather than the "tabs sit on top" three-sided one. */
.page-switch-content {
    border: 1px solid var(--bs-border-color);
    border-radius: .5rem;
}

/* ---------------------------------------------------------------------------
 * Server health meters (Server Setup)
 *
 * The severity colours are the same three the status chips use, so "green is fine,
 * amber is watch it, red is act" means one thing across the whole app. They are
 * stated as rgb triples rather than pulled from --bs-success/-warning/-danger
 * because the fill needs a low-alpha wash of the same hue behind solid body text,
 * and Bootstrap's variables are already-composited colours with no alpha to give.
 * ------------------------------------------------------------------------- */
.health-meter {
    --health-rgb: 25, 135, 84;
    height: 100%;
    padding: .75rem .85rem;
    border: 1px solid var(--bs-border-color);
    border-left: 3px solid rgb(var(--health-rgb));
    border-radius: .5rem;
    background-color: var(--bs-secondary-bg);
}

.health-meter.health-warn { --health-rgb: 255, 193, 7; }
.health-meter.health-danger { --health-rgb: 220, 53, 69; }

.health-meter .bi { color: rgb(var(--health-rgb)); }

.health-value {
    font-variant-numeric: tabular-nums;
    font-weight: 700;
}

.health-bar {
    display: block;
    height: .5rem;
    margin-top: .5rem;
    border-radius: 999px;
    /* Track: a wash of the meter's own colour, so an empty bar still reads as its state. */
    background-color: rgba(var(--health-rgb), .18);
    overflow: hidden;
}

.health-bar > span {
    display: block;
    height: 100%;
    border-radius: inherit;
    background-color: rgb(var(--health-rgb));
    transition: width .4s ease-in-out;
}

/* A reading that jumps every few seconds should not animate for users who have asked
 * the system to stop moving things. */
@media (prefers-reduced-motion: reduce) {
    .health-bar > span { transition: none; }
}

/* ---------------------------------------------------------------------------
 * Status chips
 *
 * Every "what state is this in?" cell in the app. This exists because Bootstrap's
 * `.badge` defaults to `--bs-badge-color: #fff` with NO background of its own: any
 * status value that fell through a @class([...]) list rendered as white text on a
 * white card - present in the DOM, invisible on screen. A chip always carries its
 * own ink AND tint, so an unrecognised status degrades to the readable neutral
 * instead of disappearing.
 *
 * Ink/tint pairs are ink-on-tint rather than white-on-saturated: at badge size a
 * tinted chip is easier to read and far quieter in a dense table.
 *
 * Both themes run deep on purpose - the first pass was too pale to read as a chip
 * at all. Light tints sit ~1.30:1 against the page (up from ~1.13:1) and the dark
 * set is now solid deep colour rather than a 20% alpha wash, so a chip does not
 * change shade with whatever card it lands on. Every pair is checked for WCAG
 * contrast against its own background: the lowest is 7.0:1 against a 4.5:1 floor,
 * and every border clears 1.4:1 against the page so the shape is never lost.
 * Deep red is the one background that cannot separate itself from a dark page by
 * luminance (red barely contributes to it), which is what its border is for.
 *
 * A chip is never colour alone: each carries an icon in the markup, which is what
 * makes the state readable to a colourblind operator and in print.
 * ------------------------------------------------------------------------- */
.chip {
    --chip-ink: #2b3138;
    --chip-tint: #dde1e5;
    --chip-edge: #b9c0c7;

    display: inline-flex;
    align-items: center;
    gap: .3rem;
    padding: .2rem .5rem;
    border: 1px solid var(--chip-edge);
    border-radius: 999px;
    background-color: var(--chip-tint);
    color: var(--chip-ink);
    font-size: .75rem;
    font-weight: 600;
    line-height: 1.25;
    white-space: nowrap;
    vertical-align: middle;
}

.chip > .bi {
    font-size: .8em;
}

/* Light: deeper tint, darker ink. Measured ink/tint 7.0-10.0:1. */
.chip-good { --chip-ink: #0a4f34; --chip-tint: #c7ead8; --chip-edge: #8ecfb0; }
.chip-warn { --chip-ink: #5f3a00; --chip-tint: #f6e2b8; --chip-edge: #dfbd75; }
.chip-bad  { --chip-ink: #8a1626; --chip-tint: #f7d6da; --chip-edge: #e79aa4; }
.chip-info { --chip-ink: #14407f; --chip-tint: #d3e3fb; --chip-edge: #9dc0f2; }
.chip-idle { --chip-ink: #2b3138; --chip-tint: #dde1e5; --chip-edge: #b9c0c7; }

/* Dark: solid deep colour, bright ink. Measured ink/tint 7.3-8.9:1. */
[data-bs-theme="dark"] .chip      { --chip-ink: #ccd2d8; --chip-tint: #2a2f35; --chip-edge: #454c54; }
[data-bs-theme="dark"] .chip-good { --chip-ink: #86e8b8; --chip-tint: #0d3327; --chip-edge: #1c6a4c; }
[data-bs-theme="dark"] .chip-warn { --chip-ink: #f8cf7e; --chip-tint: #3a2a05; --chip-edge: #7a5a12; }
[data-bs-theme="dark"] .chip-bad  { --chip-ink: #fbb0ba; --chip-tint: #511620; --chip-edge: #7d2735; }
[data-bs-theme="dark"] .chip-info { --chip-ink: #a9cbfb; --chip-tint: #143664; --chip-edge: #23508f; }
[data-bs-theme="dark"] .chip-idle { --chip-ink: #ccd2d8; --chip-tint: #2a2f35; --chip-edge: #454c54; }

/* Forced-colours (Windows high contrast): tints are dropped by the OS, so fall
 * back to the system border and let the icon + label carry the state. */
@media (forced-colors: active) {
    .chip {
        border-color: ButtonBorder;
        color: ButtonText;
    }
}

/* ---------------------------------------------------------------------------
 * Action bars
 *
 * One row of actions, laid out identically everywhere: same gap, same wrap, same
 * right alignment, so the buttons in row 1 of one table sit exactly where the eye
 * expects them in row 1 of another.
 *
 * The ORDER is the contract, and it is fixed for every table in the app:
 *
 *   1. open      - go look at the thing (View, Preview, Open, Configure)
 *   2. edit      - change what it is (Edit, Rename)
 *   3. operate   - do something with it (Terminal, Backups, Push, Check, Print)
 *   4. toggle    - flip its state (Enable/Disable, Lock/Unlock, Activate)
 *   5. destroy   - remove it (Delete) - ALWAYS last, never mid-row
 *
 * Destructive last is the point of the whole thing: Delete never lands where the
 * previous row's Edit was, so a mis-click cannot delete a record.
 *
 * Always ONE row, at every width. Wrapping turned a phone's action cell into a
 * vertical stack of buttons several rows tall, which pushed the rest of the table
 * off screen and moved Delete somewhere different in every row - defeating the
 * fixed order above. Every action bar sits inside .table-responsive, so the table
 * scrolls sideways instead; the buttons keep their positions and the row stays one
 * row high.
 * ------------------------------------------------------------------------- */
.action-bar {
    display: inline-flex;
    flex-wrap: nowrap;
    align-items: center;
    justify-content: flex-end;
    gap: .3rem;
    white-space: nowrap;
}

/* Forms are how POST/DELETE actions are submitted; they must not break the row. */
.action-bar > form {
    display: inline-flex;
    margin: 0;
}

/* Nothing in the bar may shrink or wrap its own label - a squeezed button that
 * breaks "Back up now" across two lines is what makes a row grow taller. */
.action-bar > *,
.action-bar > form > * {
    flex: 0 0 auto;
    white-space: nowrap;
}

/* Phones: trim the padding and type so more of the row fits before the table has to
 * scroll. The buttons stay on one line either way - this only reduces how far the
 * operator has to swipe to reach Delete. */
@media (max-width: 575.98px) {
    .action-bar {
        gap: .25rem;
    }

    .action-bar .btn {
        --bs-btn-padding-x: .4rem;
        --bs-btn-padding-y: .2rem;
        --bs-btn-font-size: .75rem;
    }

    .action-bar .btn .bi {
        margin-right: .2rem !important;
    }
}

/* Semantic action buttons.
 *
 * `btn-outline-secondary` was the default here and is the worst choice in dark
 * mode: Bootstrap paints it grey-on-grey (#6c757d ink on a dark surface), so the
 * most-used button in the app was also the hardest to see.
 *
 * These are DARK FILLS WITH LIGHT INK, and the same pairing in both themes. The
 * pass before this one was ink-on-tint - a deep label on a pale wash of its own
 * hue - which reads as a solid control on a white card but sits only a shade off
 * the card behind it, so a row of them looked like coloured text rather than like
 * buttons. A dark fill separates from both a white card and a #212529 one, which
 * is what lets the SAME colour serve both themes: only the surface behind the
 * button changes, and the button is darker than either.
 *
 * Ink is white or near-white throughout; every fill below is measured at 5.3:1 or
 * better against it, against a 4.5:1 floor. Hover LIGHTENS the fill (it cannot
 * usefully deepen from here) and never inverts, so nothing flashes mid-click.
 *
 * Dark mode lifts each fill a step rather than changing hue: on a near-black page
 * the light-theme fills are legible but sit too close to the card, and a step up
 * restores the edge without turning the button into an accent.
 */
.btn-act-open,
.btn-act-neutral,
.btn-act-view,
.btn-act-edit,
.btn-act-copy,
.btn-act-run,
.btn-act-send,
.btn-act-toggle,
.btn-act-danger {
    --bs-btn-font-weight: 600;
    --bs-btn-border-width: 1px;
    --bs-btn-color: #fff;
    --bs-btn-hover-color: #fff;
    --bs-btn-active-color: #fff;
    --bs-btn-disabled-color: rgba(255, 255, 255, .65);
    --bs-btn-focus-shadow-rgb: var(--bs-secondary-rgb);
}

/* 1. open - the neutral. Recedes next to the coloured actions without going faint.
 *    .btn-act-neutral is the same button under the name a few pages use for it. */
.btn-act-open,
.btn-act-neutral {
    --bs-btn-bg: #343d47;
    --bs-btn-border-color: #343d47;
    --bs-btn-hover-bg: #434e5b;
    --bs-btn-hover-border-color: #434e5b;
    --bs-btn-active-bg: #232a32;
    --bs-btn-active-border-color: #232a32;
    --bs-btn-disabled-bg: #545e6b;
    --bs-btn-disabled-border-color: #545e6b;
}

[data-bs-theme="dark"] .btn-act-open,
[data-bs-theme="dark"] .btn-act-neutral {
    --bs-btn-bg: #414b57;
    --bs-btn-border-color: #566270;
    --bs-btn-hover-bg: #4e5a68;
    --bs-btn-hover-border-color: #687584;
    --bs-btn-active-bg: #343d47;
    --bs-btn-active-border-color: #566270;
}

/* 1b. view - blue. "Look at this" where the row also has a plain Open on it, so two
 *     read-only buttons in one row are not the same button twice. */
.btn-act-view {
    --bs-btn-bg: #1d4ed8;
    --bs-btn-border-color: #1d4ed8;
    --bs-btn-hover-bg: #2563eb;
    --bs-btn-hover-border-color: #2563eb;
    --bs-btn-active-bg: #1a45bd;
    --bs-btn-active-border-color: #1a45bd;
    --bs-btn-disabled-bg: #6f8fe0;
    --bs-btn-disabled-border-color: #6f8fe0;
}

[data-bs-theme="dark"] .btn-act-view {
    --bs-btn-bg: #2563eb;
    --bs-btn-border-color: #60a5fa;
    --bs-btn-hover-bg: #3b82f6;
    --bs-btn-hover-border-color: #93c5fd;
    --bs-btn-active-bg: #1d4ed8;
    --bs-btn-active-border-color: #60a5fa;
}

/* 2. edit - indigo, so "change this" is the accent in the row. */
.btn-act-edit {
    --bs-btn-bg: #4338ca;
    --bs-btn-border-color: #4338ca;
    --bs-btn-hover-bg: #4f46e5;
    --bs-btn-hover-border-color: #4f46e5;
    --bs-btn-active-bg: #3730a3;
    --bs-btn-active-border-color: #3730a3;
    --bs-btn-disabled-bg: #7b74d8;
    --bs-btn-disabled-border-color: #7b74d8;
    --bs-btn-focus-shadow-rgb: 99, 102, 241;
}

[data-bs-theme="dark"] .btn-act-edit {
    --bs-btn-bg: #4b41d6;
    --bs-btn-border-color: #6b63e0;
    --bs-btn-hover-bg: #574fe0;
    --bs-btn-hover-border-color: #8079e8;
    --bs-btn-active-bg: #3f36bd;
    --bs-btn-active-border-color: #6b63e0;
}

/* 2b. copy - teal. Duplicate, "save as", export one row: it makes a new thing without
 *     touching the old one, which is neither an edit nor an operation on the router. */
.btn-act-copy {
    --bs-btn-bg: #115e6f;
    --bs-btn-border-color: #115e6f;
    --bs-btn-hover-bg: #157287;
    --bs-btn-hover-border-color: #157287;
    --bs-btn-active-bg: #0d4a57;
    --bs-btn-active-border-color: #0d4a57;
    --bs-btn-disabled-bg: #5f939e;
    --bs-btn-disabled-border-color: #5f939e;
}

[data-bs-theme="dark"] .btn-act-copy {
    --bs-btn-bg: #157287;
    --bs-btn-border-color: #22d3ee;
    --bs-btn-hover-bg: #17849c;
    --bs-btn-hover-border-color: #67e8f9;
    --bs-btn-active-bg: #115e6f;
    --bs-btn-active-border-color: #22d3ee;
}

/* 3. operate - green, a working action. */
.btn-act-run {
    --bs-btn-bg: #146c43;
    --bs-btn-border-color: #146c43;
    --bs-btn-hover-bg: #157f4e;
    --bs-btn-hover-border-color: #157f4e;
    --bs-btn-active-bg: #0f5132;
    --bs-btn-active-border-color: #0f5132;
    --bs-btn-disabled-bg: #5f9b7f;
    --bs-btn-disabled-border-color: #5f9b7f;
}

[data-bs-theme="dark"] .btn-act-run {
    --bs-btn-bg: #17784b;
    --bs-btn-border-color: #2c9468;
    --bs-btn-hover-bg: #1a8a56;
    --bs-btn-hover-border-color: #3aa87a;
    --bs-btn-active-bg: #146c43;
    --bs-btn-active-border-color: #2c9468;
}

/* 3b. send - purple. Something leaves the panel: a page uploaded to a router, a report
 *     printed, a message sent. Distinct from the green operate beside it, because a row
 *     that carries both should not look like it carries one button twice. */
.btn-act-send {
    --bs-btn-bg: #7e22ce;
    --bs-btn-border-color: #7e22ce;
    --bs-btn-hover-bg: #9333ea;
    --bs-btn-hover-border-color: #9333ea;
    --bs-btn-active-bg: #6b21a8;
    --bs-btn-active-border-color: #6b21a8;
    --bs-btn-disabled-bg: #a878cf;
    --bs-btn-disabled-border-color: #a878cf;
}

[data-bs-theme="dark"] .btn-act-send {
    --bs-btn-bg: #8b2fdb;
    --bs-btn-border-color: #a855f7;
    --bs-btn-hover-bg: #9d46e8;
    --bs-btn-hover-border-color: #c084fc;
    --bs-btn-active-bg: #7e22ce;
    --bs-btn-active-border-color: #a855f7;
}

/* 4. toggle - amber, because flipping state is reversible but not nothing. Deep
 *    enough to carry white ink: a bright amber cannot, and this row's ink is white. */
.btn-act-toggle {
    --bs-btn-bg: #8f5106;
    --bs-btn-border-color: #8f5106;
    --bs-btn-hover-bg: #a35d07;
    --bs-btn-hover-border-color: #a35d07;
    --bs-btn-active-bg: #744105;
    --bs-btn-active-border-color: #744105;
    --bs-btn-disabled-bg: #b2864f;
    --bs-btn-disabled-border-color: #b2864f;
}

[data-bs-theme="dark"] .btn-act-toggle {
    --bs-btn-bg: #9c590a;
    --bs-btn-border-color: #bd7615;
    --bs-btn-hover-bg: #b0650b;
    --bs-btn-hover-border-color: #d18a24;
    --bs-btn-active-bg: #8f5106;
    --bs-btn-active-border-color: #bd7615;
}

/* 5. destroy - the only red in the row. */
.btn-act-danger {
    --bs-btn-bg: #b02a37;
    --bs-btn-border-color: #b02a37;
    --bs-btn-hover-bg: #c33141;
    --bs-btn-hover-border-color: #c33141;
    --bs-btn-active-bg: #93222d;
    --bs-btn-active-border-color: #93222d;
    --bs-btn-disabled-bg: #c97b83;
    --bs-btn-disabled-border-color: #c97b83;
    --bs-btn-focus-shadow-rgb: 220, 53, 69;
}

[data-bs-theme="dark"] .btn-act-danger {
    --bs-btn-bg: #bd2f3d;
    --bs-btn-border-color: #d64a58;
    --bs-btn-hover-bg: #cf3746;
    --bs-btn-hover-border-color: #e35f6c;
    --bs-btn-active-bg: #a52734;
    --bs-btn-active-border-color: #d64a58;
}

/* Forced colours: the OS drops every fill, so the label and border carry the button. */
@media (forced-colors: active) {
    .btn-act-open,
    .btn-act-neutral,
    .btn-act-view,
    .btn-act-edit,
    .btn-act-copy,
    .btn-act-run,
    .btn-act-send,
    .btn-act-toggle,
    .btn-act-danger,
    .data-tools .btn-export {
        border-color: ButtonBorder;
    }
}

/* ---------------------------------------------------------------------------
 * Export toolbar
 *
 * Print / PDF / Excel / CSV / Copy / Share, on every page that shows a list.
 *
 * One hue each. Six neighbouring buttons of the same colour are six buttons you
 * have to read, and on a phone the labels are gone entirely - so the colour and
 * the icon together are what tell them apart. The hues are picked around the
 * wheel (slate, purple, green, teal, indigo, blue) rather than by shade, because
 * two shades of one colour are not a distinction at 28px.
 *
 * Red is not among them. In this app red means destroy, and it is reserved for
 * the row action that does.
 *
 * On a phone the toolbar takes its own full-width row and the buttons share it
 * equally: five or six controls with words on them do not fit beside a search
 * box, and a half-visible button wrapped under a filter is what made this look
 * like an accident on a narrow screen.
 * ------------------------------------------------------------------------- */
.data-tools {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: .3rem;
    /* Never wider than the space it was given. Left to size itself, six buttons in a
     * card header that is already carrying a filter and a search box pushed the row
     * past its own edge and the controls sat on top of each other. */
    max-width: 100%;
}

/* Every declaration is scoped under .data-tools and names the property outright rather
 * than only setting a --bs-btn-* variable.
 *
 * AdminLTE ships its own `.btn-tool`, and the rule that paints it is
 * `.btn-tool:not(.btn-tool-custom)` - two classes' worth of specificity, setting
 * `--bs-btn-bg: transparent` and a tertiary ink. A one-class rule of ours could not
 * beat it, so the first version of this toolbar rendered as transparent buttons with
 * grey text: invisible on a white card. The class is now ours (`btn-export`) and the
 * colours are set on the properties themselves, so nothing downstream can wash them out.
 */
.data-tools .btn-export {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: .35rem;
    padding: .3rem .6rem;
    border: 1px solid transparent;
    border-radius: .45rem;
    font-weight: 600;
    line-height: 1.35;
    color: #fff;
    text-decoration: none;
    background-color: var(--tool-bg);
    border-color: var(--tool-bg);
    transition: background-color .15s ease-in-out, transform .1s ease-in-out;
}

.data-tools .btn-export:hover,
.data-tools .btn-export:focus-visible {
    color: #fff;
    background-color: var(--tool-hover);
    border-color: var(--tool-hover);
}

.data-tools .btn-export:active {
    transform: translateY(1px);
}

.data-tools .btn-export:focus-visible {
    outline: 2px solid var(--tool-bg);
    outline-offset: 2px;
}

/* The six hues, per the note at the top of this section. */
.data-tools .btn-export-print { --tool-bg: #334155; --tool-hover: #42536b; }
.data-tools .btn-export-pdf   { --tool-bg: #7e22ce; --tool-hover: #9333ea; }
.data-tools .btn-export-excel { --tool-bg: #166534; --tool-hover: #1a7a3f; }
.data-tools .btn-export-csv   { --tool-bg: #115e6f; --tool-hover: #157287; }
.data-tools .btn-export-copy  { --tool-bg: #4338ca; --tool-hover: #4f46e5; }
.data-tools .btn-export-share { --tool-bg: #1d4ed8; --tool-hover: #2563eb; }

/* Dark mode lifts every fill a step: the light-theme colours are legible on a
 * near-black page but sit too close to the card behind them. */
[data-bs-theme="dark"] .data-tools .btn-export-print { --tool-bg: #41536b; --tool-hover: #4e6280; }
[data-bs-theme="dark"] .data-tools .btn-export-pdf   { --tool-bg: #8b2fdb; --tool-hover: #9d46e8; }
[data-bs-theme="dark"] .data-tools .btn-export-excel { --tool-bg: #1a7a3f; --tool-hover: #1e8c49; }
[data-bs-theme="dark"] .data-tools .btn-export-csv   { --tool-bg: #157287; --tool-hover: #17849c; }
[data-bs-theme="dark"] .data-tools .btn-export-copy  { --tool-bg: #4f46e5; --tool-hover: #6058ea; }
[data-bs-theme="dark"] .data-tools .btn-export-share { --tool-bg: #2563eb; --tool-hover: #3b82f6; }

/* The reply to a tap: "Copied", in a colour that is not the button's own, so it reads
 * as an answer rather than as the label having always said that. */
.data-tools .btn-export.is-flashing,
.data-tools .btn-export.is-flashing:hover {
    background-color: #146c43;
    border-color: #146c43;
}

.data-tools .btn-export.is-busy {
    opacity: .7;
    cursor: progress;
}

/* Phones: the toolbar takes a row of its own and the buttons share it equally, so it
 * reads as one control strip and every button is a thumb-sized target. Labels go -
 * six words do not fit beside a search box, and a printer, a document and a clipboard
 * are recognisable without them. */
@media (max-width: 767.98px) {
    .data-tools {
        width: 100%;
        gap: .25rem;
    }

    .data-tools .btn-export {
        flex: 1 1 0;
        min-width: 0;
        padding: .45rem .3rem;
    }

    .data-tools .data-tools__label {
        display: none;
    }

    .data-tools .btn-export .bi {
        font-size: 1.05rem;
    }
}

/* ---------------------------------------------------------------------------
 * List header controls
 *
 * A list page's card header carries, in order: the export toolbar, the filters
 * that narrow the list, the router filter and the search box. On a desktop they
 * sit on one line and there is room for all of them.
 *
 * On a phone there is not, and left to wrap where they run out of room they broke
 * mid-control: three filter buttons on one line and the fourth alone underneath,
 * a search box a third of a screen wide with a stray button beside it. So each
 * group takes a row of its own and shares it between its buttons - the header
 * reads as three strips (export, filter, search) the same width as each other,
 * every one of them a thumb-sized target.
 * ------------------------------------------------------------------------- */
.list-filter-bar {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: .5rem;
    max-width: 100%;
}

.new-template-bar {
    display: flex;
    flex-wrap: wrap;
    gap: .25rem;
    max-width: 100%;
}

/* A press is answered. The header controls are Bootstrap buttons and keep their own
 * colours; this only adds the small movement the export toolbar already has, so the
 * whole header behaves like one set of controls. */
.list-filter-bar .btn,
.list-search-form .btn,
.new-template-bar .btn {
    transition: color .15s ease-in-out, background-color .15s ease-in-out,
                border-color .15s ease-in-out, transform .1s ease-in-out;
}

.list-filter-bar .btn:active,
.list-search-form .btn:active,
.new-template-bar .btn:active {
    transform: translateY(1px);
}

.list-search-form__group {
    min-width: 16rem;
    max-width: 28rem;
}

@media (max-width: 767.98px) {
    /* One row, never wrapped: the state buttons share what is left after the fixed
     * button beside them, exactly as the export toolbar shares its own row. */
    .list-filter-bar {
        width: 100%;
        flex-wrap: nowrap;
        gap: .35rem;
    }

    .list-filter-bar .btn-group {
        flex: 1 1 auto;
        min-width: 0;
    }

    /* The label wraps rather than being cut: these buttons say which slice of the list
     * you are looking at, and "Flagged & blocked" clipped to "Flagged &…" is a filter
     * nobody can identify. A group of equal-height buttons simply gets a taller row. */
    .list-filter-bar .btn-group > .btn {
        flex: 1 1 0;
        min-width: 0;
        padding: .45rem .2rem;
        font-size: .78rem;
        line-height: 1.2;
        white-space: normal;
        overflow-wrap: anywhere;
    }

    /* How many rows that filter would show, under its name instead of beside it: at a
     * fifth of a phone's width "Suspended 12" breaks wherever it likes and the number
     * reads as part of the label. Its own line, one step down, cannot. */
    .list-filter-bar .filter-count {
        display: block;
        font-size: .7rem;
        font-weight: 600;
        opacity: .85;
    }

    .list-filter-bar__aside {
        flex: 0 0 auto;
        padding: .45rem .55rem;
        font-size: .78rem;
        white-space: nowrap;
    }

    /* The bar's only control - a page whose header carries one link and no filter group.
     * It takes the row it is already occupying rather than sitting small at one end of it. */
    .list-filter-bar__aside:only-child {
        flex: 1 1 auto;
    }

    /* Full width, and no minimum: a 16rem input group is wider than the card it sits
     * in on a small phone, which is what pushed the page sideways. */
    .list-search-form {
        width: 100%;
    }

    .list-search-form__group {
        width: 100%;
        min-width: 0;
        max-width: none;
    }

    .router-filter {
        width: 100%;
    }

    .router-filter .form-select {
        flex: 1 1 auto;
    }

    /* Five ways to start a template: two to a row, the odd one out taking the last row
     * whole. As one unwrappable inline row the last button hung off the card's edge. */
    .new-template-bar {
        width: 100%;
        gap: .35rem;
    }

    .new-template-bar .btn {
        flex: 1 1 calc(50% - .35rem);
        min-width: 0;
        padding: .45rem .35rem;
        font-size: .78rem;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
    }
}

/* ---------------------------------------------------------------------------
 * Coloured stat cards
 * ------------------------------------------------------------------------- */
.stat-card {
    position: relative;
    overflow: hidden;
    border: 0;
    border-radius: 1rem;
    color: #fff;
    min-height: 118px;
    background-image: linear-gradient(135deg, var(--stat-from) 0%, var(--stat-to) 100%);
    box-shadow:
        0 1px 2px rgba(0, 0, 0, .18),
        0 6px 16px -6px var(--stat-glow, rgba(0, 0, 0, .35)),
        inset 0 1px 0 rgba(255, 255, 255, 0.22);
    transition: transform .16s ease, box-shadow .16s ease;
}

.stat-card::before {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(125deg, rgba(255, 255, 255, 0.16) 0%, transparent 42%);
    pointer-events: none;
}

.stat-card:hover {
    transform: translateY(-2px);
    box-shadow: 0 2px 4px rgba(0, 0, 0, .2), 0 12px 26px -8px var(--stat-glow, rgba(0, 0, 0, .45));
}

/* Anchor variants keep the card looking like a card, not a link. */
a.stat-card,
a.stat-card:hover,
a.stat-card:focus {
    color: #fff;
    text-decoration: none;
}

a.stat-card:focus-visible {
    outline: 3px solid var(--bs-focus-ring-color, rgba(255, 255, 255, .6));
    outline-offset: 2px;
}

.stat-card__label {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: .5rem;
    font-size: .78rem;
    font-weight: 600;
    letter-spacing: .05em;
    text-transform: uppercase;
    /* Pure-white labels at this size vibrate against the gradient; ease them back. */
    color: rgba(255, 255, 255, .82);
    margin-bottom: .35rem;
    position: relative;
    z-index: 1;
}

/* The explicit safe/watch/danger word, next to the label. Colour alone does not say the
 * verdict for someone who cannot tell a tile's tint from its neighbour's at a glance, and a
 * tile whose "fine" colour is decorative (see the health/capacity ok-colour convention) needs
 * this more than a plain red/amber/green one would. */
.stat-card__badge {
    flex: none;
    font-size: .65rem;
    font-weight: 700;
    letter-spacing: .04em;
    text-transform: uppercase;
    padding: .15rem .5rem;
    border-radius: 999px;
    background-color: rgba(255, 255, 255, .22);
    color: #fff;
}

.stat-card__value {
    font-size: 2rem;
    font-weight: 700;
    line-height: 1.1;
    margin-bottom: 0;
    font-variant-numeric: tabular-nums;
    position: relative;
    z-index: 1;
}

.stat-card__meta {
    font-size: .78rem;
    color: rgba(255, 255, 255, .8);
    margin-bottom: 0;
    margin-top: .4rem;
    position: relative;
    z-index: 1;
}

/* Oversized watermark glyph, decorative only. */
.stat-card__icon {
    position: absolute;
    right: -.35rem;
    bottom: -.9rem;
    font-size: 4.5rem;
    line-height: 1;
    opacity: .18;
    pointer-events: none;
}

.stat-card__spark {
    position: absolute;
    inset: auto 0 0 0;
    height: 34px;
    opacity: .5;
    pointer-events: none;
}

/* Phones show two cards per row rather than one, so each card is roughly half the
 * width it was designed at. Scale the type and the watermark glyph back to match -
 * at full size the value overflows on long numbers and the icon crowds the meta line. */
@media (max-width: 575.98px) {
    .stat-card {
        min-height: 104px;
    }

    .stat-card__label {
        font-size: .68rem;
        letter-spacing: .02em;
    }

    .stat-card__value {
        font-size: 1.5rem;
    }

    .stat-card__meta {
        font-size: .7rem;
    }

    .stat-card__icon {
        font-size: 3rem;
        bottom: -.6rem;
    }
}

/* The navbar router scope picker: wide enough for a site name, capped so a long one
 * cannot push the account menu off a phone screen. */
.router-scope-select {
    width: auto;
    max-width: 46vw;
    min-width: 8rem;
}

/* Palette. Each pair is a same-hue ramp so the gradient reads as one colour.
 * --stat-glow tints the drop shadow to match, which is what makes them pop. */
.stat-indigo { --stat-from: #6366f1; --stat-to: #4338ca; --stat-glow: rgba(79, 70, 229, .5); }
.stat-blue   { --stat-from: #3b82f6; --stat-to: #1d4ed8; --stat-glow: rgba(37, 99, 235, .5); }
.stat-green  { --stat-from: #22c55e; --stat-to: #15803d; --stat-glow: rgba(22, 163, 74, .5); }
.stat-red    { --stat-from: #f43f5e; --stat-to: #be123c; --stat-glow: rgba(225, 29, 72, .5); }
.stat-teal   { --stat-from: #14b8a6; --stat-to: #0f766e; --stat-glow: rgba(13, 148, 136, .5); }
.stat-amber  { --stat-from: #f59e0b; --stat-to: #b45309; --stat-glow: rgba(217, 119, 6, .5); }
.stat-purple { --stat-from: #a855f7; --stat-to: #7e22ce; --stat-glow: rgba(147, 51, 234, .5); }
.stat-pink   { --stat-from: #ec4899; --stat-to: #be185d; --stat-glow: rgba(219, 39, 119, .5); }
.stat-slate  { --stat-from: #64748b; --stat-to: #334155; --stat-glow: rgba(71, 85, 105, .5); }
.stat-cyan   { --stat-from: #06b6d4; --stat-to: #0e7490; --stat-glow: rgba(8, 145, 178, .5); }
/* The traffic row: sky/orange deliberately echo the download/upload series hues in
 * the charts below them, so the cards and the plot read as one story. */
.stat-sky    { --stat-from: #38bdf8; --stat-to: #0369a1; --stat-glow: rgba(2, 132, 199, .5); }
.stat-orange { --stat-from: #fb923c; --stat-to: #c2410c; --stat-glow: rgba(234, 88, 12, .5); }

/* Dark mode: full-saturation gradients glare against a dark page. Damp them
 * slightly and trade the coloured glow for a plain shadow. */
[data-bs-theme="dark"] .stat-card {
    filter: saturate(.92) brightness(.94);
    box-shadow: 0 1px 2px rgba(0, 0, 0, .5), 0 6px 16px -6px rgba(0, 0, 0, .6);
}

[data-bs-theme="dark"] .stat-card:hover {
    box-shadow: 0 2px 6px rgba(0, 0, 0, .55), 0 14px 28px -10px rgba(0, 0, 0, .7);
}

@media (prefers-reduced-motion: reduce) {
    .stat-card,
    .stat-card:hover {
        transition: none;
        transform: none;
    }
}

/* ---------------------------------------------------------------------------
 * Router picker cards
 *
 * The picker is the first screen a reseller lands on after signing in, and on a
 * phone it is often the only screen they touch all day. A column of near-identical
 * white cards makes "which of my sites is this?" a reading task; giving every
 * router its own colour makes it a glance. The palette classes are the same
 * .stat-* ramps the dashboard tiles use, so a card here and a tile there are
 * recognisably the same object at two sizes.
 *
 * Nothing here truncates. A router name is the one string on this page the
 * operator picks by, so it wraps to as many lines as it needs rather than ending
 * in an ellipsis that makes two sites look alike.
 * ------------------------------------------------------------------------- */
/* The strip above the grid: what the fleet is, and a way to cut it down to one site. */
.router-pick-bar {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: .75rem;
    margin-bottom: .9rem;
}

.router-pick-bar__counts {
    display: inline-flex;
    flex-wrap: wrap;
    gap: .4rem;
}

.router-pick-bar__count {
    display: inline-flex;
    align-items: center;
    gap: .35rem;
    padding: .25rem .6rem;
    border: 1px solid var(--bs-border-color);
    border-radius: 999px;
    background: var(--bs-secondary-bg);
    color: var(--bs-body-color);
    font-size: .8rem;
    font-weight: 600;
}

.router-pick-bar__count.is-online { color: #0f7b52; border-color: #0f7b52; }
.router-pick-bar__count.is-offline { color: #b02a37; border-color: #b02a37; }

[data-bs-theme="dark"] .router-pick-bar__count.is-online { color: #5fd39d; border-color: #2c7a58; }
[data-bs-theme="dark"] .router-pick-bar__count.is-offline { color: #f19aa4; border-color: #8c3540; }

.router-pick-bar__filter {
    position: relative;
    margin-left: auto;
    min-width: 14rem;
    max-width: 22rem;
    flex: 1 1 14rem;
}

.router-pick-bar__filter .bi {
    position: absolute;
    top: 50%;
    left: .65rem;
    transform: translateY(-50%);
    color: var(--bs-secondary-color);
    pointer-events: none;
}

.router-pick-bar__filter .form-control {
    padding-left: 2rem;
}

/* Shown by the filter script when nothing matches, so an empty grid is never just
 * blank page. */
.router-pick-empty {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: .5rem;
    padding: 2.5rem 1rem;
    border: 1px dashed var(--bs-border-color);
    border-radius: 1rem;
    color: var(--bs-secondary-color);
}

.router-pick {
    position: relative;
    display: flex;
    flex-direction: column;
    gap: .7rem;
    width: 100%;
    padding: 1rem 1.1rem 1.1rem;
    border: 0;
    border-radius: 1rem;
    overflow: hidden;
    text-align: left;
    color: #fff;
    background-image: linear-gradient(135deg, var(--stat-from) 0%, var(--stat-to) 100%);
    box-shadow: 0 1px 2px rgba(0, 0, 0, .18), 0 6px 16px -6px var(--stat-glow, rgba(0, 0, 0, .35));
    transition: transform .16s ease, box-shadow .16s ease;
}

.router-pick:hover:not(:disabled) {
    transform: translateY(-3px);
    box-shadow: 0 2px 4px rgba(0, 0, 0, .2), 0 14px 28px -8px var(--stat-glow, rgba(0, 0, 0, .45));
}

/* Pressed. A card is a big target and the gradient hides the usual button press, so the
 * card itself takes the click. */
.router-pick:active:not(:disabled) {
    transform: translateY(-1px) scale(.995);
}

/* A site this account cannot switch to - offline, and the account is not a super admin.
 * See Admin::canManageRouter. It stays on the page, greyed rather than removed: which of
 * your sites is down is exactly what somebody opening this page needs to know. */
.router-pick:disabled {
    filter: grayscale(.55) brightness(.85);
    opacity: .78;
    cursor: not-allowed;
}

/* An offline site that CAN still be opened (a super admin's). Dimmed enough to be told
 * apart in a grid, not so much that it looks unavailable. */
.router-pick.is-offline:not(:disabled) {
    filter: saturate(.6);
}

.router-pick:focus-visible {
    outline: 3px solid rgba(255, 255, 255, .85);
    outline-offset: -4px;
}

/* The watermark glyph is painted into the card background, so every real child
 * has to be lifted above it or the name sits on top of a 5rem icon. */
.router-pick > * {
    position: relative;
    z-index: 1;
}

.router-pick__icon {
    position: absolute;
    right: -.5rem;
    bottom: -1.1rem;
    z-index: 0;
    font-size: 5rem;
    line-height: 1;
    opacity: .16;
    pointer-events: none;
}

.router-pick__head {
    display: flex;
    align-items: center;
    gap: .7rem;
}

/* The device itself, with its state light on it. This used to be the site's
 * initials, which repeated what the name beside them already said and turned a
 * grid of ten sites into ten two-letter blocks to read. One shape on every card
 * means the only thing that differs between them is the colour of the dot. */
.router-pick__avatar {
    flex: 0 0 auto;
    display: grid;
    place-items: center;
    width: 2.6rem;
    height: 2.6rem;
    border-radius: .8rem;
    border: 1px solid rgba(255, 255, 255, .35);
    background: rgba(255, 255, 255, .18);
    font-size: 1.3rem;
    line-height: 1;
    font-weight: 700;
    letter-spacing: .02em;
}

/* Reachability as a dot on the avatar: one fixed place on every card, so scanning a
 * grid for "which of these is down" is a look rather than a read. The chips below say
 * it in words as well - this is never the only carrier of the state. */
.router-pick__dot {
    position: absolute;
    right: -.15rem;
    bottom: -.15rem;
    width: .75rem;
    height: .75rem;
    border-radius: 50%;
    border: 2px solid rgba(255, 255, 255, .9);
}

.router-pick__dot.is-up {
    background: #22c55e;
}

.router-pick__dot.is-down {
    background: #ef4444;
}

/* The avatar is the dot's positioning context. */
.router-pick__avatar {
    position: relative;
}

/* A count on the fleet card, where a single site shows status chips instead. */
.router-pick__stat {
    display: inline-flex;
    align-items: center;
    gap: .3rem;
    margin-right: .35rem;
    padding: .15rem .5rem;
    border-radius: 999px;
    border: 1px solid rgba(255, 255, 255, .45);
    background: rgba(255, 255, 255, .16);
    font-size: .75rem;
    font-weight: 600;
}

/* Without this a flex item refuses to shrink below its content, and a single long
 * unspaced site name widens the card instead of wrapping inside it. */
.router-pick__head > div {
    min-width: 0;
}

.router-pick__name {
    margin: 0;
    font-size: 1.15rem;
    font-weight: 700;
    line-height: 1.25;
    /* Site names are operator-typed and can be one long unspaced token. */
    overflow-wrap: anywhere;
}

.router-pick__meta {
    margin: .15rem 0 0;
    font-size: .78rem;
    color: rgba(255, 255, 255, .85);
    overflow-wrap: anywhere;
}

.router-pick__tags {
    /* Pushes the call to action to the bottom edge so every card in a row ends
     * with its button on the same line, however many lines the name took. */
    margin-bottom: auto;
}

/* The chips keep their own tones: colour is what separates "Online" from "Not
 * backed up", and that has to survive being placed on a coloured card. The ring
 * stops a pale chip from bleeding into a pale gradient. */
.router-pick .chip {
    box-shadow: 0 0 0 1px rgba(255, 255, 255, .45);
}

.router-pick__cta {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: .4rem;
    align-self: flex-start;
    padding: .35rem .8rem;
    border: 1px solid rgba(255, 255, 255, .5);
    border-radius: 999px;
    background: rgba(255, 255, 255, .18);
    font-size: .82rem;
    font-weight: 600;
}

.router-pick:hover:not(:disabled) .router-pick__cta {
    background: rgba(255, 255, 255, .28);
}

/* The arrow is the card saying it goes somewhere. It only travels on hover, so the
 * resting card stays quiet. */
.router-pick__arrow {
    transition: transform .16s ease;
}

.router-pick:hover:not(:disabled) .router-pick__arrow {
    transform: translateX(.2rem);
}

.router-pick__current {
    display: inline-flex;
    align-items: center;
    gap: .25rem;
}

/* The router this session is already scoped to. */
.router-pick.is-current {
    box-shadow: 0 0 0 3px var(--bs-body-bg), 0 0 0 6px var(--stat-from), 0 8px 20px -8px var(--stat-glow, rgba(0, 0, 0, .45));
}

.router-pick__current {
    position: absolute;
    top: .7rem;
    right: .8rem;
    z-index: 1;
    padding: .15rem .5rem;
    border-radius: 999px;
    background: rgba(255, 255, 255, .9);
    color: #1f2937;
    font-size: .68rem;
    font-weight: 700;
    letter-spacing: .04em;
    text-transform: uppercase;
}

/* The badge floats over the card, so the name has to be kept out from under it. */
.router-pick.is-current .router-pick__head {
    padding-right: 4.5rem;
}

/* Phones get one card per row, so the card is twice the width it is on a laptop
 * but has the same content in it. Tighten the padding and let the button run the
 * full width, which is also the only thumb-sized target on the screen. */
@media (max-width: 575.98px) {
    .router-pick {
        gap: .6rem;
        padding: .85rem .9rem 1rem;
        border-radius: .85rem;
    }

    .router-pick__avatar {
        width: 2.3rem;
        height: 2.3rem;
        border-radius: .7rem;
        font-size: 1.15rem;
    }

    .router-pick__name {
        font-size: 1.05rem;
    }

    .router-pick__icon {
        font-size: 3.6rem;
        bottom: -.8rem;
    }

    .router-pick__cta {
        align-self: stretch;
        padding: .45rem .8rem;
    }
}

[data-bs-theme="dark"] .router-pick {
    filter: saturate(.92) brightness(.94);
    box-shadow: 0 1px 2px rgba(0, 0, 0, .5), 0 6px 16px -6px rgba(0, 0, 0, .6);
}

[data-bs-theme="dark"] .router-pick:hover {
    box-shadow: 0 2px 6px rgba(0, 0, 0, .55), 0 16px 30px -10px rgba(0, 0, 0, .7);
}

[data-bs-theme="dark"] .router-pick.is-current {
    box-shadow: 0 0 0 3px var(--bs-body-bg), 0 0 0 6px var(--stat-from), 0 10px 24px -10px rgba(0, 0, 0, .7);
}

@media (prefers-reduced-motion: reduce) {
    .router-pick,
    .router-pick:hover {
        transition: none;
        transform: none;
    }
}

/* Forced colours drops the gradients entirely, taking the only thing telling one
 * card from the next with it. Put the border back so they are still cards. */
@media (forced-colors: active) {
    .router-pick {
        border: 1px solid ButtonBorder;
    }

    .router-pick__cta {
        border-color: ButtonBorder;
    }
}

/* ---------------------------------------------------------------------------
 * Panels, charts, misc
 * ------------------------------------------------------------------------- */
.panel-card {
    border: 1px solid color-mix(in srgb, var(--bs-border-color) 82%, var(--mtik-gilt, #b89b6a));
    border-radius: 1rem;
    background-color: var(--bs-body-bg);
    box-shadow: var(--mtik-panel-shadow, 0 0.55rem 1.35rem rgba(28, 24, 18, 0.07));
    overflow: hidden;
    transition: box-shadow 0.18s ease, border-color 0.18s ease;
}

.panel-card:hover {
    box-shadow: var(--mtik-panel-shadow-hover, 0 0.75rem 1.75rem rgba(28, 24, 18, 0.11));
}

.panel-card > .card-header {
    background:
        linear-gradient(90deg, var(--mtik-gilt-soft, transparent) 0%, transparent 70%),
        transparent;
    border-bottom: 1px solid color-mix(in srgb, var(--bs-border-color) 88%, var(--mtik-gilt, #b89b6a));
    font-weight: 600;
    letter-spacing: 0.01em;
}

.chart-panel {
    --chart-accent: #2a78d6;
    --chart-gilt: #b89b6a;
    border-color: color-mix(in srgb, var(--bs-border-color) 72%, var(--chart-gilt));
    box-shadow:
        0 0.55rem 1.4rem rgba(28, 24, 18, 0.06),
        inset 0 1px 0 rgba(255, 255, 255, 0.35);
    overflow: hidden;
    border-radius: 1rem;
}

.chart-panel > .card-header {
    background:
        linear-gradient(90deg, color-mix(in srgb, var(--chart-gilt) 16%, transparent) 0%, transparent 62%),
        linear-gradient(180deg, color-mix(in srgb, var(--bs-body-bg) 70%, transparent), transparent);
    position: relative;
    gap: 0.5rem;
    align-items: center;
}

.chart-panel > .card-header::after {
    content: "";
    position: absolute;
    left: 1rem;
    right: 1rem;
    bottom: 0;
    height: 1px;
    background: linear-gradient(90deg, color-mix(in srgb, var(--chart-gilt) 55%, transparent), transparent 85%);
}

.chart-panel > .card-header > span:first-child {
    display: inline-flex;
    align-items: center;
    letter-spacing: 0.01em;
    min-width: 0;
}

.chart-panel > .card-header > span:first-child i {
    width: 1.45rem;
    height: 1.45rem;
    border-radius: 999px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    background: color-mix(in srgb, var(--chart-gilt) 20%, transparent);
    color: color-mix(in srgb, var(--chart-gilt) 40%, var(--chart-accent));
    flex: 0 0 auto;
}

.chart-panel .badge.badge-neutral {
    border: 1px solid color-mix(in srgb, var(--bs-border-color) 65%, var(--chart-gilt));
    background: color-mix(in srgb, var(--bs-body-bg) 86%, var(--chart-gilt));
    font-weight: 500;
}

.chart-panel [data-period-filter] {
    min-width: 0;
}

.dash-chart-kpis {
    padding: 0.15rem 0 0.35rem;
}

.dash-chart-kpi {
    padding: 0.7rem 0.85rem;
    border-radius: 0.85rem;
    background: color-mix(in srgb, var(--bs-body-bg) 70%, var(--chart-gilt) 8%);
    border: 1px solid color-mix(in srgb, var(--bs-border-color) 80%, var(--chart-gilt));
    min-width: 0;
}

.dash-chart-kpi .fs-5,
.dash-chart-kpi .fs-6 {
    font-variant-numeric: tabular-nums;
    letter-spacing: -0.02em;
}

.dash-load-meter {
    flex: 1 1 4rem;
    height: 0.35rem;
    min-width: 3rem;
    max-width: 7rem;
    border-radius: 999px;
    background: color-mix(in srgb, var(--bs-border-color) 80%, transparent);
    overflow: hidden;
}

.dash-load-meter > span {
    display: block;
    height: 100%;
    border-radius: inherit;
    background: linear-gradient(90deg, #2a78d6, #b89b6a);
}

.chart-box {
    position: relative;
    width: 100%;
    min-width: 0;
    touch-action: pan-y;
}

.chart-box > canvas {
    max-width: 100%;
}

.chart-box--flow {
    height: 280px;
}

.chart-box--trend {
    height: 260px;
}

.chart-box--wa {
    height: 180px;
}

.chart-box--fleet {
    height: 200px;
}

.chart-box--bars {
    height: 220px;
}

@media (max-width: 767.98px) {
    .chart-panel > .card-header {
        padding-top: 0.85rem;
        padding-bottom: 0.85rem;
    }

    .chart-panel [data-period-filter] {
        width: 100%;
        justify-content: stretch;
    }

    .chart-panel [data-period-filter] .form-select {
        flex: 1 1 auto;
        min-width: 0 !important;
        width: auto;
    }

    .chart-box--flow {
        height: 210px;
    }

    .chart-box--trend,
    .chart-box--bars {
        height: 200px;
    }

    .chart-box--wa {
        height: 150px;
    }

    .chart-box--fleet {
        height: 180px;
    }

    .stat-card__value {
        font-size: 1.55rem;
    }
}

@media (prefers-reduced-motion: reduce) {
    .chart-panel,
    .dash-attention__item,
    .stat-card {
        transition: none;
    }
}

/* Dashboard attention strip: problems that want a click, not another chart. */
.dash-attention {
    display: grid;
    gap: 0.75rem;
    grid-template-columns: repeat(auto-fit, minmax(15.5rem, 1fr));
}

.dash-attention__item {
    display: flex;
    align-items: center;
    gap: 0.85rem;
    padding: 0.9rem 1rem;
    border: 1px solid var(--bs-border-color);
    border-radius: 0.9rem;
    background: var(--bs-body-bg);
    text-decoration: none;
    color: inherit;
    transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}

.dash-attention__item:hover {
    transform: translateY(-1px);
    box-shadow: 0 0.35rem 1rem rgba(15, 23, 42, 0.08);
    border-color: color-mix(in srgb, var(--bs-border-color) 60%, currentColor);
    color: inherit;
}

.dash-attention__icon {
    font-size: 1.25rem;
    line-height: 1;
    opacity: 0.9;
}

.dash-attention__copy {
    min-width: 0;
    flex: 1;
}

.dash-attention__label {
    font-weight: 600;
    font-size: 0.92rem;
}

.dash-attention__meta {
    color: var(--bs-secondary-color);
    font-size: 0.8rem;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.dash-attention__value {
    font-size: 1.35rem;
    font-weight: 700;
    line-height: 1;
}

.dash-attention__item--warn {
    border-left: 3px solid #eda100;
}

.dash-attention__item--bad {
    border-left: 3px solid #e34948;
}

.dash-attention__item--idle {
    border-left: 3px solid #898781;
}

.dash-attention__item--warn .dash-attention__icon,
.dash-attention__item--warn .dash-attention__value {
    color: #eda100;
}

.dash-attention__item--bad .dash-attention__icon,
.dash-attention__item--bad .dash-attention__value {
    color: #e34948;
}

.dash-attention__item--idle .dash-attention__icon,
.dash-attention__item--idle .dash-attention__value {
    color: #898781;
}

.dash-stock-bar {
    display: flex;
    height: 0.7rem;
    border-radius: 999px;
    overflow: hidden;
    background: color-mix(in srgb, var(--bs-border-color) 70%, transparent);
}

.dash-stock-bar__seg {
    display: block;
    height: 100%;
    min-width: 0;
}

[data-bs-theme="dark"] .chart-panel {
    box-shadow: 0 0.55rem 1.5rem rgba(0, 0, 0, 0.38), inset 0 1px 0 rgba(255, 255, 255, 0.04);
}

[data-bs-theme="dark"] .dash-chart-kpi {
    background: color-mix(in srgb, var(--bs-body-bg) 88%, var(--chart-gilt) 10%);
}

/* Small leading swatch for legends/lists. */
.dot {
    display: inline-block;
    width: .6rem;
    height: .6rem;
    border-radius: 50%;
    flex: 0 0 auto;
}

/* ---------------------------------------------------------------------------
 * Avatars
 * ------------------------------------------------------------------------- */
.avatar {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    object-fit: cover;
    background-color: var(--bs-secondary-bg);
    color: var(--bs-emphasis-color);
    font-weight: 600;
    overflow: hidden;
    flex: 0 0 auto;
}

.avatar-sm { width: 2rem; height: 2rem; font-size: .8rem; }
.avatar-md { width: 3rem; height: 3rem; font-size: 1.1rem; }
.avatar-xl { width: 7rem; height: 7rem; font-size: 2.4rem; }

.avatar-ring {
    box-shadow: 0 0 0 3px var(--bs-body-bg), 0 0 0 5px #6366f1;
}

.avatar img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

/* ---------------------------------------------------------------------------
 * Account menu (header photo + name)
 *
 * Same click behaviour as before; the card itself is what changed. A gilt-washed
 * hero carries the larger photo and identity, then two quiet action rows. On a
 * phone it pins under the header like the notification menu so it never clips
 * off the left edge of the screen.
 * ------------------------------------------------------------------------- */
.mtk-account-toggle__avatar {
    box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--mtik-gilt) 55%, transparent);
}

.mtk-account-menu {
    --mtk-account-radius: 1rem;
    width: min(19.5rem, calc(100vw - 1.5rem));
    min-width: 16.5rem;
    padding: 0;
    overflow: hidden;
    border: 1px solid var(--mtik-gilt-line);
    border-radius: var(--mtk-account-radius);
    box-shadow: var(--mtik-panel-shadow);
    background:
        linear-gradient(180deg, color-mix(in srgb, var(--bs-body-bg) 88%, var(--mtik-gilt)), var(--bs-body-bg));
}

.mtk-account-menu__hero {
    display: flex;
    align-items: center;
    gap: 0.9rem;
    padding: 1.1rem 1.15rem 1rem;
    background:
        radial-gradient(ellipse 90% 120% at 0% 0%, var(--mtik-gilt-soft), transparent 55%),
        linear-gradient(160deg,
            color-mix(in srgb, var(--bs-body-bg) 70%, var(--mtik-gilt)),
            var(--bs-body-bg) 70%);
    border-bottom: 1px solid var(--mtik-gilt-line);
}

.mtk-account-menu__photo {
    width: 3.35rem;
    height: 3.35rem;
    font-size: 1.15rem;
    box-shadow:
        0 0 0 2px var(--bs-body-bg),
        0 0 0 3.5px color-mix(in srgb, var(--mtik-gilt) 70%, transparent),
        0 0.45rem 1rem color-mix(in srgb, var(--mtik-gilt) 28%, transparent);
    background:
        linear-gradient(145deg,
            color-mix(in srgb, var(--mtik-gilt) 28%, var(--bs-secondary-bg)),
            var(--bs-secondary-bg));
}

.mtk-account-menu__identity {
    min-width: 0;
    flex: 1 1 auto;
}

.mtk-account-menu__name {
    font-weight: 650;
    letter-spacing: 0.01em;
    line-height: 1.25;
    color: var(--bs-emphasis-color);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.mtk-account-menu__email {
    margin-top: 0.15rem;
    font-size: 0.78rem;
    color: var(--bs-secondary-color);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.mtk-account-menu__role {
    display: inline-flex;
    align-items: center;
    margin-top: 0.45rem;
    padding: 0.18rem 0.55rem;
    border-radius: 999px;
    border: 1px solid var(--mtik-gilt-line);
    background: color-mix(in srgb, var(--mtik-gilt) 16%, var(--bs-body-bg));
    color: color-mix(in srgb, var(--mtik-gilt) 55%, var(--bs-emphasis-color));
    font-size: 0.68rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
}

.mtk-account-menu__actions {
    display: flex;
    flex-direction: column;
    padding: 0.45rem;
    gap: 0.15rem;
}

.mtk-account-menu__link {
    display: flex;
    align-items: center;
    gap: 0.7rem;
    width: 100%;
    margin: 0;
    padding: 0.7rem 0.8rem;
    border: 0;
    border-radius: 0.7rem;
    background: transparent;
    color: var(--bs-body-color);
    text-align: start;
    text-decoration: none;
    font-size: 0.92rem;
    line-height: 1.2;
}

.mtk-account-menu__link > .bi {
    font-size: 1.05rem;
    color: color-mix(in srgb, var(--mtik-gilt) 45%, var(--bs-secondary-color));
}

.mtk-account-menu__link:hover,
.mtk-account-menu__link:focus-visible {
    background: color-mix(in srgb, var(--mtik-gilt) 12%, var(--bs-secondary-bg));
    color: var(--bs-emphasis-color);
}

.mtk-account-menu__link--danger {
    color: var(--bs-danger);
}

.mtk-account-menu__link--danger > .bi {
    color: var(--bs-danger);
}

.mtk-account-menu__link--danger:hover,
.mtk-account-menu__link--danger:focus-visible {
    background: color-mix(in srgb, var(--bs-danger) 10%, var(--bs-body-bg));
    color: var(--bs-danger);
}

@media (max-width: 767.98px) {
    .mtk-account-menu {
        position: fixed !important;
        top: 3.9rem !important;
        right: 0.75rem !important;
        left: auto !important;
        bottom: auto !important;
        width: min(20rem, calc(100vw - 1.5rem)) !important;
        min-width: 0 !important;
        max-width: none !important;
        margin: 0 !important;
        transform: none !important;
    }

    .mtk-account-menu__hero {
        padding: 1rem 1rem 0.9rem;
        gap: 0.8rem;
    }

    .mtk-account-menu__photo {
        width: 3.1rem;
        height: 3.1rem;
    }

    .mtk-account-menu__link {
        min-height: 2.75rem;
        padding: 0.75rem 0.85rem;
    }
}

/* ---------------------------------------------------------------------------
 * Founder photo preview
 *
 * The Landing Page > Team screen, showing each person exactly as the public page
 * draws them: a circular crop with the initials as the fallback. Deliberately not
 * .avatar - that one is sized for the navbar and the profile page, and this has to
 * match a marketing card, including the ring that separates the circle from the
 * card behind it.
 * ------------------------------------------------------------------------- */
.founder-preview {
    --founder-preview-size: 5.5rem;
    width: var(--founder-preview-size);
    height: var(--founder-preview-size);
    flex: 0 0 auto;
    display: inline-grid;
    place-items: center;
    overflow: hidden;
    border-radius: 50%;
    background-color: var(--bs-primary-bg-subtle);
    box-shadow: 0 0 0 3px var(--bs-body-bg), 0 0 0 4px var(--bs-border-color);
}

/* The row in the accordion header, where it is an identifier rather than a preview. */
.founder-preview-sm { --founder-preview-size: 1.75rem; }

.founder-preview img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.founder-preview-initials {
    font-weight: 700;
    /* Scales with the circle, so one rule covers both sizes. */
    font-size: calc(var(--founder-preview-size) * .34);
    color: var(--bs-primary);
    line-height: 1;
}

/* ---------------------------------------------------------------------------
 * Sign-in page
 *
 * The one screen every user meets before they trust the rest, and the only one an
 * unauthenticated stranger can reach.
 *
 * Two panels inside one card: a brand side that carries the colour, and a form side
 * that is deliberately quiet. Below lg the brand side is dropped entirely rather
 * than stacked - on a phone the form is the whole job, and a coloured banner above
 * it only pushes the password field towards the on-screen keyboard.
 *
 * The layout owns its palette rather than reading the theme tokens: the body is
 * pinned to data-bs-theme="light" (see the auth branch of layouts/app.blade.php),
 * so these values are the ones actually in play and writing them out keeps the page
 * from shifting when the panel's own theme is retuned.
 * ------------------------------------------------------------------------- */
.auth-page {
    --auth-ink: #101828;
    --auth-muted: #5b6475;
    --auth-line: #e3e8ef;
    --auth-surface: #ffffff;
    --auth-accent: var(--bs-primary, #1d6fe0);

    min-height: 100dvh;
    /* The vh line stays first as the fallback for browsers without dvh. */
    min-height: 100vh;
    margin: 0;
    color: var(--auth-ink);
    background-color: #f4f7fb;
    /* Two very soft pools of brand colour, so the page behind the card is not a flat
       grey rectangle. */
    background-image:
        radial-gradient(42rem 26rem at 12% -12%, rgba(var(--bs-primary-rgb), .18), transparent 70%),
        radial-gradient(34rem 24rem at 92% 110%, rgba(var(--bs-primary-rgb), .14), transparent 70%);
    background-repeat: no-repeat;
    background-attachment: fixed;
}

.auth-shell {
    min-height: 100dvh;
    min-height: 100vh;
    display: grid;
    place-items: center;
    /* Clears the notch and the home indicator; the fallback runs first for browsers
       with no env(). */
    padding: 24px 16px;
    padding: max(24px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right))
             max(24px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
}

.auth-card {
    width: 100%;
    max-width: 420px;
    background: var(--auth-surface);
    border: 1px solid var(--auth-line);
    border-radius: 20px;
    overflow: hidden;
    box-shadow: 0 1px 2px rgba(16, 24, 40, .04), 0 24px 56px rgba(16, 24, 40, .12);
}

/* ---- the brand side ---- */

.auth-aside { display: none; }

.auth-aside-brand {
    display: flex;
    align-items: center;
    gap: .625rem;
    min-width: 0;
}

/* The uploaded logo is drawn for a light page and this panel is not one, so it is given
   the light background it was designed against rather than being recoloured.
   `filter: brightness(0) invert(1)` was the obvious move and is wrong: it only produces a
   silhouette for artwork on a *transparent* ground. The logo actually in use is dark
   artwork on an opaque white rectangle, and the filter turns that whole rectangle white -
   a faint white block on blue with the mark erased out of it. A chip reproduces the
   context every logo is authored for, whatever its background. */
.auth-aside-brand img {
    height: 2.25rem;
    width: auto;
    max-width: 9rem;
    object-fit: contain;
    flex: 0 0 auto;
    background: #fff;
    padding: .25rem .4375rem;
    border-radius: .5rem;
    /* The chip is a light surface on a dark panel, so it needs its own edge or it reads as
       a cut-out hole rather than a card. */
    box-shadow: 0 1px 2px rgba(16, 24, 40, .18);
}

/* Drawn when there is no logo: the app's initial in a soft square. */
.auth-aside-mark {
    width: 2.25rem;
    height: 2.25rem;
    flex: 0 0 auto;
    display: grid;
    place-items: center;
    border-radius: .625rem;
    background: rgba(255, 255, 255, .18);
    font-weight: 800;
    font-size: 1.15rem;
}

.auth-aside-name {
    font-size: 1.25rem;
    font-weight: 700;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    min-width: 0;
}

.auth-aside-body h2 {
    font-size: 1.75rem;
    font-weight: 800;
    margin: 0 0 .5rem;
    letter-spacing: -.01em;
}

.auth-aside-body > p {
    margin: 0 0 1.75rem;
    color: rgba(255, 255, 255, .82);
    font-size: .95rem;
}

.auth-notes {
    list-style: none;
    margin: 0;
    padding: 0;
    display: grid;
    gap: 1.125rem;
}

.auth-notes li {
    display: flex;
    align-items: flex-start;
    gap: .75rem;
    font-size: .875rem;
    color: rgba(255, 255, 255, .82);
}

.auth-notes i {
    flex: none;
    font-size: 1rem;
    width: 2rem;
    height: 2rem;
    display: grid;
    place-items: center;
    border-radius: .625rem;
    background: rgba(255, 255, 255, .16);
    color: #fff;
}

.auth-notes b { display: block; color: #fff; font-weight: 700; }

.auth-aside-foot {
    margin: 0;
    font-size: .8rem;
    color: rgba(255, 255, 255, .65);
}

/* ---- the form side ---- */

.auth-main { padding: 2rem 1.5rem 1.5rem; }

/* The logo and the app name share one row and one height, so the pair reads as a
   single lockup instead of an image with a caption beside it. */
.auth-brand {
    --auth-brand-height: 2.25rem;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: .625rem;
    max-width: 100%;
    margin-bottom: 1.5rem;
}

.auth-brand img {
    height: var(--auth-brand-height);
    width: auto;
    max-width: 9rem;
    object-fit: contain;
    flex: 0 0 auto;
}

/* Scales down rather than wrapping, so a long name stays on the logo's line even on
   the narrowest phone. */
.auth-brand-name {
    font-size: clamp(1.15rem, 5.5vw, 1.5rem);
    font-weight: 700;
    line-height: var(--auth-brand-height);
    color: var(--auth-ink);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    min-width: 0;
}

.auth-title {
    font-size: 1.5rem;
    font-weight: 800;
    letter-spacing: -.01em;
    margin: 0 0 .25rem;
    text-align: center;
}

.auth-sub {
    margin: 0 0 1.5rem;
    color: var(--auth-muted);
    font-size: .9rem;
    text-align: center;
}

/* ---- the forgotten-password stepper ----
 *
 * Four steps have to fit at 320px, so only the current step keeps its word: the others
 * are numbered dots, and the number is what a person counts their progress by anyway.
 * The connecting rule is drawn on the item rather than between items so the row keeps
 * working when a step is added or removed. */
.auth-steps {
    display: flex;
    list-style: none;
    margin: 0 0 1.5rem;
    padding: 0;
}

.auth-step {
    flex: 1 1 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: .375rem;
    position: relative;
    font-size: .6875rem;
    font-weight: 700;
    color: var(--auth-muted);
    text-align: center;
    min-width: 0;
}

.auth-step + .auth-step::before {
    content: "";
    position: absolute;
    top: 0.875rem;
    right: 50%;
    left: -50%;
    height: 2px;
    background: var(--auth-line);
}

.auth-step.is-done::before,
.auth-step.is-current::before { background: var(--bs-primary); }

.auth-step-dot {
    position: relative;
    z-index: 1;
    display: grid;
    place-items: center;
    width: 1.75rem;
    height: 1.75rem;
    border-radius: 50%;
    border: 2px solid var(--auth-line);
    background: var(--bs-body-bg, #fff);
    font-size: .75rem;
    line-height: 1;
}

.auth-step.is-done .auth-step-dot,
.auth-step.is-current .auth-step-dot {
    border-color: var(--bs-primary);
    color: #fff;
    background: var(--bs-primary);
}

.auth-step.is-current { color: var(--auth-ink); }

/* Below sm the labels are what overflows first, so they go and the dots carry it. */
.auth-step-label {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    max-width: 100%;
}

@media (max-width: 359.98px) {
    .auth-step:not(.is-current) .auth-step-label { display: none; }
}

/* The address the sequence belongs to, shown on every step after the first: without it
   somebody who mistyped it two screens ago has no way to notice. */
.auth-account {
    margin: 0 0 1.25rem;
    padding: .625rem .875rem;
    border: 1px solid var(--auth-line);
    border-radius: .625rem;
    font-size: .875rem;
    font-weight: 600;
    color: var(--auth-muted);
    overflow-wrap: anywhere;
}

/* The account card on the handoff page - the same details the WhatsApp message carries,
   so the person can check them before sending it. */
.auth-summary {
    border: 1px solid var(--auth-line);
    border-radius: .75rem;
    margin-bottom: 1.25rem;
    font-size: .875rem;
}

.auth-summary-row {
    display: flex;
    flex-wrap: wrap;
    gap: .25rem 1rem;
    justify-content: space-between;
    padding: .625rem .875rem;
}

.auth-summary-row + .auth-summary-row { border-top: 1px solid var(--auth-line); }

.auth-summary-row > span { color: var(--auth-muted); }

.auth-summary-row > b { overflow-wrap: anywhere; text-align: right; }

.auth-field { margin-bottom: 1.125rem; }

.auth-field .form-label {
    font-size: .8125rem;
    font-weight: 700;
    color: var(--auth-muted);
    margin-bottom: .375rem;
}

/* An icon inside the field rather than an input-group addon beside it: the addon
   splits one control into two boxes, and on a 320px screen that is most of the room
   the value had. */
.auth-input { position: relative; }

.auth-input > i {
    position: absolute;
    left: .875rem;
    top: 50%;
    transform: translateY(-50%);
    color: var(--auth-muted);
    font-size: 1rem;
    pointer-events: none;
}

.auth-input .form-control {
    /* 48px: a comfortable touch target, and tall enough that the icon and the value
       are not fighting for the same line. */
    height: 3rem;
    padding-left: 2.75rem;
    padding-right: 1rem;
    border-radius: .75rem;
    border-color: var(--auth-line);
    background: #fbfcfe;
    /* iOS zooms the whole page in when a focused field is under 16px. */
    font-size: 1rem;
}

.auth-input .form-control:focus {
    background: var(--auth-surface);
    border-color: var(--auth-accent);
    box-shadow: 0 0 0 4px rgba(var(--bs-primary-rgb), .14);
}

/* Room for the eye button so a long password never runs underneath it. */
#password.form-control { padding-right: 3rem; }

.auth-eye {
    position: absolute;
    right: .375rem;
    top: 50%;
    transform: translateY(-50%);
    width: 2.5rem;
    height: 2.5rem;
    display: grid;
    place-items: center;
    border: 0;
    border-radius: .5rem;
    background: transparent;
    color: var(--auth-muted);
    cursor: pointer;
}

.auth-eye:hover { color: var(--auth-ink); background: rgba(16, 24, 40, .05); }
.auth-eye:focus-visible { outline: 2px solid var(--auth-accent); outline-offset: 1px; }

.auth-remember {
    margin: 0 0 1.25rem;
    font-size: .875rem;
    color: var(--auth-muted);
}

.auth-remember .form-check-input { border-color: #c6cedb; }

.auth-submit {
    height: 3rem;
    border-radius: .75rem;
    font-weight: 700;
    font-size: 1rem;
    box-shadow: 0 8px 18px rgba(var(--bs-primary-rgb), .28);
}

.auth-foot {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: center;
    gap: .25rem 1rem;
    margin-top: 1.5rem;
    padding-top: 1rem;
    border-top: 1px solid var(--auth-line);
    font-size: .8125rem;
    color: var(--auth-muted);
}

/* A 44px target on a phone: this is the way back to the public site for someone who
   arrived on the wrong page, and it sits at the very bottom of the screen. */
.auth-foot a {
    display: inline-flex;
    align-items: center;
    min-height: 2.75rem;
    padding-inline: .25rem;
}

/* The caps-lock hint appears mid-form. Reserving its line would leave a gap on every
   other visit, so it takes the space only when shown - the fields below simply move
   down, which is also what draws the eye to it. */
[data-caps-hint] { font-weight: 600; }

/* ---- from lg: the two-panel card ---- */

@media (min-width: 992px) {
    /* A desktop window is mostly empty around a 960px card, so the ground behind it does
       more work here: the two brand pools are joined by a very faint rule grid that gives
       the emptiness a scale and stops the shadow floating on nothing. */
    .auth-page {
        background-image:
            radial-gradient(42rem 26rem at 12% -12%, rgba(var(--bs-primary-rgb), .18), transparent 70%),
            radial-gradient(34rem 24rem at 92% 110%, rgba(var(--bs-primary-rgb), .14), transparent 70%),
            linear-gradient(rgba(16, 24, 40, .028) 1px, transparent 1px),
            linear-gradient(90deg, rgba(16, 24, 40, .028) 1px, transparent 1px);
        background-size: auto, auto, 64px 64px, 64px 64px;
        background-position: 0 0, 0 0, center, center;
        /* The base rule is a single no-repeat for the two pools; the grid layers are the
           only ones that tile, and one value would apply to all four. */
        background-repeat: no-repeat, no-repeat, repeat, repeat;
    }

    .auth-card {
        max-width: 960px;
        display: grid;
        /* The brand side is fixed-ish and the form side takes the rest, so the form
           never becomes the narrower of the two. */
        grid-template-columns: 1fr 1fr;
        border-radius: 24px;
        /* Three stacked layers rather than one: a hairline, a close contact shadow and a
           wide soft one. A single large blur reads as grey haze at this size. */
        box-shadow:
            0 0 0 1px rgba(16, 24, 40, .03),
            0 2px 6px rgba(16, 24, 40, .05),
            0 32px 72px -20px rgba(16, 24, 40, .28);
    }

    .auth-aside {
        display: flex;
        flex-direction: column;
        justify-content: space-between;
        gap: 2rem;
        padding: 3rem;
        color: #fff;
        /* The fill must read as one solid blue. An earlier pass stacked a dot field and a
           second glow on top of the ramp; three white washes over the same 480px column
           mottle it, and the panel looks unevenly filled rather than lit. One ramp, one
           restrained corner light, nothing else. */
        background:
            radial-gradient(24rem 18rem at 108% -10%, rgba(255, 255, 255, .18), transparent 66%),
            linear-gradient(150deg, var(--auth-accent), #17539f 62%, #10386e);
        position: relative;
    }

    .auth-aside-body h2 {
        font-size: 2rem;
        letter-spacing: -.02em;
    }

    .auth-aside-body > p {
        margin-bottom: 2rem;
        font-size: 1rem;
        max-width: 24rem;
    }

    /* The forgotten-password steps share this panel and carry no list under the blurb, so
       there the paragraph is the last thing in the body and owes nothing below it. */
    .auth-aside-body > p:last-child { margin-bottom: 0; }

    /* The three lines become cards on this width. As bare list items on a large panel they
       drifted in the middle of it with nothing setting their edge; a translucent surface
       gives each one a shape and lets the gradient show through it. */
    .auth-notes { gap: .625rem; }

    .auth-notes li {
        gap: .875rem;
        padding: .875rem 1rem;
        border-radius: .875rem;
        /* Kept light: the border is what gives each one its shape, so the fill only has to
           separate it from the panel - not lighten the blue behind it. */
        background: rgba(255, 255, 255, .06);
        border: 1px solid rgba(255, 255, 255, .14);
        line-height: 1.45;
    }

    .auth-notes i {
        background: rgba(255, 255, 255, .2);
        box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .12);
    }

    .auth-notes b { margin-bottom: .125rem; }

    /* The form column is the shorter of the two, so it is centred against the brand panel
       instead of hanging from the top with the difference left at the bottom. */
    .auth-main {
        display: flex;
        flex-direction: column;
        justify-content: center;
        padding: 3.25rem 3.5rem;
    }

    /* The brand is already on the panel beside it, so on this width the lockup is a
       repeat. The heading carries the page from here. */
    .auth-brand { display: none; }

    .auth-title,
    .auth-sub { text-align: left; }

    .auth-title { font-size: 1.75rem; }
    .auth-sub { margin-bottom: 1.75rem; }

    /* A pointer can rest on a field before committing to it, which a finger cannot - so the
       border answers on hover, and the focus ring grows into place rather than snapping. */
    .auth-input .form-control {
        height: 3.125rem;
        transition: border-color .15s ease, box-shadow .15s ease, background-color .15s ease;
    }

    .auth-input .form-control:hover:not(:focus) { border-color: #c9d2e0; }

    .auth-eye { transition: color .15s ease, background-color .15s ease; }

    .auth-submit {
        height: 3.125rem;
        /* A flat fill this large reads as a placeholder; the ramp is slight enough that it
           still matches the primary buttons inside the panel. */
        background-image: linear-gradient(180deg, rgba(255, 255, 255, .12), rgba(0, 0, 0, .04));
        /* No brightness filter on hover: btn-primary already darkens its own fill there,
           and the two pull against each other. The lift is the whole response. */
        transition: transform .15s ease, box-shadow .15s ease;
    }

    .auth-submit:hover:not(.disabled) {
        transform: translateY(-1px);
        box-shadow: 0 12px 24px rgba(var(--bs-primary-rgb), .34);
    }

    .auth-submit:active:not(.disabled) {
        transform: translateY(0);
        box-shadow: 0 6px 14px rgba(var(--bs-primary-rgb), .26);
    }

    .auth-foot {
        justify-content: space-between;
        margin-top: 1.75rem;
    }

    /* On a phone the footer link is a 44px target and its underline is noise. With a
       pointer the underline is the affordance, so it arrives on hover. */
    .auth-foot a { text-decoration: none; }
    .auth-foot a:hover { text-decoration: underline; }
}

/* The card arrives rather than appearing: under half a second of movement on the one screen
   a person waits at, and nothing else on the page moves with it. */
@media (min-width: 992px) and (prefers-reduced-motion: no-preference) {
    .auth-card {
        animation: auth-card-in .45s cubic-bezier(.22, 1, .36, 1) both;
    }

    @keyframes auth-card-in {
        from { opacity: 0; transform: translateY(12px) scale(.995); }
        to { opacity: 1; transform: none; }
    }
}

/* ---------------------------------------------------------------------------
 * Sidebar scrolling on phones
 *
 * Below lg the sidebar is a fixed overlay that AdminLTE sizes with 100vh. On a
 * phone 100vh is the *largest* viewport - it counts the strip behind the address
 * bar and bottom toolbar - so the menu runs past the visible area and its last
 * items (Server Setup, My Profile) cannot be scrolled to. Dynamic viewport units
 * measure what is actually on screen; the vh line stays first as the fallback for
 * browsers without dvh, and the extra bottom padding clears the home indicator on
 * notched devices.
 * ------------------------------------------------------------------------- */
@media (max-width: 991.98px) {
    .sidebar-expand-lg .app-sidebar {
        max-height: 100vh;
        max-height: 100dvh;
    }

    .sidebar-expand-lg .app-sidebar .sidebar-wrapper {
        height: calc(100vh - 3.5rem - 1px);
        height: calc(100dvh - 3.5rem - 1px);
        padding-bottom: calc(0.5rem + env(safe-area-inset-bottom, 0px));
        -webkit-overflow-scrolling: touch;
    }
}

/* ---------------------------------------------------------------------------
 * Touch scrolling
 *
 * Wide tables scroll sideways inside .table-responsive rather than wrapping - see
 * the action-bar notes above - and on a touch screen that sideways drag competes
 * with the browser's own panning and, inside the Android app, with pull-to-refresh.
 * Naming the axes a container handles is what settles it: the gesture is delivered
 * to the element instead of being arbitrated after the fact, and a drag that runs
 * off the end of a table stops there instead of dragging the page behind it.
 *
 * The app disables its refresh gesture for the length of any touch that starts in
 * one of these - MainActivity.injectSupportScript() - which is the other half.
 * ------------------------------------------------------------------------- */
.table-responsive {
    -webkit-overflow-scrolling: touch;
    touch-action: pan-x pan-y;
    overscroll-behavior-x: contain;
}

.app-sidebar .sidebar-wrapper {
    touch-action: pan-y;
    overscroll-behavior: contain;
}

/* ---------------------------------------------------------------------------
 * Sidebar section headings
 * ------------------------------------------------------------------------- */
.sidebar-menu .nav-header {
    padding: 1rem 1rem .35rem;
    font-size: .7rem;
    font-weight: 700;
    letter-spacing: .09em;
    text-transform: uppercase;
    color: color-mix(in srgb, var(--bs-secondary-color) 88%, var(--mtik-gilt, #b89b6a));
}

.mtik-app .sidebar-menu .nav-link {
    border-radius: 0.65rem;
    margin: 0.1rem 0.45rem;
    padding-top: 0.55rem;
    padding-bottom: 0.55rem;
    transition: background-color 0.15s ease, color 0.15s ease;
}

/* The sidebar is pinned to data-bs-theme="dark" in the layout, so its own colours
 * are constant - only the active marker needs help standing out. */
.mtik-app .sidebar-menu .nav-link.active {
    background-color: rgba(99, 102, 241, .22);
    color: #fff;
    border-left: 3px solid var(--mtik-gilt, #b89b6a);
    box-shadow: none;
}

.sidebar-menu .nav-link.active {
    background-color: rgba(99, 102, 241, .22);
    color: #fff;
    border-left: 3px solid #6366f1;
}

.mtik-app .form-control,
.mtik-app .form-select {
    border-radius: 0.65rem;
    min-height: 2.35rem;
}

@media (max-width: 767.98px) {
    .mtik-app .form-control,
    .mtik-app .form-select,
    .mtik-app .btn {
        min-height: 2.75rem;
    }

    .mtik-app .btn-sm {
        min-height: 2.35rem;
    }
}

.mtik-app .table {
    --bs-table-hover-bg: color-mix(in srgb, var(--mtik-gilt, #b89b6a) 8%, var(--bs-table-bg));
}

.mtik-app .table thead th {
    font-size: 0.72rem;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--bs-secondary-color);
    border-bottom-width: 1px;
}

.sidebar-menu .nav-header:first-child {
    padding-top: .35rem;
}

/* ---------------------------------------------------------------------------
 * Sidebar page search - public/js/sidebar-search.js
 *
 * Sticky so it stays in reach while a long menu scrolls under it. 16px text on
 * phones: anything smaller and iOS zooms the whole page in on focus. The clear
 * button is a full 44px target for a thumb.
 * ------------------------------------------------------------------------- */
.sidebar-search {
    position: sticky;
    top: 0;
    z-index: 2;
    padding: 0.6rem 0.55rem 0.4rem;
    background: var(--bs-body-bg);
}

.sidebar-search__icon {
    position: absolute;
    left: 1.25rem;
    top: 50%;
    transform: translateY(-50%);
    margin-top: 0.1rem;
    color: var(--bs-secondary-color);
    pointer-events: none;
}

.mtik-app .sidebar-search__input {
    padding-left: 2.1rem;
    padding-right: 2.5rem;
    background-color: color-mix(in srgb, var(--bs-body-bg) 80%, #fff);
    border-color: color-mix(in srgb, var(--bs-border-color) 70%, var(--mtik-gilt, #b89b6a));
}

.sidebar-search__input::-webkit-search-cancel-button {
    display: none;
}

.sidebar-search__clear {
    position: absolute;
    right: 0.6rem;
    top: 50%;
    transform: translateY(-50%);
    margin-top: 0.1rem;
    width: 2.5rem;
    height: 2.5rem;
    border: 0;
    background: transparent;
    color: var(--bs-secondary-color);
}

.sidebar-search__empty {
    margin: 0.5rem 1rem;
    font-size: 0.85rem;
    color: var(--bs-secondary-color);
}

.sidebar-search-hidden {
    display: none !important;
}

/* While searching every matching submenu is laid open - a hit hidden inside a
 * collapsed group is no hit at all - and Enter's target is marked. */
.sidebar-menu.is-searching .nav-treeview {
    display: block !important;
}

.sidebar-menu.is-searching .nav-arrow {
    display: none;
}

.mtik-app .sidebar-menu .nav-link.sidebar-search-first:not(.active) {
    background-color: rgba(255, 255, 255, .08);
}

@media (max-width: 991.98px) {
    .mtik-app .sidebar-search__input {
        font-size: 16px;
        min-height: 2.75rem;
    }
}

/* ---------------------------------------------------------------------------
 * Theme toggle
 * ------------------------------------------------------------------------- */
.theme-toggle .bi { font-size: 1.05rem; }

/* Show the icon matching the ACTIVE theme, hide the other. */
[data-bs-theme="light"] .theme-toggle [data-theme-icon="dark"] { display: none; }
[data-bs-theme="dark"] .theme-toggle [data-theme-icon="light"] { display: none; }

/* ---------------------------------------------------------------------------
 * Disclosure panels as popups
 *
 * Every "Add X" / "Edit X" / "View" form on the Configure page (and anywhere else
 * that uses form-disclosure.js) used to expand in place, pushing everything below
 * it down the page - awkward once a panel has more than a couple of rows above its
 * form. Styled here as a centred dialog instead. The open/close logic and every
 * panel's own field-population script (router-bridge-form.js, router-port-detail.js,
 * hotspot-wizard.js, ...) are completely unchanged - only where the content renders
 * is different, because they all share this one generic [data-disclosure] contract.
 * ------------------------------------------------------------------------- */
.disclosure-backdrop {
    position: fixed;
    inset: 0;
    /* Above AdminLTE's sidebar (1038) and Bootstrap's own modal (1055) would sit,
     * below the job-in-progress veil (2000) so a submit from inside a popup still
     * shows through it. */
    z-index: 1050;
    background-color: rgba(15, 23, 42, .55);
    -webkit-backdrop-filter: blur(2px);
    backdrop-filter: blur(2px);
    opacity: 0;
    visibility: hidden;
    transition: opacity .15s ease, visibility 0s linear .15s;
}

.disclosure-backdrop.is-active {
    opacity: 1;
    visibility: visible;
    transition: opacity .15s ease, visibility 0s linear 0s;
}

/* Every disclosure panel, regardless of which page or how deep in the markup it
 * sits - position:fixed lifts it out of the document flow entirely, which is what
 * turns "expands the page" into "floats over it". Hidden state (.d-none) still
 * wins over this, same as before. */
[data-disclosure] {
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    z-index: 1060;
    width: calc(100vw - 2rem);
    max-width: 700px;
    max-height: calc(100vh - 2rem);
    overflow-y: auto;
    margin: 0 !important;
    /* The popup paints its own surface. Panels that wrap their form in a .card brought a
     * background with them, but the ones built from a bare "border rounded p-3" form - the
     * hotspot profile, PPPoE, DHCP and landing-page forms - had none, so the page showed
     * straight through the dialog. */
    background-color: var(--bs-body-bg);
    border-radius: var(--bs-border-radius-lg);
    box-shadow: 0 1.25rem 3rem rgba(15, 23, 42, .35);
    padding: 1rem;
}

/* A panel that is a card already has its own edge and padding; framing it again would
 * draw a box inside a box. */
[data-disclosure]:has(> .card) {
    padding: 0;
}

/* Own class rather than reusing .mtik-loading (the job-in-progress veil below): a submit
 * made from inside an open popup must still be able to show that veil on top of this. */
body.disclosure-open { overflow: hidden; }

@media (prefers-reduced-motion: reduce) {
    .disclosure-backdrop { transition-duration: .01ms; }
}

/* ---------------------------------------------------------------------------
 * Dashboard scope banner on a phone
 *
 * On a wide screen the router's name, its chips and the actions share one row. On a phone
 * that row wrapped wherever each control happened to end, leaving selects at their desktop
 * width and buttons of every size with gaps at the end of each line. Here every control
 * takes the card's full width: the router and period filters one row each, the buttons two
 * to a row at equal width. Flex rather than grid so the set can vary - a reseller has no
 * router filter, a PPPoE site a different main button - and an odd last button simply
 * grows across its row instead of leaving a hole beside it.
 * ------------------------------------------------------------------------- */
@media (max-width: 767.98px) {
    .scope-banner {
        gap: .75rem !important;
    }

    .scope-banner__identity,
    .scope-banner > .d-inline-flex {
        width: 100%;
    }

    .scope-banner__actions {
        width: 100%;
        margin-left: 0 !important;
        gap: .5rem !important;
    }

    .scope-banner__actions > form {
        flex: 1 1 100%;
        min-width: 0;
    }

    /* The selects carry an inline desktop width; only !important outranks it. */
    .scope-banner__actions .form-select,
    .scope-banner__actions .form-control {
        flex: 1 1 0;
        width: 100% !important;
        min-width: 0 !important;
    }

    .scope-banner__actions > .btn {
        flex: 1 1 calc(50% - .25rem);
        display: inline-flex;
        align-items: center;
        justify-content: center;
        min-width: 0;
        padding: .5rem .6rem;
        white-space: nowrap;
    }
}

/* ---------------------------------------------------------------------------
 * Loading veil
 *
 * Everything in this panel is a round trip to a router over a WireGuard tunnel:
 * applying a configuration, generating a batch, killing a session. Those take
 * seconds, not milliseconds, and before this the only feedback was the browser's
 * own thin progress line - operators clicked twice, which on a router means the
 * job runs twice.
 *
 * So the whole screen is taken over: the page behind is blurred and dimmed (which
 * also makes it unclickable), and one animation with one line of text says what is
 * happening. The result is announced afterwards by the toast below, on the page
 * that loads next, so the veil never has to report anything itself.
 *
 * Colours are the brand indigo warming into cyan - the same pairing as .btn-brand
 * and the sidebar active marker. Both are saturated enough to sit on either theme,
 * so this section hard-codes them, per the note at the top of this file. The
 * backdrop itself is built from --bs-body-bg-rgb and follows the theme.
 * ------------------------------------------------------------------------- */
.mtik-loader {
    position: fixed;
    inset: 0;
    /* Above AdminLTE's sidebar (1038) and Bootstrap's modal (1055): a job started
     * from inside a modal must still veil the modal. */
    z-index: 2000;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 1.5rem;
    background-color: rgba(var(--bs-body-bg-rgb), .74);
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
    /* The visibility delay is what keeps the veil in the accessibility tree and
     * clickable for the length of the fade-out instead of vanishing mid-fade. */
    transition: opacity .26s ease, visibility 0s linear .26s;
}

/* Blur only while the veil is up. Declared on the hidden overlay it promoted a
 * full-viewport compositor layer on every page and some GPUs painted a solid sheet. */
.mtik-loader.is-active {
    opacity: 1;
    visibility: visible;
    pointer-events: auto;
    -webkit-backdrop-filter: blur(9px) saturate(130%);
    backdrop-filter: blur(9px) saturate(130%);
    transition: opacity .2s ease, visibility 0s linear 0s;
}

/* Sign-in is always on a light page; a body-coloured scrim vanishes into it. */
.auth-page .mtik-loader.is-active {
    background-color: rgba(16, 24, 40, .32);
}

.mtik-loader:not(.is-active) * {
    animation-play-state: paused;
}

/* Set on <body> for as long as the veil is up. A page that still scrolls behind
 * the blur reads as the click having missed. */
body.mtik-loading { overflow: hidden; }

.mtik-loader-inner {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 1.15rem;
    text-align: center;
    transform: translateY(10px) scale(.96);
    transition: transform .28s cubic-bezier(.16, 1, .3, 1);
}

.mtik-loader.is-active .mtik-loader-inner { transform: none; }

/* ------------------------------------------------------- the signal emitter */
.mtik-signal {
    position: relative;
    display: grid;
    place-items: center;
    width: 136px;
    height: 136px;
}

/* Radar sweep. A conic gradient masked down to a 3px ring, spun as a whole - one
 * element rather than a bordered circle, so the head of the sweep can fade out
 * behind itself instead of being a hard arc. */
.mtik-signal-sweep {
    position: absolute;
    inset: 0;
    border-radius: 50%;
    background: conic-gradient(from 0deg,
        rgba(79, 70, 229, 0) 0deg,
        rgba(79, 70, 229, 0) 190deg,
        rgba(79, 70, 229, .35) 280deg,
        #4f46e5 340deg,
        #22d3ee 360deg);
    -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2px));
    mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2px));
    animation: mtik-sweep 1.25s linear infinite;
}

/* Three broadcast rings leaving the core, staggered a third of the cycle apart so
 * there is always one mid-flight. */
.mtik-signal-ring {
    position: absolute;
    inset: 0;
    border-radius: 50%;
    border: 2px solid rgba(34, 211, 238, .5);
    opacity: 0;
    animation: mtik-broadcast 2.4s ease-out infinite;
}

.mtik-signal-ring:nth-child(3) { animation-delay: .8s; }
.mtik-signal-ring:nth-child(4) { animation-delay: 1.6s; }

.mtik-signal-core {
    position: relative;
    display: grid;
    place-items: center;
    width: 64px;
    height: 64px;
    border-radius: 50%;
    background: linear-gradient(145deg, #4f46e5, #7c3aed 52%, #22d3ee);
    color: #fff;
    font-size: 1.65rem;
    line-height: 1;
    box-shadow: 0 12px 32px rgba(79, 70, 229, .42);
    animation: mtik-breathe 2s ease-in-out infinite;
}

@keyframes mtik-sweep {
    to { transform: rotate(360deg); }
}

@keyframes mtik-broadcast {
    0%   { transform: scale(.44); opacity: 0; }
    14%  { opacity: .85; }
    100% { transform: scale(1.12); opacity: 0; }
}

@keyframes mtik-breathe {
    0%, 100% { transform: scale(1); box-shadow: 0 12px 32px rgba(79, 70, 229, .42); }
    50%      { transform: scale(1.06); box-shadow: 0 16px 40px rgba(34, 211, 238, .45); }
}

/* ------------------------------------------------------------- the caption */
.mtik-loader-label {
    margin: 0;
    max-width: 30ch;
    font-size: 1.02rem;
    font-weight: 600;
    letter-spacing: .01em;
    color: var(--bs-emphasis-color);
}

.mtik-loader-bar {
    width: min(240px, 62vw);
    height: 4px;
    border-radius: 999px;
    background-color: var(--bs-secondary-bg);
    overflow: hidden;
}

.mtik-loader-bar > span {
    display: block;
    width: 40%;
    height: 100%;
    border-radius: inherit;
    background: linear-gradient(90deg, rgba(79, 70, 229, 0), #4f46e5, #22d3ee, rgba(34, 211, 238, 0));
    animation: mtik-track 1.5s ease-in-out infinite;
}

@keyframes mtik-track {
    0%   { transform: translateX(-115%); }
    100% { transform: translateX(365%); }
}

/* Shown only once a job has run long enough to look stuck, so the operator knows
 * to keep waiting rather than reload and fire the whole thing a second time. */
.mtik-loader-hint {
    margin: -.35rem 0 0;
    max-width: 34ch;
    font-size: .82rem;
    color: var(--bs-secondary-color);
}

/* Motion here is decoration, not information - the caption and the veil already
 * carry the whole message, so for anyone who has asked for less of it the moving
 * parts stop and the ring is simply drawn. */
@media (prefers-reduced-motion: reduce) {
    .mtik-loader,
    .mtik-loader-inner { transition-duration: .01ms; }

    .mtik-signal-sweep,
    .mtik-signal-core { animation: none; }

    /* Full width rather than the travelling 40% chunk: parked at the left it would
     * read as a progress bar that has stopped, which is the opposite of the truth. */
    .mtik-loader-bar > span {
        width: 100%;
        opacity: .55;
        animation: none;
    }

    .mtik-signal-ring { opacity: .5; animation: none; }
    .mtik-signal-ring:nth-child(3) { transform: scale(.78); }
    .mtik-signal-ring:nth-child(4) { transform: scale(.56); }

    .mtik-signal-sweep {
        background: none;
        border: 3px solid #4f46e5;
    }
}

/* ---------------------------------------------------------------------------
 * Result toasts
 *
 * The counterpart to the veil: the veil says a job is running, this says how it
 * went. It replaces the inline "alert alert-info" that every list page used to
 * print above its table - that alert reflowed the page on arrival and was easy to
 * scroll past, and after a redirect it was the only sign anything had happened.
 *
 * Rendered once in the layout from session('status'), so a controller only has to
 * keep doing ->with('status', ...) and the message lands here on whatever page the
 * redirect chose. Top-centre: the veil clears at the top of the page, so that is
 * where the operator is already looking when the result arrives - and the middle is
 * the one spot that reads the same on a phone as on a desktop, where the right-hand
 * corner sat over the router scope and the user menu.
 *
 * One region for every toast, so the result and the account notice stack rather than
 * landing on top of each other.
 * ------------------------------------------------------------------------- */
.mtik-toast-region {
    position: fixed;
    left: 0;
    right: 0;
    top: 0;
    z-index: 1090;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: .5rem;
    padding: 1rem;
    padding-top: calc(1rem + env(safe-area-inset-top, 0px));
    /* The region outlives the toast inside it; without this its padding stays
     * behind as a dead strip that swallows clicks on whatever is under it. */
    pointer-events: none;
}

/* A redirect lands at the top of the page, where the header is - so clear its height
 * (navbar padding + the 2.5rem nav-link) rather than parking the toast over the
 * brand and the search. The sign-in page has no header, which is why this is not on
 * the region itself. */
body:not(.auth-page) .mtik-toast-region {
    padding-top: calc(3.5rem + .75rem + env(safe-area-inset-top, 0px));
}

.mtik-toast {
    position: relative;
    pointer-events: auto;
    width: min(24rem, calc(100vw - 2rem));
    border: 1px solid var(--bs-border-color);
    border-radius: .7rem;
    background-color: var(--bs-body-bg);
    box-shadow: 0 18px 45px rgba(0, 0, 0, .22);
    overflow: hidden;
}

/* The accent edge does the colour-coding, so the body stays on the theme's own
 * surface and the text keeps full contrast in dark mode - which is where the
 * tinted alert backgrounds were washing out. */
.mtik-toast::before {
    content: "";
    position: absolute;
    inset: 0 auto 0 0;
    width: 4px;
    background: linear-gradient(180deg, #4f46e5, #22d3ee);
}

.mtik-toast-body {
    display: flex;
    align-items: flex-start;
    gap: .7rem;
    padding: .85rem 1rem .85rem 1.1rem;
}

.mtik-toast-icon {
    flex: 0 0 auto;
    display: grid;
    place-items: center;
    width: 1.9rem;
    height: 1.9rem;
    border-radius: 50%;
    background: linear-gradient(145deg, #4f46e5, #22d3ee);
    color: #fff;
    font-size: .95rem;
    line-height: 1;
}

.mtik-toast-text {
    flex: 1 1 auto;
    min-width: 0;
}

.mtik-toast-title {
    font-size: .72rem;
    font-weight: 700;
    letter-spacing: .08em;
    text-transform: uppercase;
    color: var(--bs-secondary-color);
}

.mtik-toast-message {
    color: var(--bs-body-color);
    word-break: break-word;
}

/* The remaining life of the toast, drawn rather than described: it is the only
 * cue that the message is about to leave on its own. */
.mtik-toast-life {
    height: 3px;
    background: linear-gradient(90deg, #4f46e5, #22d3ee);
    transform-origin: left center;
    animation: mtik-toast-life var(--mtik-toast-life, 7s) linear forwards;
}

@keyframes mtik-toast-life {
    from { transform: scaleX(1); }
    to   { transform: scaleX(0); }
}

@media (prefers-reduced-motion: reduce) {
    .mtik-toast-life { animation: none; }
}

/* ---------------------------------------------------------------------------
 * The notification bell's menu
 *
 * It used to be a plain dropdown with no height limit, so a bell with a dozen
 * unread items grew taller than the screen and the bottom of it - "View all" and
 * "Mark all read" - sat below the fold with nothing to scroll. On a phone that is
 * most of the menu. The fix is a column: the title row and the actions row are
 * pinned, and only the list between them scrolls.
 *
 * min-width yields on a narrow phone rather than pushing the menu off the side,
 * which is the other half of the same complaint.
 * ------------------------------------------------------------------------- */
.notification-menu {
    display: none;
    min-width: min(22rem, calc(100vw - 1.5rem));
    max-width: min(95vw, 26rem);
    /* Leaves the header bar and a little breathing room below the menu. */
    max-height: calc(100vh - 5rem);
    max-height: calc(100dvh - 5rem);
    padding: 0;
    /* Clips the scrolling list to the menu's own rounded corners. */
    overflow: hidden;
}

/* Bootstrap toggles .show; the flex layout only applies once it is open, so the
 * closed menu stays display:none rather than becoming a visible empty column. */
.notification-menu.show {
    display: flex;
    flex-direction: column;
}

.notification-menu__head,
.notification-menu__foot {
    flex: 0 0 auto;
    padding: 0.5rem 1rem;
}

.notification-menu__list {
    flex: 1 1 auto;
    overflow-y: auto;
    /* A flex child will not shrink below its content without this, which would
     * put the scrollbar back on the page instead of inside the list. */
    min-height: 0;
    -webkit-overflow-scrolling: touch;
    /* Reaching the end of the list must not start scrolling the dashboard behind
     * it, and inside the Android app must not become a pull-to-refresh. */
    overscroll-behavior: contain;
    touch-action: pan-y;
}

/* On a phone the menu is pinned to the viewport rather than hung off the bell.
 *
 * The bell sits a few icons in from the right edge, so a menu aligned to it and
 * wider than the space remaining runs off the left of the screen - which is what
 * "half of it is hidden" was. Popper would normally shove it back into view, but
 * it writes position, inset and transform inline, and the toggle now carries
 * data-bs-display="static" to keep it out of this. That leaves placement to these
 * rules: one panel, inset from both edges, directly under the header.
 *
 * !important because .dropdown-menu-end and Bootstrap's own .dropdown-menu set the
 * same properties, and this has to win regardless of which order they land in. */
@media (max-width: 767.98px) {
    .notification-menu {
        position: fixed !important;
        top: 3.9rem !important;
        right: 0.75rem !important;
        left: 0.75rem !important;
        bottom: auto !important;
        width: auto !important;
        min-width: 0 !important;
        max-width: none !important;
        margin: 0 !important;
        transform: none !important;
        max-height: calc(100vh - 5rem);
        max-height: calc(100dvh - 5rem);
    }
}

/* ============================================================================
 * Network map
 *
 * The superadmin's live picture of a site. Everything is built from --bs-*
 * variables or from the five status colours below, which are declared once and
 * flipped as a set for dark mode - a node, its edge, its pip and its entry in
 * the fault list all have to agree on what "down" looks like, and they only do
 * that reliably if they read the same variable.
 * ========================================================================= */

.netmap-card {
    --netmap-ok: #16a34a;
    --netmap-warn: #d97706;
    --netmap-down: #dc2626;
    --netmap-unreachable: #9ca3af;
    --netmap-idle: #94a3b8;
    --netmap-line: var(--bs-border-color);
    --netmap-node-bg: var(--bs-body-bg);
    --netmap-grid: rgba(100, 116, 139, .16);
}

[data-bs-theme="dark"] .netmap-card {
    --netmap-ok: #4ade80;
    --netmap-warn: #fbbf24;
    --netmap-down: #f87171;
    --netmap-unreachable: #6b7280;
    --netmap-idle: #64748b;
    --netmap-node-bg: var(--bs-secondary-bg);
    --netmap-grid: rgba(148, 163, 184, .12);
}

/* --- the small-screen swap ------------------------------------------------ */

.netmap-narrow { display: none; }
.netmap-narrow-icon { font-size: 2.5rem; color: var(--bs-secondary-color); }

@media (max-width: 991.98px) {
    .netmap-narrow { display: block; }
    .netmap-card { display: none; }
}

/* --- header and summary --------------------------------------------------- */

.netmap-search { width: 15rem; max-width: 40vw; }
.netmap-stamp { font-variant-numeric: tabular-nums; }

[data-netmap-refresh].is-busy .bi-arrow-repeat,
.netmap-spin {
    animation: netmap-spin 1s linear infinite;
    display: inline-block;
}

/* Building the PDF re-reads the router, so the button has to refuse a second press for as
   long as that takes rather than reading it as an order for a second copy. */
[data-netmap-pdf].is-busy { pointer-events: none; opacity: .65; }

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

.netmap-summary {
    display: flex;
    gap: .5rem;
    padding: .65rem 1rem;
    border-bottom: 1px solid var(--bs-border-color);
    flex-wrap: wrap;
}

.netmap-stat {
    display: flex;
    flex-direction: column;
    min-width: 5.5rem;
    padding: .35rem .75rem;
    border: 1px solid var(--bs-border-color);
    border-radius: .5rem;
    background: var(--bs-tertiary-bg);
}

.netmap-stat-value { font-size: 1.15rem; font-weight: 600; line-height: 1.2; font-variant-numeric: tabular-nums; }
.netmap-stat-label { font-size: .75rem; color: var(--bs-secondary-color); text-transform: uppercase; letter-spacing: .03em; }

.netmap-stat[data-netmap-stat="critical"].is-active { border-color: var(--netmap-down); }
.netmap-stat[data-netmap-stat="critical"].is-active .netmap-stat-value { color: var(--netmap-down); }
.netmap-stat[data-netmap-stat="warning"].is-active { border-color: var(--netmap-warn); }
.netmap-stat[data-netmap-stat="warning"].is-active .netmap-stat-value { color: var(--netmap-warn); }

/* --- the stage ------------------------------------------------------------ */

.netmap-body { display: flex; align-items: stretch; min-height: 0; }

/* A floor, not a height. The script sets an explicit height from the size the drawing
   turned out to be, so the card grows to hold the map at a readable scale and the browser
   page scrolls - rather than the map being shrunk into 74vh until its labels are dust.
   This is what is left when there is nothing to draw yet, or almost nothing. */
.netmap-stage {
    position: relative;
    flex: 1 1 auto;
    min-width: 0;
    min-height: min(74vh, 780px);
    overflow: hidden;
    background-color: var(--bs-body-bg);
    background-image: radial-gradient(var(--netmap-grid) 1px, transparent 1px);
    background-size: 22px 22px;
}

.netmap-svg { width: 100%; height: 100%; cursor: grab; display: block; }
.netmap-svg.is-panning { cursor: grabbing; }
.netmap-svg [data-netmap-viewport].is-animating { transition: transform .3s ease; }

.netmap-state {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    text-align: center;
    padding: 2rem;
    z-index: 2;
}

.netmap-state-icon { font-size: 2.25rem; color: var(--bs-secondary-color); display: block; margin-bottom: .75rem; }
.netmap-state-text { color: var(--bs-secondary-color); max-width: 32rem; }
.netmap-state[data-mode="spin"] .netmap-state-icon { animation: netmap-pulse 1.4s ease-in-out infinite; }
.netmap-state[data-mode="error"] .netmap-state-icon { color: var(--netmap-down); }
.netmap-state[data-mode="error"] .netmap-state-text { color: var(--netmap-down); }

@keyframes netmap-pulse { 0%, 100% { opacity: .35; } 50% { opacity: 1; } }

.netmap-controls {
    position: absolute;
    right: .75rem;
    bottom: .75rem;
    display: flex;
    flex-direction: column;
    gap: .25rem;
    z-index: 3;
}

.netmap-controls .btn { box-shadow: 0 1px 3px rgba(0, 0, 0, .18); }

/* --- the screen of its own -------------------------------------------------
 *
 * network-map/full.blade.php: the same map as a document with no panel around it. The
 * page is the map, so the card loses its box and the stage loses its fixed height and
 * takes everything the bar above it does not.
 *
 * Making the card fixed inside the panel page was tried first and is what a card can
 * never quite do: the sidebar, the navbar and the container each have a say in how big
 * their descendants get, and the result was a map that covered the menu without ever
 * really filling the screen. */
body.netmap-page {
    margin: 0;
    height: 100vh;
    overflow: hidden;
}

.netmap-card-bare {
    display: flex;
    flex-direction: column;
    height: 100vh;
}

.netmap-card-bare .netmap-body {
    flex: 1 1 auto;
    /* The drawer is positioned against this. */
    position: relative;
}

.netmap-card-bare .netmap-stage { height: auto; }

.netmap-bar {
    display: flex;
    align-items: center;
    gap: .5rem;
    flex-wrap: wrap;
    padding: .5rem .75rem;
    border-bottom: 1px solid var(--bs-border-color);
    background: var(--bs-body-bg);
}

.netmap-bar-title { display: inline-flex; align-items: center; gap: .4rem; }
.netmap-bar-title .bi { color: var(--bs-secondary-color); }

/* The only piece of the panel this page keeps, so it stays small: a mark that says whose
   map this is, not a header. */
.netmap-bar-logo { height: 1.6rem; width: auto; max-width: 9rem; object-fit: contain; }

.netmap-bar-mark {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.6rem;
    height: 1.6rem;
    border-radius: .4rem;
    background: var(--bs-primary);
    color: #fff;
    font-weight: 700;
    font-size: .85rem;
}

/* The side panel as a drawer: over the map rather than beside it, because on a full
 * screen the diagram is the page and the words about one device are an interruption of
 * it. Nothing is hidden while it is shut - it carries no state of its own, and the map
 * underneath keeps refreshing. */
.netmap-card-bare .netmap-side {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    z-index: 4;
    /* Out of the flex flow, so the column width the card relies on has to be stated. */
    width: 21.5rem;
    box-shadow: -6px 0 18px rgba(0, 0, 0, .16);
    transform: translateX(100%);
    transition: transform .22s ease;
}

.netmap-card-bare.is-side-open .netmap-side { transform: translateX(0); }

@media (prefers-reduced-motion: reduce) {
    .netmap-card-bare .netmap-side { transition: none; }
}

.netmap-side-close {
    border: 0;
    background: none;
    color: var(--bs-secondary-color);
    padding: 0 .7rem;
    line-height: 1;
}

.netmap-side-close:hover { color: var(--bs-emphasis-color); }

.netmap-legend {
    position: absolute;
    left: .75rem;
    bottom: .75rem;
    display: flex;
    gap: .85rem;
    flex-wrap: wrap;
    padding: .35rem .7rem;
    border-radius: .5rem;
    background: color-mix(in srgb, var(--bs-body-bg) 88%, transparent);
    border: 1px solid var(--bs-border-color);
    font-size: .75rem;
    color: var(--bs-secondary-color);
    z-index: 3;
}

.netmap-key { display: inline-flex; align-items: center; gap: .35rem; }
.netmap-dot { width: .6rem; height: .6rem; border-radius: 50%; display: inline-block; background: var(--netmap-idle); }
.netmap-dot-ok { background: var(--netmap-ok); }
.netmap-dot-warn { background: var(--netmap-warn); }
.netmap-dot-down { background: var(--netmap-down); }
.netmap-dot-unreachable { background: var(--netmap-unreachable); }
.netmap-dot-idle { background: var(--netmap-idle); }
.netmap-dash { width: 1.1rem; height: 0; border-top: 2px dashed var(--bs-secondary-color); display: inline-block; }

/* --- edges ---------------------------------------------------------------- */

.netmap-edge {
    fill: none;
    stroke: var(--netmap-line);
    stroke-width: 2;
    stroke-linecap: round;
}

.netmap-edge-hit { fill: none; stroke: transparent; stroke-width: 16; cursor: pointer; }
.netmap-edge-group { outline: none; }
.netmap-edge-group:hover .netmap-edge,
.netmap-edge-group:focus-visible .netmap-edge { stroke-width: 3.5; }
.netmap-edge-group.is-selected .netmap-edge { stroke-width: 4; stroke-dasharray: none; }
.netmap-edge-ok { stroke: color-mix(in srgb, var(--netmap-ok) 55%, var(--bs-border-color)); }
.netmap-edge-warn { stroke: var(--netmap-warn); }
.netmap-edge-idle { stroke: var(--netmap-idle); stroke-dasharray: 3 4; }
.netmap-edge-unknown { stroke: var(--netmap-unreachable); stroke-dasharray: 2 5; }

.netmap-edge-down {
    stroke: var(--netmap-down);
    stroke-width: 2.5;
    stroke-dasharray: 7 5;
    animation: netmap-crawl 1.1s linear infinite;
}

@keyframes netmap-crawl { to { stroke-dashoffset: -24; } }

.netmap-edge-inferred { stroke-dasharray: 2 4; opacity: .8; }
.netmap-edge-label { font-size: 9px; fill: var(--bs-secondary-color); text-transform: uppercase; letter-spacing: .04em; }

/* What is flowing through a link, written on the link. Quieter than the labels above it and
   tabular, so a column of them lines up and can be scanned for the one that is not moving. */
.netmap-edge-rate {
    font-size: 9px;
    fill: var(--bs-secondary-color);
    opacity: .85;
    font-variant-numeric: tabular-nums;
}
.netmap-edge-group.is-dim { opacity: .12; }

/* --- nodes ---------------------------------------------------------------- */

.netmap-node { cursor: pointer; outline: none; }
.netmap-box { fill: var(--netmap-node-bg); stroke: var(--bs-border-color); stroke-width: 1.25; }
.netmap-accent { fill: var(--netmap-idle); }
.netmap-icon { fill: var(--bs-secondary-color); }
.netmap-label { font-size: 12.5px; font-weight: 600; fill: var(--bs-emphasis-color); }
.netmap-sub { font-size: 10.5px; fill: var(--bs-secondary-color); }
.netmap-pip { fill: var(--netmap-idle); }

/* Deliberately no filter: a drop-shadow anywhere inside this SVG makes Chrome rasterise the
 * whole diagram at its layout size and then scale that bitmap, so the moment the pointer
 * touched a box every label on the map went soft. The highlight is drawn with paint the SVG
 * renderer understands instead, which stays sharp at any zoom. */
.netmap-node:hover .netmap-box,
.netmap-node:focus-visible .netmap-box {
    stroke: var(--bs-primary);
    stroke-width: 2;
}

.netmap-node.is-selected .netmap-box {
    stroke: var(--bs-primary);
    stroke-width: 2.5;
}

.netmap-status-ok .netmap-accent { fill: var(--netmap-ok); }
.netmap-status-warn .netmap-accent, .netmap-status-warn .netmap-pip { fill: var(--netmap-warn); }
.netmap-status-idle .netmap-accent, .netmap-status-idle .netmap-pip { fill: var(--netmap-idle); }
.netmap-status-unreachable .netmap-accent, .netmap-status-unreachable .netmap-pip { fill: var(--netmap-unreachable); }
.netmap-status-unknown .netmap-accent, .netmap-status-unknown .netmap-pip { fill: var(--netmap-unreachable); }

.netmap-status-idle .netmap-box,
.netmap-status-unreachable .netmap-box,
.netmap-status-unknown .netmap-box { stroke-dasharray: 4 3; }

.netmap-status-idle, .netmap-status-unreachable, .netmap-status-unknown { opacity: .62; }

/* A fault has to be findable without reading anything, so it is the one thing on
   the map that moves. */
.netmap-status-down .netmap-accent, .netmap-status-down .netmap-pip { fill: var(--netmap-down); }

.netmap-status-down .netmap-box {
    stroke: var(--netmap-down);
    stroke-width: 2;
}

.netmap-status-down .netmap-pip { animation: netmap-throb 1.3s ease-in-out infinite; }

@keyframes netmap-throb {
    0%, 100% { opacity: 1; r: 5; }
    50% { opacity: .35; r: 6.5; }
}

@media (prefers-reduced-motion: reduce) {
    .netmap-status-down .netmap-pip,
    .netmap-edge-down,
    [data-netmap-refresh].is-busy .bi-arrow-repeat,
    .netmap-spin,
    .netmap-state[data-mode="spin"] .netmap-state-icon { animation: none; }
}

.netmap-badge rect { fill: var(--bs-tertiary-bg); stroke: var(--bs-border-color); }
.netmap-badge text { font-size: 9.5px; fill: var(--bs-secondary-color); font-weight: 600; }

.netmap-toggle circle { fill: var(--bs-body-bg); stroke: var(--bs-border-color); stroke-width: 1.25; }
.netmap-toggle text { font-size: 12px; font-weight: 700; fill: var(--bs-secondary-color); }
.netmap-toggle:hover circle { stroke: var(--bs-primary); }

.netmap-node.is-dim { opacity: .14; }
.netmap-node.is-match .netmap-box { stroke: var(--bs-primary); stroke-width: 2.5; }

/* --- side panel ----------------------------------------------------------- */

.netmap-side {
    flex: 0 0 21.5rem;
    max-width: 21.5rem;
    border-left: 1px solid var(--bs-border-color);
    display: flex;
    flex-direction: column;
    background: var(--bs-tertiary-bg);
    min-height: 0;
}

/* The card is now as tall as the map, which can be several screens. A faults column stretched
   down all of it would put the list at the top and nothing beside the part being read, so in
   the panel it travels with the page instead. Not on the full-screen page, where it is a
   drawer pinned to a window that never scrolls. */
.netmap-card:not(.netmap-card-bare) .netmap-side {
    position: sticky;
    top: 0;
    align-self: flex-start;
    max-height: min(74vh, 780px);
    overflow-y: auto;
}

.netmap-side-tabs { display: flex; border-bottom: 1px solid var(--bs-border-color); }

.netmap-tab {
    flex: 1;
    border: 0;
    background: transparent;
    padding: .6rem .5rem;
    font-size: .85rem;
    font-weight: 600;
    color: var(--bs-secondary-color);
    border-bottom: 2px solid transparent;
}

.netmap-tab.active { color: var(--bs-emphasis-color); border-bottom-color: var(--bs-primary); }

.netmap-side-pane { overflow-y: auto; padding: .75rem; flex: 1 1 auto; }

.netmap-empty {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: .5rem;
    padding: 2.5rem 1rem;
    color: var(--bs-secondary-color);
    font-size: .875rem;
    text-align: center;
}

.netmap-empty .bi { font-size: 1.6rem; }

.netmap-issues { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .4rem; }

.netmap-issue {
    border: 1px solid var(--bs-border-color);
    border-left-width: 3px;
    border-radius: .45rem;
    background: var(--bs-body-bg);
    overflow: hidden;
}

.netmap-issue-critical { border-left-color: var(--netmap-down); }
.netmap-issue-warning { border-left-color: var(--netmap-warn); }
.netmap-issue-critical .netmap-issue-head .bi { color: var(--netmap-down); }
.netmap-issue-warning .netmap-issue-head .bi { color: var(--netmap-warn); }

.netmap-issue-head {
    display: flex;
    gap: .5rem;
    align-items: flex-start;
    width: 100%;
    border: 0;
    background: transparent;
    text-align: left;
    padding: .55rem .65rem;
    font-size: .83rem;
    font-weight: 600;
    color: var(--bs-emphasis-color);
    line-height: 1.35;
}

.netmap-issue-body { display: none; padding: 0 .65rem .65rem; font-size: .8rem; color: var(--bs-secondary-color); }
.netmap-issue.is-open .netmap-issue-body { display: block; }
.netmap-issue-body p { margin-bottom: .5rem; }

.netmap-issue-hint {
    display: flex;
    gap: .4rem;
    align-items: flex-start;
    padding: .45rem .55rem;
    border-radius: .35rem;
    background: var(--bs-tertiary-bg);
    color: var(--bs-emphasis-color);
}

.netmap-detail-head {
    display: flex;
    gap: .5rem;
    align-items: center;
    padding-bottom: .6rem;
    margin-bottom: .6rem;
    border-bottom: 1px solid var(--bs-border-color);
}

.netmap-detail-head strong { display: block; font-size: .92rem; line-height: 1.3; overflow-wrap: anywhere; }
.netmap-detail-head small { display: block; color: var(--bs-secondary-color); font-size: .78rem; }

.netmap-detail-issues { display: flex; flex-direction: column; gap: .3rem; margin-bottom: .7rem; }

.netmap-detail-issue {
    border: 1px solid var(--bs-border-color);
    border-left-width: 3px;
    border-radius: .35rem;
    background: var(--bs-body-bg);
    text-align: left;
    padding: .4rem .5rem;
    font-size: .78rem;
    font-weight: 600;
    color: var(--bs-emphasis-color);
}

.netmap-detail-list {
    display: grid;
    grid-template-columns: minmax(6.5rem, auto) 1fr;
    gap: .3rem .7rem;
    margin: 0 0 .75rem;
    font-size: .8rem;
}

.netmap-detail-list dt { color: var(--bs-secondary-color); font-weight: 500; }
.netmap-detail-list dd { margin: 0; color: var(--bs-emphasis-color); overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }

/* The product photograph. Sized so it cannot push the facts below it off the panel, and on a
   light plate because product shots are cut out on white and vanish into a dark background. */
.netmap-photo {
    margin: 0 0 .75rem;
    border: 1px solid var(--bs-border-color);
    border-radius: .45rem;
    overflow: hidden;
    background: var(--bs-body-bg);
}

.netmap-photo img {
    display: block;
    width: 100%;
    max-height: 9rem;
    object-fit: contain;
    background: #fff;
    padding: .4rem;
}

.netmap-photo figcaption {
    padding: .35rem .5rem;
    font-size: .74rem;
    color: var(--bs-secondary-color);
    background: var(--bs-tertiary-bg);
    overflow-wrap: anywhere;
}

/* A picture of the category rather than of this device. Dimmed and captioned so it never
   reads as a photograph of the unit that is actually installed. */
.netmap-photo.is-example img { opacity: .82; }

.netmap-photo-note {
    display: block;
    margin-top: .1rem;
    font-style: italic;
    opacity: .85;
}

/* No picture was found. The block collapses to its caption, which is a link to go and look -
   an empty frame would read as something still loading. */
.netmap-photo.is-missing { border-style: dashed; }
.netmap-photo.is-missing figcaption::before {
    content: "No picture found for ";
    color: var(--bs-secondary-color);
}

/* ---------------------------------------------------------------------------
 * Security step flows (profile: PIN, password, authenticator)
 *
 * The in-panel twin of .auth-steps above, which does the same job for the
 * signed-out password reset. Two rules rather than one shared one because the
 * signed-out card paints its own palette (--auth-ink, --auth-line) over a fixed
 * background, while this sits on a panel page and has to take the panel's.
 *
 * The connecting rule is drawn on the item rather than between items, so the row
 * keeps working when a step is added or dropped - which happens here on every
 * render, since an account without an authenticator is asked one question fewer.
 * ------------------------------------------------------------------------- */
.step-flow {
    max-width: 42rem;
    margin: 0 auto;
}

.step-flow-track {
    display: flex;
    list-style: none;
    margin: 0 0 1.25rem;
    padding: 0;
}

.step-flow-node {
    flex: 1 1 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: .375rem;
    position: relative;
    min-width: 0;
    font-size: .6875rem;
    font-weight: 700;
    color: var(--bs-secondary-color);
    text-align: center;
}

.step-flow-node + .step-flow-node::before {
    content: "";
    position: absolute;
    top: .875rem;
    right: 50%;
    left: -50%;
    height: 2px;
    background: var(--bs-border-color);
}

.step-flow-node.is-done::before,
.step-flow-node.is-current::before { background: var(--bs-primary); }

.step-flow-dot {
    position: relative;
    z-index: 1;
    display: grid;
    place-items: center;
    width: 1.75rem;
    height: 1.75rem;
    border-radius: 50%;
    border: 2px solid var(--bs-border-color);
    background: var(--bs-body-bg);
    font-size: .75rem;
    line-height: 1;
}

.step-flow-node.is-done .step-flow-dot,
.step-flow-node.is-current .step-flow-dot {
    border-color: var(--bs-primary);
    background: var(--bs-primary);
    color: #fff;
}

.step-flow-node.is-current { color: var(--bs-emphasis-color); }

/* Below sm the labels overflow first, so they go and the numbered dots carry it. */
.step-flow-label {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    max-width: 100%;
}

@media (max-width: 575.98px) {
    .step-flow-node:not(.is-current) .step-flow-label { display: none; }
}

/* ---- the PIN box ----
 *
 * Wide-spaced and centred, so a PIN reads as a PIN rather than as a short
 * password. Not split into one box per digit: a row of separate inputs fights
 * every phone keyboard and every password manager, and announces the length to
 * anyone glancing at the screen, which is the one thing about a PIN worth not
 * saying out loud. */
.pin-entry {
    max-width: 14rem;
    font-size: 1.5rem;
    letter-spacing: .5em;
    text-align: center;
    padding-left: .5em;
}

/* ---- choosing the length ----
 *
 * A segmented row rather than a <select>: five options, all of them one
 * character, and the choice is worth seeing all of at once. */
.pin-length-choice {
    display: flex;
    flex-wrap: wrap;
    gap: .5rem;
}

.pin-length-option input {
    position: absolute;
    opacity: 0;
    pointer-events: none;
}

.pin-length-option span {
    display: grid;
    place-items: center;
    width: 3rem;
    height: 3rem;
    border: 1px solid var(--bs-border-color);
    border-radius: .5rem;
    background: var(--bs-secondary-bg);
    color: var(--bs-emphasis-color);
    font-size: 1.125rem;
    font-weight: 700;
    cursor: pointer;
    transition: background-color .15s ease-in-out, border-color .15s ease-in-out, color .15s ease-in-out;
}

.pin-length-option input:checked + span {
    background: var(--bs-emphasis-color);
    border-color: var(--bs-emphasis-color);
    color: var(--bs-body-bg);
}

.pin-length-option input:focus-visible + span {
    outline: 2px solid var(--bs-primary);
    outline-offset: 2px;
}

/* ---------------------------------------------------------------------------
 * The face capture screen
 *
 * A round window rather than the raw video element. Two reasons: it tells the
 * operator where to put their face without a sentence saying so, and it crops
 * the room out of the preview, which matters on a screen somebody else may be
 * standing behind.
 *
 * The video is mirrored. An unmirrored preview makes "turn left" read as the
 * wrong direction to everybody who has ever used a mirror, and people follow the
 * picture rather than the words. What is uploaded is unmirrored - the flip is a
 * CSS transform on the preview only, and never touches the canvas.
 * ------------------------------------------------------------------------- */
.face-capture {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 1rem;
}

.face-stage {
    position: relative;
    width: min(18rem, 70vw);
    aspect-ratio: 1;
}

.face-stage video {
    width: 100%;
    height: 100%;
    object-fit: cover;
    border-radius: 50%;
    background: var(--bs-secondary-bg);
    transform: scaleX(-1);
}

.face-ring {
    position: absolute;
    inset: 0;
    border-radius: 50%;
    border: 3px dashed var(--bs-border-color);
    pointer-events: none;
    transition: border-color .2s ease-in-out;
}

.face-capture.is-running .face-ring {
    border-style: solid;
    border-color: var(--bs-primary);
}

/* A moment of green when the server says it saw the movement. The checklist below says how
   far through the whole thing somebody is; this is the only feedback in the place they are
   actually looking, which is at their own face. */
.face-capture.is-seen .face-ring {
    border-color: var(--bs-success);
    border-width: 5px;
}

/* Not one line any more. It carries what the server said about the last frame - "move
   closer", "it is too dark to see you", "keep going" - which is a sentence rather than a
   label, and a sentence set to nowrap runs off the side of a phone. Two lines of room are
   reserved whether or not they are used, so the checklist below does not jump every time the
   advice changes length. */
.face-instruction {
    position: absolute;
    left: 50%;
    bottom: -3.4rem;
    transform: translateX(-50%);
    margin: 0;
    width: min(24rem, 88vw);
    min-height: 2.6rem;
    text-align: center;
    line-height: 1.3;
    font-weight: 600;
    color: var(--bs-emphasis-color);
}

/* The movements, as a checklist that fills in behind the live instruction - so a
   person who looks away for a second can see how far through they are. */
.face-moves {
    list-style: none;
    margin: 3.4rem 0 0;
    padding: 0;
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: .4rem;
}

.face-moves li {
    padding: .3rem .7rem;
    border: 1px solid var(--bs-border-color);
    border-radius: 2rem;
    background: var(--bs-secondary-bg);
    color: var(--bs-secondary-color);
    font-size: .8125rem;
    font-weight: 600;
}

.face-moves li.is-current {
    border-color: var(--bs-primary);
    background: var(--bs-primary);
    color: #fff;
}

.face-moves li.is-done {
    border-color: var(--bs-success);
    color: var(--bs-success);
    background: var(--bs-secondary-bg);
}

/* Pagination: page numbers on every screen size, not only tablet/desktop. */
.mtik-pagination .pagination,
.mtik-pagination__list {
    --bs-pagination-padding-x: 0.65rem;
    --bs-pagination-padding-y: 0.4rem;
    --bs-pagination-font-size: 0.9375rem;
    gap: 0.2rem;
}

.mtik-pagination .page-link {
    min-width: 2.35rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    line-height: 1.2;
}

@media (max-width: 575.98px) {
    .mtik-pagination .pagination,
    .mtik-pagination__list {
        --bs-pagination-padding-x: 0.7rem;
        --bs-pagination-padding-y: 0.55rem;
        --bs-pagination-font-size: 1rem;
        gap: 0.35rem;
    }

    .mtik-pagination .page-link {
        min-width: 2.85rem;
        min-height: 2.85rem;
        border-radius: 0.5rem;
    }

    .card-footer:has(.mtik-pagination) {
        padding-left: 0.5rem;
        padding-right: 0.5rem;
        padding-top: 0.75rem;
        padding-bottom: 0.75rem;
    }
}

/* ---- Add/Edit User steps (users/form, js/user-wizard.js) ---------------------------- */
.mtk-wizard-steps {
    display: flex;
    gap: 0.5rem;
    list-style: none;
    padding: 0;
    margin: 0 0 1rem;
    overflow-x: auto;
}

.mtk-wizard-steps > li {
    flex: 1 1 0;
    min-width: 6.5rem;
}

.mtk-wizard-steps__item {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    width: 100%;
    padding: 0.55rem 0.75rem;
    border: 1px solid var(--bs-border-color);
    border-radius: 0.6rem;
    background: var(--bs-body-bg);
    color: var(--bs-secondary-color);
    font-weight: 500;
    text-align: left;
}

.mtk-wizard-steps__item:disabled {
    opacity: 0.55;
    cursor: default;
}

.mtk-wizard-steps__num {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: none;
    width: 1.6rem;
    height: 1.6rem;
    border-radius: 50%;
    background: var(--bs-secondary-bg);
    font-size: 0.8rem;
}

.mtk-wizard-steps__item.is-done .mtk-wizard-steps__num {
    background: color-mix(in srgb, #4f46e5 18%, transparent);
    color: #6366f1;
}

.mtk-wizard-steps__item.is-active {
    border-color: #4f46e5;
    color: var(--bs-emphasis-color);
    box-shadow: 0 0 0 0.15rem color-mix(in srgb, #4f46e5 18%, transparent);
}

.mtk-wizard-steps__item.is-active .mtk-wizard-steps__num {
    background: #4f46e5;
    color: #fff;
}

@media (max-width: 575.98px) {
    .mtk-wizard-steps > li {
        min-width: 0;
    }

    .mtk-wizard-steps__label {
        display: none;
    }

    .mtk-wizard-steps__item {
        justify-content: center;
    }

    .mtk-wizard-steps__item.is-active .mtk-wizard-steps__label {
        display: inline;
    }
}

/* Account type: a radio drawn as a card. */
.mtk-type-card {
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
    height: 100%;
    padding: 0.75rem 0.85rem;
    border: 1px solid var(--bs-border-color);
    border-radius: 0.6rem;
    background: var(--bs-body-bg);
    cursor: pointer;
}

.mtk-type-card__icon {
    font-size: 1.25rem;
    color: #6366f1;
}

.mtk-type-card__label {
    font-weight: 600;
    color: var(--bs-emphasis-color);
}

.mtk-type-card__detail {
    font-size: 0.8rem;
    color: var(--bs-secondary-color);
}

.btn-check:checked + .mtk-type-card {
    border-color: #4f46e5;
    background: color-mix(in srgb, #4f46e5 8%, var(--bs-body-bg));
    box-shadow: 0 0 0 0.15rem color-mix(in srgb, #4f46e5 18%, transparent);
}

.btn-check:focus-visible + .mtk-type-card {
    outline: 2px solid #6366f1;
    outline-offset: 2px;
}

/* A dropdown of tick boxes (router groups, custom access). */
.mtk-check-dropdown .dropdown-menu {
    max-height: 22rem;
    overflow-y: auto;
}

.mtk-check-dropdown__row {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
}

.mtk-check-dropdown__item {
    display: flex;
    align-items: center;
    flex: 1 1 0;
    min-width: 0;
    white-space: normal;
    border-radius: 0.4rem;
    cursor: pointer;
}

.mtk-check-dropdown__detail {
    flex-basis: 100%;
    margin: 0 0.5rem 0.4rem 2.35rem;
    padding: 0.4rem 0.6rem;
    border-radius: 0.4rem;
    background: var(--bs-secondary-bg);
    font-size: 0.8rem;
    color: var(--bs-secondary-color);
}

.mtk-info-btn {
    color: var(--bs-secondary-color);
    text-decoration: none;
}

.mtk-info-btn[aria-expanded="true"],
.mtk-info-btn:hover {
    color: #6366f1;
}
