/* static/css/styles.css */

/* Self-hosted UI font (capture fidelity): snapshot PDFs/PNGs are rendered by
   the backend's Playwright browser on a VM with NONE of the system stack
   installed, so text fell back to a monospace (2026-07-16). Serving Inter from
   our own origin guarantees the SAME face on every machine - live dashboards,
   the render pages, and captures. Variable file covers weights 100-900; the
   latin subset covers our UI text, anything outside its unicode-range falls
   through to the system stack below. */
@font-face {
    font-family: 'Inter';
    font-style: normal;
    font-weight: 100 900;
    font-display: swap;
    src: url('../fonts/inter-latin-wght-normal.woff2') format('woff2-variations');
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* Colour tokens: every brand colour variable (text, surfaces, accent, links,
   buttons, borders, tables, focus ring, chart chrome, overlays) is emitted per
   brand AND per mode by the token registry (app/branding/tokens.py), through
   templates/shared/brand_styles.html, which every page head includes after this
   file. Only variables no brand can change live here. */
:root {
    /* Typography */
    --font-family: 'Inter', system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
    --line-height: 1.5;
    --font-weight: 400;

    /* Status colors (semantic — fixed across all brands) */
    --status-danger: #dc3545;
    --status-success: #198754;
    --status-warning: #b1740a;

    /* Status backgrounds (notification bars) */
    --status-danger-bg: #b52323;
    --status-success-bg: #034808;
    --status-warning-bg: #b1740a;
}

:root.light-mode {
    --status-danger-bg: #dc3545;
    --status-success-bg: #198754;
    --status-warning-bg: #b1740a;
}

/* ===== BOOTSTRAP PALETTE OVERRIDES =====
   Bootstrap 5.3 runs in its LIGHT theme underneath this app's own dark/light
   switch (.light-mode on <html>), so every Bootstrap variable a component we
   use reads has to be bridged to our tokens here. An unbridged one paints
   Bootstrap's stock blue (#0d6efd) or its light-theme black - colours no brand
   chose. The page-level variables are below; the component-scoped ones follow. */
:root {
    --bs-primary: var(--accent-color);
    --bs-body-bg: var(--bg-color);
    --bs-body-color: var(--text-color);
    --bs-border-color: var(--border-color);
    --bs-secondary-bg: var(--alt-bg);
    --bs-tertiary-bg: var(--hl-bg);
    --bs-secondary-color: rgba(180, 180, 190, 0.75);
    /* .btn-link and pagination text (a bare <a> is styled by our own `a` rule) */
    --bs-link-color: var(--link-color);
    --bs-link-hover-color: var(--link-hover-color);
    /* The strongest ink: plain .table cells and list-group hovers read it, and
       Bootstrap's light-theme #000 drew black text on the dark page. The rgb
       triplet only feeds Bootstrap's translucent table stripe/hover washes, so
       it is each mode's neutral, not a brand colour. */
    --bs-emphasis-color: var(--text-color);
    --bs-emphasis-color-rgb: 255, 255, 255;
    --bs-focus-ring-color: var(--focus-ring-color);
}

:root.light-mode {
    --bs-secondary-color: rgba(33, 37, 41, 0.75);
    --bs-emphasis-color-rgb: 0, 0, 0;
}

/* Bootstrap's *-primary utilities read an RGB triplet (--bs-primary-rgb) that
   the accent no longer has, so they are restated on the accent itself, keeping
   Bootstrap's opacity hooks. A solid fill is inked with --on-accent. */
.text-primary {
    color: color-mix(in srgb, var(--accent-color) calc(var(--bs-text-opacity, 1) * 100%), transparent) !important;
}

.bg-primary {
    background-color: color-mix(in srgb, var(--accent-color) calc(var(--bs-bg-opacity, 1) * 100%), transparent) !important;
}

.text-bg-primary {
    color: var(--on-accent) !important;
    background-color: color-mix(in srgb, var(--accent-color) calc(var(--bs-bg-opacity, 1) * 100%), transparent) !important;
}

.border-primary {
    border-color: color-mix(in srgb, var(--accent-color) calc(var(--bs-border-opacity, 1) * 100%), transparent) !important;
}

.link-primary {
    color: color-mix(in srgb, var(--accent-color) calc(var(--bs-link-opacity, 1) * 100%), transparent) !important;
    text-decoration-color: color-mix(in srgb, var(--accent-color) calc(var(--bs-link-underline-opacity, 1) * 100%), transparent) !important;
}

.link-underline-primary {
    text-decoration-color: color-mix(in srgb, var(--accent-color) calc(var(--bs-link-underline-opacity, 1) * 100%), transparent) !important;
}

.focus-ring-primary {
    --bs-focus-ring-color: color-mix(in srgb, var(--accent-color) calc(var(--bs-focus-ring-opacity, 0.25) * 100%), transparent);
}

/* The mouse hover comes from the app-wide button hover rule (Buttons section
   below). Bootstrap paints KEYBOARD focus with the hover variables, so they
   carry the same hover look: its literal white hover ink vanished on a light
   hover fill. The pressed look is the app's own pressed rule, so Bootstrap's
   --bs-btn-active-* are never read. */
.btn-primary {
    --bs-btn-color: var(--button-primary-color);
    --bs-btn-bg: var(--button-primary-bg);
    --bs-btn-border-color: var(--button-primary-bg);
    --bs-btn-hover-color: var(--button-hover-text);
    --bs-btn-hover-bg: var(--button-hover-bg);
    --bs-btn-hover-border-color: var(--button-hover-bg);
    --bs-btn-disabled-color: var(--button-primary-color);
    --bs-btn-disabled-bg: var(--button-primary-bg);
    --bs-btn-disabled-border-color: var(--button-primary-bg);
    --bs-btn-focus-box-shadow: 0 0 0 0.25rem var(--focus-ring-color);
}

.btn-outline-primary {
    --bs-btn-color: var(--button-outline-color);
    --bs-btn-border-color: var(--button-outline-color);
    --bs-btn-hover-color: var(--button-hover-text);
    --bs-btn-hover-bg: var(--button-hover-bg);
    --bs-btn-hover-border-color: var(--button-outline-color);
    --bs-btn-disabled-color: var(--button-outline-color);
    --bs-btn-disabled-border-color: var(--button-outline-color);
    --bs-btn-focus-box-shadow: 0 0 0 0.25rem var(--focus-ring-color);
}

/* Link-styled buttons take --bs-link-color (bridged above); only their keyboard
   focus glow still carried Bootstrap's blue. */
.btn-link {
    --bs-btn-focus-box-shadow: 0 0 0 0.25rem var(--focus-ring-color);
}

/* Selected-item fills: the current insights dashboard / guide option, a picked
   merge candidate, the current page. Same accent + ink as .btn-primary. */
.dropdown-menu {
    --bs-dropdown-link-active-color: var(--menu-item-selected-color);
    --bs-dropdown-link-active-bg: var(--menu-item-selected-bg);
}

.list-group {
    --bs-list-group-active-color: var(--on-accent);
    --bs-list-group-active-bg: var(--accent-color);
    --bs-list-group-active-border-color: var(--accent-color);
}

.pagination {
    --bs-pagination-active-color: var(--on-accent);
    --bs-pagination-active-bg: var(--accent-color);
    --bs-pagination-active-border-color: var(--accent-color);
    --bs-pagination-focus-box-shadow: 0 0 0 0.25rem var(--focus-ring-color);
}

/* Upload progress (the shared dropzone) and every other .progress bar. */
.progress,
.progress-stacked {
    --bs-progress-bar-bg: var(--progress-bar-bg);
}

/* Focus rings Bootstrap hard-codes in its blue. */
.btn-close {
    --bs-btn-close-focus-shadow: 0 0 0 0.25rem var(--focus-ring-color);
}

.nav-link:focus-visible {
    box-shadow: 0 0 0 0.25rem var(--focus-ring-color);
}

/* Range thumbs are painted with literals in Bootstrap (no variable to bridge).
   Engine-prefixed pseudo-elements must stay in separate rules: one unknown
   selector in a list drops the whole rule. */
.form-range::-webkit-slider-thumb {
    background-color: var(--range-thumb-bg);
}

.form-range::-moz-range-thumb {
    background-color: var(--range-thumb-bg);
}

.form-range::-webkit-slider-thumb:active {
    background-color: color-mix(in srgb, var(--range-thumb-bg) 45%, white);
}

.form-range::-moz-range-thumb:active {
    background-color: color-mix(in srgb, var(--range-thumb-bg) 45%, white);
}

.form-range:focus::-webkit-slider-thumb {
    box-shadow: 0 0 0 1px var(--bg-color), 0 0 0 0.25rem var(--focus-ring-color);
}

.form-range:focus::-moz-range-thumb {
    box-shadow: 0 0 0 1px var(--bg-color), 0 0 0 0.25rem var(--focus-ring-color);
}

.form-check-input {
    background-color: var(--check-bg);
}

.form-check-input:focus {
    border-color: var(--input-focus-border-color);
    box-shadow: 0 0 0 0.25rem var(--focus-ring-color);
}

/* Bootstrap turns an unchecked switch's knob its blue (#86b7fe) while focused.
   The focus ring above already marks focus, so the knob keeps its resting look
   (Bootstrap's own resting knob). A checked switch keeps Bootstrap's white knob. */
.form-switch .form-check-input:focus:not(:checked) {
    --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");
}

html {
    scrollbar-width: none; /* Firefox */
    -ms-overflow-style: none; /* Edge, Internet Explorer */
}

html::-webkit-scrollbar {
    display: none; /* Chrome, Safari, Opera */
}

/* Base styles */
body {
    margin: 0;
    display: flex;
    flex-direction: column;
    width: 100%;
    height: 100vh;
    min-width: 320px;
    min-height: 100vh;
    font-family: var(--font-family);
    letter-spacing: 0.04em;
    line-height: var(--line-height);
    font-weight: var(--font-weight);
    color: var(--text-color);
    background-color: var(--bg-color);
    font-synthesis: none;
    text-rendering: optimizeLegibility;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    color-scheme: light dark;
}

h1 {
    font-size: 3.2em;
    line-height: 1.1;
}

/* Reusable utilities */
.border-radius-8 {
    border-radius: 8px;
}

.border-none {
    border: none !important;
}

.font-weight-500 {
    font-weight: 500;
}

.transition-bg-color {
    transition: background-color 0.25s;
}

.transition-color {
    transition: color 0.25s;
}

.cursor-pointer {
    cursor: pointer;
}

/* Compact alert banners (on-device settings notices) */
.alert-compact {
    font-size: .8em;
}

/* Four-digit year number input (excluded-months forms) */
.year-input {
    width: 90px;
}

/* Narrow trailing table column holding row action buttons */
.col-actions {
    width: 50px;
}

/* Links */
a {
    color: var(--link-color);
    text-decoration: none;
    font-weight: 500;
}

a:hover {
    color: var(--link-hover-color);
}

a i {
    background-color: transparent;
}

a i:hover {
    color: var(--link-hover-color);
    text-decoration: none;
}

/* Buttons. A <button> element carries no paint of its own: the app's brand
   button is .button, Bootstrap's are .btn, and every other component styles
   its own buttons (branding decision D3). The element rule this replaces put
   the brand fill, weight and hover on every <button>, and components kept
   repairing it. :where() holds button.button at that rule's specificity
   (0-0-1, 0-1-1 on hover), so every rule that restyled a .button before
   (.light-bg, .get-btn, .contrast-background-rounded, utilities) still does.
   input.button is the WTForms submit field (form.submit(class="button")). */
button:where(.button),
input.button {
    border-radius: 8px;
    border: 1px solid transparent;
    padding: 0.6em 1.2em;
    font-size: 1em;
    font-weight: 500;
    font-family: var(--font-family);
    background-color: var(--button-bg);
    color: var(--button-text);
    cursor: pointer;
    transition: background-color 0.25s, color 0.25s;
}

label.button {
    border-radius: 8px;
    border: 1px solid var(--border-color);
    padding: 0.3em 0.6em;
    font-size: 1em;
    font-weight: 500;
    color: var(--text-color);
    cursor: pointer;
    transition: background-color 0.25s, color 0.25s;
}

/* A toggle label under the pointer previews the ink it takes when pressed. */
label.btn.button:hover {
    border: 1px solid var(--border-color);
    color: var(--button-pressed-color);
}

/* Pressed, toggled on or menu open: every .btn variant, the toggle groups'
   labels included. Bootstrap's own rule for the same selectors reads
   --bs-btn-active-*, which this replaces. */
.btn-check:checked + .btn, .btn.active, .btn.show, .btn:first-child:active, :not(.btn-check) + .btn:active {
    color: var(--button-pressed-color);
    background-color: var(--button-pressed-bg);
    border-color: var(--button-pressed-border-color);
}

/* The brand hover, for the app's own buttons: .button and the brand .btn's
   (.btn-primary, .btn-outline-primary, a variant-less .btn). Bootstrap's
   SEMANTIC variants keep their own hover (owner ruling 2026-09-23): a red
   delete button that hovered into the brand's fill lost its meaning. :where()
   holds the .btn selector at plain .btn:hover specificity, so every rule that
   already outranked it (.btn.channel-btn:hover, ...) still does. */
button:where(.button):hover,
.btn:where(:not(.btn-secondary, .btn-outline-secondary, .btn-success, .btn-outline-success,
    .btn-danger, .btn-outline-danger, .btn-warning, .btn-outline-warning, .btn-info,
    .btn-outline-info, .btn-light, .btn-outline-light, .btn-dark, .btn-outline-dark,
    .btn-link)):hover,
input.button:hover {
    background-color: var(--button-hover-bg);
    color: var(--button-hover-text);
}

/* Keyboard focus draws the brand ring, as on .btn-primary. */
button:where(.button):focus-visible,
input.button:focus-visible {
    outline: 0;
    box-shadow: 0 0 0 0.25rem var(--focus-ring-color);
}

.btn.channel-btn {
    text-align: center;
    font-size: smaller;
    background-color: var(--channel-pill-bg);
    padding: 3px 5px;
    min-width: 6em;
    border: 0;
    box-shadow: 1px 1px 3px var(--channel-pill-shadow-color);
    transition: background-color 0.2s;
}

.btn.channel-btn:hover {
    background-color: var(--channel-pill-hover-bg);
    color: var(--channel-pill-hover-color);
    transition: background-color 0.3s;
}

.alert-icon-channel,
.channel-btn .stale-icon-device {
    color: #dc3545 !important;
    background: rgba(255, 255, 255, 0.9);
    /*font-size: 0.65em;*/
    margin-right: 3px;
    padding: 0px 3px 0px 2px;
    border-radius: 3px;
    vertical-align: middle;
    line-height: 1;
}

/* Interval corner tag on selected-channel pills (graphs dashboard).
   Fixed dark fill: the pill face is a chart color in BOTH themes, so the tag
   is theme-invariant like the pill itself — only the cutout border tracks the
   card surface (var(--primary-bg)) behind the pills. */
.channel-btn .interval-tag {
    position: absolute;
    top: -7px;
    right: -6px;
    z-index: 1;
    min-width: 16px;
    height: 16px;
    padding: 0 3px;
    border-radius: 5px;
    background-color: #2e2e31;
    color: #fff;
    border: 2px solid var(--primary-bg);
    font-size: 9px;
    font-weight: 700;
    line-height: 1;
    display: inline-flex;
    align-items: center;
    justify-content: center;
}

/* Keep the tag's on-pill footprint off long channel names. */
.btn.channel-btn.has-interval-tag {
    padding-right: 12px;
}

/* Circle around stale device activity icons — matches bi-exclamation-circle visual style.
   Uses ::after pseudo-element to draw a perfect circle centered on the glyph
   (::before is used by Bootstrap Icons for the glyph itself). */
.stale-icon .bi-activity,
.stale-icon-page .bi-activity,
.stale-icon-owner .bi-activity,
.stale-icon-device.bi-activity {
    position: relative;
    display: inline-block;
}
.stale-icon .bi-activity::after,
.stale-icon-page .bi-activity::after,
.stale-icon-owner .bi-activity::after,
.stale-icon-device.bi-activity::after {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    width: 1.1em;
    height: 1.1em;
    border: 0.08em solid currentColor;
    border-radius: 50%;
    transform: translate(-50%, -50%);
    pointer-events: none;
}

.get-btn {
    background-color: var(--accent-color);
    color: var(--button-text);
}

.btn-sm {
    padding: 0.25rem 0.5rem;
}

/* Containers */
.body_background {
    background-color: var(--bg-color) !important;
}

.subzone-container {
    border: 1px solid var(--alt-bg);
    padding: 0.5em;
    border-radius: 8px;
}

.owner-search-container .owner-item[style*="display: none"] {
    display: none !important;
}

.channel-grid {
    border: 1px solid var(--table-border);
    padding: 0.2em 0.4em;
    border-radius: 8px;
}

.channel-details {
    border-left: 15px solid var(--border-color) !important;
}

.form-grouping {
    background-color: var(--primary-bg);
}

/* ---- Add-channel cards (loggers + controllers) ----
   ONE rule set for every bulk add-channel form: cw_logger_channels.html,
   cw_channels_add_form.html, wc_channels_api.html, wc_channels_add_form.html
   and the manual-entry <template> in wc_channels_manual.html. The single-channel
   Details/Edit forms are NOT cards and stay on their own layout.

   These forms live inside #modal (.modal-custom-size, 80-92vw), so the column
   count MUST come from the container, never from Bootstrap's viewport
   breakpoints: col-xl-4 at a 1366px window drew three 331px cards into a
   1093px modal while the control row needed 352px (439px with the loop unit
   select) - measured 2026-09-18. auto-fill/minmax sizes the columns from the
   grid's own width; the max() keeps a card at least 320px (the floor of the
   stacked fields) and caps a row at four columns on wide screens. */
.channel-group {
    padding: 0 16px 16px;
}

.channel-card-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(max(320px, calc((100% - 3 * 16px) / 4)), 1fr));
    gap: 16px;
    /* Each card keeps its own height: a Tank Level card that grows its tank
       fields must not stretch its row-mates to match (owner 2026-09-18). */
    align-items: start;
}

/* A card is a column of stacked fields (custom name on its own line, unit +
   sub-zone side by side, tank fields underneath) and a size container, so the
   tank-field grid below decides one or two columns from the CARD's width. */
.channel-card {
    display: flex;
    flex-direction: column;
    gap: 10px;
    padding: 12px 14px 14px;
    container-type: inline-size;
}

.channel-card .form-control,
.channel-card .form-select {
    min-width: 0;
}

.channel-card__head {
    display: flex;
    align-items: center;
    min-height: 24px;
}

/* Bootstrap's .form-switch floats the input into a 2.5em left pad; in the card
   head the switch and the label share one flex line instead. */
.channel-card__head .form-check {
    display: flex;
    align-items: center;
    gap: 10px;
    min-height: 0;
    margin: 0;
    padding-left: 0;
}

.channel-card__head .form-check-input {
    float: none;
    flex: none;
    margin: 0;
}

.channel-card__head .form-label {
    margin: 0;
    min-width: 0;
}

.channel-card__fields {
    display: grid;
    gap: 8px;
}

/* Unit + sub-zone on one line. The unit column's floor is what Bootstrap's
   .form-select.is-invalid needs: 4.125rem of right padding for the invalid
   icon AND the chevron, plus "Unit" / "bar" / "%" - 84px showed "Ur" at 1366
   (owner screenshot 2026-09-18). */
.channel-card__pair {
    display: grid;
    grid-template-columns: minmax(7.5rem, 1fr) minmax(0, 2.4fr);
    gap: 8px;
}

.channel-card__pair--single {
    grid-template-columns: 1fr;
}

/* Below 344px of card content the pair cannot hold both selects in their
   invalid state (7.5rem + 8px + "Choose Sub-Zone" with its icon = 216px), so
   the unit select takes its own line instead of clipping. Only loop-reading
   cards carry a pair; at 1366 (350px content) it stays side by side. */
@container (max-width: 343px) {
    .channel-card__pair {
        grid-template-columns: 1fr;
    }
}

/* Input-group rows on a card (the manual-entry fields and the shared tank
   fragment, shared/partials/tank_capture_fields.html) become caption-over-
   control blocks: the 14em label span the full-width forms can afford left
   about 100px per control inside a card. Same move the phone clause makes for
   the channel settings forms. The fragment's markup is untouched - one
   fragment, styled per host - so the Details/Edit forms keep their input
   groups. Bootstrap's mb-2 / mb-3 utilities and its .input-group > :not(
   :first-child) radius/margin rules outrank these selectors, hence the
   !important on exactly those properties. */
.channel-card .input-group {
    display: grid;
    gap: 3px;
    min-width: 0;
    margin-bottom: 0 !important;
}

.channel-card .input-group > .input-group-text {
    width: auto;
    padding: 0;
    border: 0;
    background: transparent;
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--gray);
}

.channel-card .input-group > .form-control,
.channel-card .input-group > .form-select {
    width: 100%;
    margin-left: 0 !important;
    border-radius: var(--bs-border-radius-sm) !important;
}

.channel-card .tank-capture-fields {
    display: grid;
    grid-template-columns: 1fr;
    gap: 8px;
    margin-top: 0 !important; /* the fragment's own mt-2; the card's gap spaces it */
    padding-top: 10px;
    border-top: 1px dashed var(--border-color);
}

.channel-card .tank-capture-fields > small {
    grid-column: 1 / -1;
}

/* Manual entry: six fields two-up once the card is wide enough. */
.channel-card__fields--two-up {
    grid-template-columns: 1fr;
}

@container (min-width: 430px) {
    .channel-card__fields--two-up {
        grid-template-columns: 1fr 1fr;
    }

    .channel-card .tank-capture-fields {
        grid-template-columns: 1fr 1fr;
    }

    /* Tank name spans; usage / type / dimensions / capacity pair up. */
    .channel-card .tank-capture-fields > .input-group:first-child {
        grid-column: 1 / -1;
    }
}

.channel-card .remove-channel-btn {
    align-self: flex-end;
}

.channel-zones {
    border-bottom: 1px solid var(--table-border);
}

.zones-container {
    display: flex !important;
    max-height: 0;
    overflow: hidden;
}

.zones-container[style*="display: none"] {
    display: none !important;
}

.subzone-outer-container {
    display: flex !important;
    margin-bottom: 0;
    gap: 0;
    max-height: 0;
    overflow: hidden;
    transition: max-height 0.2s ease-in-out;
}

.subzone-outer-container[style*="display: none"] {
    display: none !important;
}

/* Navbar */
.navbar {
    padding: 0.3rem 1.5rem;
    border-bottom: 1px solid var(--navbar-border-color);
    background-color: var(--navbar-bg);
}

.navbar-brand {
    color: var(--nav-link-color);
    font-weight: 500;
    font-size: 1.5rem;
}

.navbar-brand:hover {
    color: var(--nav-link-hover-color);
}

.nav-link {
    color: var(--nav-link-color) !important;
    padding: 0.5rem 1rem !important;
    transition: color 0.2s ease, background-color 0.2s ease;
}

.nav-link:hover {
    color: var(--nav-link-hover-color) !important;
}

/* Pill hover on navbar links only (avoids affecting sidebar, modals, etc.) */
.navbar .nav-link {
    border-radius: 0.375rem;
}

.navbar .nav-link:hover {
    background-color: var(--nav-link-hover-bg);
}

/* A navbar link whose menu is open. Bootstrap 5 marks the open toggle itself
   (.show on the link and the menu), never the .nav-item around it: keyed on the
   .nav-item, as in Bootstrap 4, this rule never matched. */
.navbar .nav-item.dropdown > .nav-link.show {
    color: var(--nav-link-open-color) !important;
    background-color: var(--nav-link-open-bg);
}

.navbar-toggler {
    border-color: var(--toggler-border-color);
    color: var(--nav-link-color);
}

/* The three bars are Bootstrap's own shape used as a MASK and painted with
   currentColor. The data-URI image this replaces had its stroke baked in
   (white, in BOTH modes, so the menu icon vanished on the light navbar) and an
   image can never follow a colour variable. Same technique as the close X. */
.navbar-toggler-icon {
    background-image: none;
    background-color: currentColor;
    -webkit-mask: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='%23000' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") center / 100% no-repeat;
    mask: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='%23000' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") center / 100% no-repeat;
}

/* Dropdowns */
.dropdown-menu {
    background-color: var(--dropdown-bg);
    border: 1px solid var(--menu-border-color);
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 8px var(--shadow-color); /* NEUTRAL */
    border-radius: 0.5rem;
    padding: 0.375rem;
}

/* Smooth dropdown entrance (opacity only — transform would override Popper positioning) */
.dropdown-menu.show {
    animation: dropdownFadeIn 0.15s ease forwards;
}

@keyframes dropdownFadeIn {
    from { opacity: 0; }
    to   { opacity: 1; }
}

.dropdown-item {
    color: var(--menu-item-color);
    padding: 0.5rem 0.75rem;
    border-radius: 0.25rem;
    transition: background-color 0.15s ease;
}

.dropdown-item:hover,
.dropdown-item:focus {
    color: var(--menu-item-hover-color);
    background-color: var(--menu-item-hover-bg);
}

/* Menu items that ARE a switch - "Admin only" on the insights kebabs and on the
   graph card menu: role="menuitemcheckbox" + aria-checked carry the state, so
   ONE rule paints it for every menu. On = an inset outline in the brand accent
   and a check on the right; off = a muted X. Never Bootstrap's .active fill:
   that is the stock blue, which no brand chose. */
.dropdown-item[role="menuitemcheckbox"] {
    display: flex;
    align-items: center;
}

.dropdown-item[role="menuitemcheckbox"]::after {
    content: "\f659"; /* bi-x-lg */
    font-family: bootstrap-icons;
    font-size: 0.8em;
    line-height: 1;
    margin-left: auto;
    padding-left: 1rem;
    opacity: 0.45;
}

.dropdown-item[role="menuitemcheckbox"][aria-checked="true"] {
    box-shadow: inset 0 0 0 1px var(--menu-item-checked-color);
}

.dropdown-item[role="menuitemcheckbox"][aria-checked="true"]::after {
    content: "\f633"; /* bi-check-lg */
    color: var(--menu-item-checked-color);
    opacity: 1;
}

/* Modals */

/* Reserve the scrollbar gutter so it cannot come and go.
   Bootstrap scrolls the .modal element itself (overflow-y: auto) and centres
   .modal-dialog inside it, so whenever the content shrinks past the viewport
   the scrollbar disappears, the modal's client box gains ~15px, and the centred
   dialog re-centres half that - the whole modal appears to jump sideways. It
   reads as a width change, but the dialog's width never changes.
   Measured on the site-settings Channels tab: filtering to a one-channel device
   took the dialog from x=249 to x=256.
   Applied to every modal rather than to that tab, because any modal with
   dynamic content has the same defect. Browsers without scrollbar-gutter simply
   keep today's behaviour. */
.modal {
    scrollbar-gutter: stable;
}

.modal-colour {
    background-color: var(--modal-bg);
}

.modal-header {
    border-bottom: 0;
    background-color: var(--modal-header-bg);
    color: var(--modal-header-color);
}

/* Bootstrap's close X is a baked-in black data-URI image and cannot follow a
   color variable; masking the same X shape and painting with currentColor
   makes it wear the header's ink for any brand. */
.modal-header .btn-close,
.ds-nav-sheet .btn-close,
.sidebar-head .btn-close {
    background-image: none;
    background-color: currentColor;
    -webkit-mask: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e") center / 1em auto no-repeat;
    mask: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e") center / 1em auto no-repeat;
}

.modal-custom-size {
    max-width: 80vw;
}

/* Laptop widths (up to 1500px): 80vw left the site-settings modal about
   1000px of usable body, too little for three add-channel cards; 92vw buys
   about 160px there without touching desktops (owner decision 2026-09-18).
   Global to every .modal-custom-size on purpose: scoping it to the channel
   forms with :has() would make an open modal jump wider the moment the
   add-channels partial swaps in. The <=560px rule further down still takes
   the modal to 100vw on phones. */
@media (max-width: 1500px) {
    .modal-custom-size {
        max-width: 92vw;
    }
}

.modal-secondary-title {
    color: var(--primary-bg);
}

/* Accordion */
.accordion {
    border: 1px solid var(--accordion-border-color);
}

.accordion-collapse {
    transition: height 0.2s ease-in-out;
    overflow: hidden; /* Ensures content doesn’t spill during transition */
}

.accordion-collapse:not(.show) {
    height: 0;
}

.accordion-collapse.show {
    height: auto;
    overflow: visible;
}

.accordion-item {
    background-color: var(--accordion-bg);
    border: 1px solid var(--accordion-border-color);
}

:root.light-mode .accordion-item {
    border: 1px solid var(--accordion-border-color);
}

.accordion-item.channel-inactive {
    opacity: 0.6;
}

.accordion-item.channel-inactive .accordion-button {
    border-left: 3px solid var(--status-warning);
}

/* Triggered alert in the merged Alerts list. Same left-stripe idiom as the
   inactive channel above, in danger rather than warning - deliberately the same
   mechanism so the two flat lists flag a row the same way. No opacity change:
   an inactive channel is de-emphasised, a triggered alert is the opposite. */
.accordion-item.alert-triggered .accordion-button {
    border-left: 3px solid var(--status-danger);
}

/* The one field an alert TEMPLATE cannot fill in (alert_templates_plan D5).
   Applying a template fills every other control, which makes the empty channel
   selects the least conspicuous thing on a form that otherwise looks finished -
   exactly backwards. Warning, not danger: nothing is wrong yet, there is simply
   a decision left, and the row's Triggered stripe above must stay the loudest
   red on this page. Cleared by pages/alert_form.js the moment a channel is
   picked, so the highlight only ever marks what is actually outstanding. */
.form-select.alert-channel-needed {
    border-color: var(--status-warning);
    box-shadow: inset 0 0 0 1px var(--status-warning);
}

/* The count of what is still outstanding, above the trigger slots. Matches the
   left-stripe idiom the two flat lists use for a row that needs attention,
   rather than inventing a third way to say the same thing. */
.alert-template-pending {
    border-left: 3px solid var(--status-warning);
    padding-left: 0.75rem;
}

.accordion-button {
    color: var(--accordion-color);
    background-color: var(--accordion-bg);
    font-weight: 500;
    font-size: 0.5em;
    padding: 0.5rem 2rem 0.5rem 1rem;
}

.accordion-button:focus {
    box-shadow: none;
}

.accordion-button:not(.collapsed) {
    /*font-weight: 600;*/
    /*font-size: 0.7em;*/
    letter-spacing: 0.05em;
    background-color: var(--accordion-open-bg);
    color: var(--accordion-open-color);
    box-shadow: none;
}

/* Deliberately NO chevron on accordion buttons - an owner ruling (2026-08-27),
   not an accident. The previous rule embedded stroke='var(--text-color)' in a
   data-URI SVG, which can never resolve (custom properties don't reach a
   separate SVG document), so chevrons had been invisible since it was written;
   the app's accordions signal the open state by the fill change instead. The
   ::after box is kept (background-image: none, overriding Bootstrap's own
   chevron) so layout is byte-identical to the invisible-image era. */
.accordion-button::after,
.accordion-button:not(.collapsed)::after {
    background-image: none;
}

.accordion-button:hover {
    background-color: var(--accordion-hover-bg);
    color: var(--accordion-hover-color);
}

.accordion-body {
    color: var(--accordion-color);
    background-color: var(--accordion-bg);
    padding: 0;
}

/* ── Channel section containers (outer structure) ───────────── */

/* Major sections (Controller/Logger/Virtual Channels) in settings tabs */
.channel-section {
    background-color: var(--bg-color);
    border: 1px solid var(--border-color);
    border-radius: 8px;
    padding: 1rem 1.25rem;
    margin-bottom: 1.5rem;
}

/* ── Shared flat-list vocabulary (Site Settings > Channels, Alerts) ──
   Adopted wholesale from the insights widget-config rail's channel table
   (.insights-ct-* in static/css/insights.css): micro-caps column headers, a
   two-line identity cell of bold name over a monospace provenance line, the
   rest in aligned columns on hairline separators, all inside a boxed group with
   an h6 border-b-lt title.

   Restated here rather than imported because insights.css is only served on
   /insights. The VALUES below are copied from that file on purpose - font
   sizes, opacities and the 6% hairline are the same numbers - so the two
   surfaces read as one system. Change them together. */

/* One sub-zone group. Sits on the tab's .channel-section panel, so it takes the
   lighter --primary-bg fill to read as a box on a box (the rail's group boxes
   do the same against the rail body). */
.ds-group {
    background-color: var(--primary-bg);
    border: 1px solid var(--border-color);
    border-radius: 8px;
    padding: 0.8rem 1rem 0.55rem;
    margin-bottom: 0.9rem;
}

.ds-group > h6 {
    display: flex;
    align-items: baseline;
    gap: 8px;
    padding-bottom: 0.4rem;
    margin-bottom: 0.6rem;
    font-size: 13px;
    font-weight: 600;
}

.ds-group-count {
    font-size: 11px;
    font-weight: 500;
    opacity: 0.5;
    font-variant-numeric: tabular-nums;
}

/* Every page that hosts #modal marks its dialog .modal-dialog-centered, which
   flex-centres .modal-content inside a full-height dialog. That is right for a
   short dialog and wrong for one whose height CHANGES: expanding a row in these
   flat lists grew the content, so the whole modal re-centred - it slid up by
   half the row body on open and back down on close, which reads as the modal
   jumping under the pointer. Top-aligning removes the movement entirely; the
   content still scrolls normally once it outgrows the viewport.

   Scoped by :has() to the dialog that actually holds the settings partial
   (#siteSettingsData is its hidden data marker), so every other modal on every
   other page keeps the centring it was designed with. A browser without :has()
   simply keeps today's behaviour. */
.modal-dialog-centered:has(#siteSettingsData) {
    align-items: flex-start;
}

/* ---- The row and its cells ---- */

/* THE COLUMN CONTRACT. The header strip and every row are SEPARATE flex
   containers - the head is a sibling of the accordion items, each row is inside
   its own accordion button - so they line up only if all three of these hold:

     1. identical content-box width  (--ds-chevron, below)
     2. no column sized in font-relative units, because the head is 10.5px and a
        row is 12px
     3. no column whose basis is `auto`, because that is its own text's width and
        the head's text is a label, not a value

   Every one of those was violated at once, which is why the columns drifted. Do
   not reintroduce `ch`, `auto` or `fit-content` in the column budget below. */
.ds-list,
.ds-row--head {
    /* Bootstrap's accordion chevron is an in-flow flex ITEM of the button, so it
       eats width from the row beside it. The head has no chevron, so without
       restating the number here it is exactly one chevron wider than every row
       it labels. Shared as a variable so the ::after rule and this padding
       cannot drift apart. */
    --ds-chevron: 0.85rem;
    /* Clear of the gear, which is absolutely positioned at the right edge. */
    --ds-row-gutter: 1.75rem;
}

.ds-row {
    display: flex;
    align-items: center;
    gap: 8px;
    width: 100%;
    min-width: 0;
    padding-right: var(--ds-row-gutter);
}

.ds-cell {
    display: flex;
    align-items: center;
    gap: 6px;
    min-width: 0;
}

/* Column budget. Fixed px for the serial, which cannot grow; percentages for
   the rest, ranked by how much of the answer each one carries. Same reasoning
   as the rail's .insights-ct-td-* block. */
.ds-col-name {
    flex: 1 1 32%;
    flex-direction: column;
    align-items: flex-start;
    gap: 1px;
}

.ds-col-device {
    flex: 0 1 26%;
}

/* Room for a 10-digit Cloudworks serial plus enough air that the next column is
   not touching it. In rem, NOT ch: `ch` is font-relative, and the head strip
   renders at 10.5px against a row's 12px, so the same `12ch` came out 83px in
   the header and 93px in the rows and shoved every column after it out of
   step. */
.ds-col-serial {
    flex: 0 0 5.8rem;
    font-variant-numeric: tabular-nums;
}

/* A percentage basis rather than `auto`. `auto` means "as wide as my own
   content", which for a row is the condition text and for the head is the word
   CONDITION - two different widths, so the column could never align. The row is
   still spanned rather than clustered left: .ds-col-name is the one column that
   grows, and it is the right one to absorb the slack because it carries the
   identity. */
.ds-col-tail {
    flex: 0 1 22%;
}

/* Header strip: micro-caps, the rail's exact 10.5px/0.55. Carries NO rule of
   its own - the group title already has one and the first row brings its own
   hairline, so a third line here reads as clutter. */
.ds-row--head {
    /* Matches the left padding on .ds-list .accordion-button. The head strip is
       a SIBLING of the accordion items rather than a child of one, so it does
       not inherit the button's padding and has to restate it or the column
       labels sit left of the values they label. */
    padding-left: 10px;
    /* And the same on the right, plus the chevron the rows lose to Bootstrap
       but the head does not have - see the column contract above. This is what
       makes the two content boxes the same width, which every percentage
       column below is resolved against. */
    padding-right: calc(var(--ds-row-gutter) + var(--ds-chevron));
    padding-bottom: 3px;
    font-size: 10.5px;
    text-transform: uppercase;
    letter-spacing: 0.03em;
    opacity: 0.55;
    font-weight: 600;
}

.ds-name-line {
    display: flex;
    align-items: center;
    min-width: 0;
    max-width: 100%;
}

/* Only the name truncates. Status - the badges beside it - must always survive. */
.ds-name {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 12.5px;
    font-weight: 600;
    line-height: 1.2;
}

/* PROSE a person typed - the alert description. Deliberately unlike .ds-prov
   directly below it, because on an alert row the two are stacked and would
   otherwise read as one wrapped paragraph: this keeps the row's proportional
   face and drops to the faintest ink in the stack, so it ranks below both the
   name above it and the rule below it. The contrast that separates them is
   PROPORTIONAL-vs-MONOSPACE - not size, which would just look like a mistake at
   these sizes, and not colour, which is spoken for by status. */
.ds-desc {
    font-size: 10.5px;
    opacity: 0.42;
    line-height: 1.25;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    max-width: 100%;
}

/* Machine values the display name hides: a channel's backend name and unit, an
   alert's trigger rule. Monospace so they read as data rather than prose. */
.ds-prov {
    font-family: monospace;
    font-size: 10px;
    opacity: 0.5;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    max-width: 100%;
}

/* WC / CW / VC - two characters, so the slot self-aligns. A low-saturation
   tint, never the accent: it is a category, not a state. */
.ds-kind {
    flex: 0 0 auto;
    padding: 0 5px;
    border-radius: 3px;
    background: color-mix(in srgb, var(--text-color) 10%, transparent);
    color: var(--text-color);
    font-family: monospace;
    font-size: 10px;
    letter-spacing: 0.04em;
    opacity: 0.85;
}

/* An Alerts row sits on every device its triggers touch (unified alerts Phase
   4): the device cell lists one chip+name line per device, and the serial cell
   stacks to match, so the two columns read line for line. Same 1px rhythm as
   the two-line identity cell. */
.ds-device-stack {
    flex-direction: column;
    align-items: flex-start;
    justify-content: center;
    gap: 1px;
}

.ds-device-line {
    display: flex;
    align-items: center;
    gap: 4px;
    min-width: 0;
    max-width: 100%;
}

/* How many alerts are CONFIGURED on this row. Distinct from .alert-icon-channel
   beside it, which is the live "firing right now" signal. */
/* Clickable since phase 3 - it crosses to the Alerts tab filtered to this
   channel - and an <a>, because it renders inside the row's accordion-button and
   a <button> there would break the parse (see site_channels.html). So the
   defaults to undo are the global anchor's, not a button's: `a` paints
   var(--link-color) and `a:hover` (0-1-1) recolours to var(--link-hover-color),
   which would turn the pill into a link the moment the pointer crossed it. The
   :hover block below outranks it at 0-2-0 and is what keeps the ink constant. */
.ds-alert-count {
    background: color-mix(in srgb, var(--text-color) 12%, transparent);
    color: var(--text-color);
    font-weight: 500;
    font-size: 9.5px;
    padding: 2px 5px;
    line-height: inherit;
}

.ds-alert-count:hover,
.ds-alert-count:focus-visible {
    /* Darkens rather than recolours: the pill must stay legible in both themes,
       and its ink never changes. */
    background: color-mix(in srgb, var(--text-color) 24%, transparent);
    color: var(--text-color);
}

.ds-alert-count:focus-visible {
    outline: 2px solid var(--accent-color);
    outline-offset: 1px;
}

/* ---- Accordion chrome, neutralised so the rows read as table rows ----
   The list still renders THROUGH accordion_item (enableEdit depends on its
   ids), so the Bootstrap button surface has to be quietened down to a row:
   no fill, a hairline above, and the compact padding the rail's td uses. */
.ds-list .accordion-item {
    background: transparent;
    border: 0;
}

/* Light mode gives every .accordion-item a full box border at 0-3-0 specificity
   (:root.light-mode .accordion-item), which outranks the rule above and drew a
   rounded outline around every row - a box per row inside a box per group. The
   selector has to be at least as specific to unset it, so this is a specificity
   fix, not a duplicate. */
:root.light-mode .ds-list .accordion-item {
    border: 0;
}

.ds-list .accordion-item + .accordion-item .accordion-button,
.ds-list .accordion-item:first-child .accordion-button {
    border-top: 1px solid color-mix(in srgb, var(--text-color) 6%, transparent);
}

.ds-list .accordion-button {
    background: transparent;
    /* Left padding lifts the name off the group's inner edge. .ds-row--head
       carries the SAME value so the column header strip stays aligned with the
       rows - the head is a sibling of the items, not a child of this button. */
    padding: 7px 0 7px 10px;
    font-size: 12px;
}

/* A hover changes the SURFACE only: a 4% wash instead of the Accordion hover bg,
   under the row's own hover ink. */
.ds-list .accordion-button:hover {
    background: color-mix(in srgb, var(--text-color) 4%, transparent);
}

/* The open row wears the app's accordion fill - var(--accordion-open-bg), the
   same fill #ownersAccordion uses - so an expanded row here reads as the
   same object as an expanded owner elsewhere in the app. This deliberately
   REPLACES an earlier 8%-accent wash with an inset stripe: that was quieter,
   but it made these lists the only accordion in the app that did not announce
   an open row the house way. letter-spacing stays normal (the base rule opens
   it to 0.05em, which is too loose for a dense data row), and there is no inset
   stripe any more - it has nothing left to separate against on a solid fill. */
.ds-list .accordion-button:not(.collapsed) {
    background: var(--accordion-open-bg);
    /* Explicit, not inherited from the base accordion rule: the :hover repair
       above also matches an open hovered row and would win on file order. */
    color: var(--accordion-open-color);
    letter-spacing: normal;
}

/* The per-theme ink split that used to live here as a scoped override is now
   the app-wide --button-text variable (dark #fff, light --text-color, brand
   overridable) — the "app-wide change deliberately NOT done here" note that
   previously justified the override has been done for real. */

/* Both of these are de-emphasised AGAINST A NEUTRAL ROW - 50% ink for the
   provenance line, a 10% wash behind the kind badge. On the accent fill that
   reads as illegible rather than as secondary, so the open row lifts them just
   far enough to stay readable while still ranking below the name. */
.ds-list .accordion-button:not(.collapsed) .ds-prov {
    opacity: 0.75;
}

/* Same lift for the description line, and it needs MORE of one: it sits fainter
   than .ds-prov by design, which is legible against a neutral row and not
   against the accent fill. Still below .ds-prov so the ranking survives. */
.ds-list .accordion-button:not(.collapsed) .ds-desc {
    opacity: 0.68;
}

/* The kind badge is the one element the accent fill genuinely broke, and raising
   its wash could not fix it: a translucent tint of the row's own ink over a
   saturated fill measured 1.38:1 in light and 1.36:1 in dark, so the badge SHAPE
   dissolved and its two letters read as ink floating on the accent. On the open
   row it becomes an opaque chip instead - the same object, punched through the
   fill rather than mixed into it.

   --bg-color, not --primary-bg, and the intuitive choice is the wrong one: the
   chip sits on a card, so the card's own surface reads as the natural pick, but
   --primary-bg clears the 3:1 non-text threshold only in light (3.54) and fails
   in dark (2.75). --bg-color is the one token that passes BOTH (3.11 / 3.09),
   because it is the app's deepest neutral in dark and its lightest in light -
   exactly the property a chip needs to punch through an accent. Ink comes from
   the row (17.6:1 light, 16.7:1 dark). */
.ds-list .accordion-button:not(.collapsed) .ds-kind {
    background: var(--bg-color);
    opacity: 1;
}

/* Bootstrap's chevron is sized for a full-width header; shrink it to the row.
   The width is --ds-chevron because .ds-row--head has to pad by exactly this
   much to match the row's content box - see the column contract. */
.ds-list .accordion-button::after {
    background-size: var(--ds-chevron);
    width: var(--ds-chevron);
    height: var(--ds-chevron);
}

.ds-list .accordion-item.channel-inactive {
    opacity: 0.55;
}

.ds-list .accordion-item.channel-inactive .accordion-button {
    border-left: 0;
    box-shadow: inset 2px 0 0 var(--status-warning);
}

/* The triggered stripe gets the SAME treatment as the inactive one above, and
   for a reason that is not cosmetic: a `border-left` occupies layout, so a
   striped row's name sat 3px right of every other row and of the column header
   above it. An inset shadow paints in the same place without displacing
   anything, so the column stays true whatever a row's state.

   CORRECTION (Phase 5b, measured): an earlier version of this comment claimed
   the inset shadow also keeps the stripe visible on the open row's accent fill.
   It does not - painting in the right PLACE is not the same as being legible,
   and the numbers are damning: red on the fill is 1.28:1 in light and 1.34:1 in
   dark, amber 1.10:1 and 1.55:1. The open row is handled below. */
.ds-list .accordion-item.alert-triggered .accordion-button {
    border-left: 0;
    box-shadow: inset 2px 0 0 var(--status-danger);
}

/* On an OPEN row the status colour has nowhere to go: both stripes are within
   1.6:1 of the accent fill, so the row silently loses the one mark that says
   triggered or inactive. Giving the stripe a 1px hairline of --bg-color beside
   it restores the edge without changing what the stripe MEANS - red stays red
   and amber stays amber, which a theme-flipped neutral stripe would have cost
   (triggered and inactive rows would then look alike).

   The order matters: inset shadows paint first-on-top, so the 2px status band
   sits over a 3px hairline band and only its outer 1px shows. Same total width
   as the closed row, so the column contract is untouched.

   TRIGGERED, measured: hairline vs fill 3.11 light / 3.09 dark, stripe vs its
   own hairline 3.97 / 4.14. Both clear the 3:1 non-text threshold.

   INACTIVE does NOT, and the reason is worth knowing: .channel-inactive puts
   `opacity: .55` on the whole item, which is a GROUP - fill, stripe and hairline
   are all composited toward the card together, so contrast between them is
   compressed no matter what colours they are. Real figures there are 1.87 light
   / 2.34 dark for stripe-vs-hairline. Kept anyway because it is still an edge
   where there was none (the amber stripe alone measured 1.00:1 against the
   dimmed fill in light - identical luminance, completely invisible), but it is
   NOT 3:1 and cannot be made so from here. Fixing it means moving the dimming
   off the item and onto its content, which changes how every inactive row in
   the app looks - deliberately out of Phase 5b's scope. */
.ds-list .accordion-item.channel-inactive .accordion-button:not(.collapsed) {
    box-shadow: inset 2px 0 0 var(--status-warning), inset 3px 0 0 var(--bg-color);
}

.ds-list .accordion-item.alert-triggered .accordion-button:not(.collapsed) {
    box-shadow: inset 2px 0 0 var(--status-danger), inset 3px 0 0 var(--bg-color);
}

/* ── Shared list filter bar ──────────────────────────────────
   Chips themselves are the shared .ds-chip vocabulary (see the Chips section);
   only the bar and its search field are new here. */
.ds-filter-bar {
    margin-bottom: 0.9rem;
}

/* One line of controls. Row 1 is "which box", row 2 is "where on site". */
.ds-filter-row {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 6px;
    margin-top: 9px;
}

/* .ds-chips owns the top margin inside a chipset group; inside a filter row the
   row already spaces things, so neutralise it. */
.ds-filter-row .ds-chips {
    margin-top: 0;
}

/* ---- Facet popout (used where the value count is unbounded, e.g. devices) ---- */
.ds-filter-menu {
    position: relative;
    display: inline-flex;
}

/* Deliberately the same height, radius and ink as .ds-chip so a menu and a
   chipset read as two forms of one control, not two components. */
.ds-menu-btn {
    display: inline-flex;
    align-items: center;
    gap: 7px;
    max-width: 26ch;
    padding: 3px 9px 3px 11px;
    border: 1px solid var(--chip-border-color);
    border-radius: 999px;
    background: transparent;
    color: var(--text-color);
    font-size: 0.78rem;
    font-weight: 500;
    line-height: 1.5;
    letter-spacing: 0.02em;
    opacity: 0.72;
}

.ds-menu-btn:hover {
    opacity: 1;
    background: var(--chip-hover-bg);
}

.ds-menu-btn:focus-visible {
    outline: 2px solid var(--accent-color);
    outline-offset: 1px;
    opacity: 1;
}

/* A set filter carries the accent, exactly as a selected chip does. */
.ds-menu--set .ds-menu-btn {
    background: var(--chip-on-bg);
    border-color: var(--chip-on-bg);
    color: var(--chip-on-color);
    opacity: 1;
}

.ds-menu-label {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.ds-menu-caret {
    font-size: 8px;
    opacity: 0.7;
}

/* A long device list scrolls rather than running off the modal. */
.ds-menu-list {
    max-height: 320px;
    overflow-y: auto;
    font-size: 0.8rem;
}

.ds-menu-list .dropdown-item {
    display: flex;
    align-items: center;
    gap: 10px;
}

.ds-menu-list .dropdown-item.active {
    background: var(--menu-item-active-bg);
    color: var(--menu-item-color);
}

.ds-menu-optlabel {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.ds-menu-list .ds-chip-count {
    margin-left: auto;
}

/* The channel's device, so two channels called "pH" are tellable apart. Between
   the label and the count, and the first thing to give up its width. */
.ds-menu-optdetail {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    opacity: 0.65;
    font-size: 0.9em;
}

/* Filter at the head of a popout whose option count is unscannable. Sticky so it
   stays reachable while the options below it scroll. */
.ds-menu-search {
    position: sticky;
    top: 0;
    z-index: 1;
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 6px 10px;
    /* --dropdown-bg, because this IS the dropdown surface and it is themed in
       both palettes. A sticky header over a transparent background would let the
       scrolling options show through it. */
    background: var(--dropdown-bg);
    border-bottom: 1px solid rgba(128, 128, 128, 0.25);
}

.ds-menu-search .bi-search {
    opacity: 0.6;
    font-size: 0.8rem;
}

/* Hidden by the MENU's own filter, which is a different question from
   .d-none - that one means "this option can match no row". Two mechanisms
   because they must compose: a search that hides an option must not resurrect
   it when the row filter would also have hidden it. */
.ds-option-filtered {
    display: none !important;
}

/* Sits beside the menu, not inside it: clearing is one click from the bar
   rather than a trip back into the list you are trying to leave. */
.ds-menu-clear {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 22px;
    height: 22px;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: transparent;
    color: var(--text-color);
    opacity: 0.6;
    font-size: 13px;
    line-height: 1;
}

.ds-menu-clear:hover {
    opacity: 1;
    background: color-mix(in srgb, var(--text-color) 10%, transparent);
}

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

.ds-filter-search .bi-search {
    position: absolute;
    left: 10px;
    top: 50%;
    transform: translateY(-50%);
    font-size: 12px;
    opacity: 0.5;
    pointer-events: none;
}

.ds-filter-search .form-control {
    padding-left: 30px;
    border: 1px solid color-mix(in srgb, var(--text-color) 14%, transparent);
    border-radius: 7px;
}

.ds-chip-count {
    opacity: 0.6;
    font-variant-numeric: tabular-nums;
}

/* Device names are free text, so a chipset of them can otherwise run the width
   of the bar. The full value is in the chip's title. */
.ds-chip-label {
    display: inline-block;
    max-width: 20ch;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    vertical-align: bottom;
}

/* Tables */
.table {
    margin-bottom: 0;
    border: 1px solid var(--table-border);
}

.table-dark {
    --bs-table-bg: var(--table-bg);
    --bs-table-color: var(--table-color);
    --bs-table-hover-bg: var(--table-hover-bg);
    /* Bootstrap's .table-dark also sets --bs-table-striped-bg/-color to values
       meant for a dark table, and .table-striped paints them as an inset
       box-shadow. Without theming them too, every second row of a striped table
       kept Bootstrap's dark stripe in LIGHT mode - a dark band with unreadable
       text on an otherwise light table. Theme them with the same variables as
       the base so striping follows the active theme. */
    --bs-table-striped-bg: var(--table-stripe-bg);
    --bs-table-striped-color: var(--table-color);
    --bs-table-accent-bg: transparent;
    /* Same story for .table-hover: Bootstrap's dark-table hover ink is #fff, so
       in LIGHT mode a hovered row turned its text white on the light hover wash. */
    --bs-table-hover-color: var(--table-color);
    /* The row separators (the cell rule below draws them) */
    --bs-table-border-color: var(--table-divider-color);
}

/* The lines between rows. Bootstrap's reboot has table cells inherit their
   border colour from the table, whose border .table above sets to the frame
   colour, so --bs-table-border-color never reached them; the divider token
   defaults to the frame colour, so rows look the same until a brand sets it. */
.table-dark > :not(caption) > * > * {
    border-color: var(--bs-table-border-color);
}

.table-dark-inactive {
    --bs-table-bg: var(--gray);
    --bs-table-color: var(--text-color-form);
    --bs-table-hover-bg: var(--gray);
}

/* A muted second line on an inactive row (the Devices page's serial and
   owner): .gray-text paints var(--gray), which IS this row's fill, so the text
   vanished at 1:1 under every brand. Take the row's own ink, quietened. */
.table-dark-inactive .gray-text {
    color: inherit !important;
    opacity: 0.75;
}

/* A row a click selects (reports, templates, adding existing users). Under the
   pointer it takes the table's own hover shade: painting it like a selected row
   would make hovered and selected rows look alike in a multi-select list. */
.selectable-row {
    cursor: pointer;
}

/* A selected row. Bootstrap draws the hover shade as an inset shadow over the
   cell's fill, so a selected row turns it off to keep its fill under the pointer. */
tr.selected-row {
    --bs-table-hover-bg: transparent;
}

tr.selected-row > td,
tr.selected-row > th {
    background-color: var(--table-selected-bg) !important;
    color: var(--table-selected-color);
}

/* Forms */
.form-control {
    background-color: var(--input-bg);
    color: var(--input-color);
    border: 0;
}

.form-control::placeholder {
    color: var(--input-placeholder-color);
}

.form-checkbox {
    font-size: 1.938em !important
}

/* Native date / time / month controls follow the APP theme, not the OS one.
   body declares `color-scheme: light dark`, which hands the choice to the OS,
   so with the OS in dark mode and the app in light mode Chrome drew a white
   picker glyph on the app's white input (invisible), and the picker popup
   opened dark on a light page. Pinning the scheme per theme makes the glyph
   dark on the light-mode input and light on the dark-mode input - neutral, so
   it reads under every brand (owner ruling 2026-09-10: no colour a brand did
   not choose; an accent tint was rejected because a brand's accent can fall
   under 3:1 on the dark input). The input's own colours are unaffected: the
   scheme only drives what the browser paints itself. */
input[type="date"],
input[type="time"],
input[type="month"],
input[type="datetime-local"] {
    color-scheme: dark;
}

:root.light-mode input[type="date"],
:root.light-mode input[type="time"],
:root.light-mode input[type="month"],
:root.light-mode input[type="datetime-local"] {
    color-scheme: light;
}

input[type="date"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator,
input[type="month"]::-webkit-calendar-picker-indicator,
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
    cursor: pointer;
}

/* NOTE: flatpickr styling is owned solely by static/css/flatpickr_theme.css
   (the single source of truth). Do NOT re-add flatpickr rules here — a global
   copy in styles.css would override the theme by load order (the drift this
   consolidation removed). */

.form-control:focus {
    border-color: var(--input-focus-border-color);
    box-shadow: 0 0 0 0.25rem var(--focus-ring-color);
}

.form-select:focus {
    border-color: var(--input-focus-border-color);
    box-shadow: 0 0 0 0.25rem var(--focus-ring-color);
}

.form-select:hover {
    border-color: var(--input-focus-border-color);
    box-shadow: 0 0 0 0.25rem var(--focus-ring-color);
}

/* .is-locked = the disabled look for an input that must stay READONLY, because a disabled
   input is never posted. Opt-in by class, never by [readonly] itself: flatpickr marks its
   inputs readonly and they must keep their normal look. Toggled by add_cw_logger.js for the
   datalog forward address (docs/logger_email_forward_plan.md D2). */
.form-control:disabled,
.form-control.is-locked {
    opacity: 0.5;
    background-color: var(--input-bg)
}

.form-select:disabled {
    opacity: 0.5;
    background-color: var(--input-bg)
}

#accordionSearch.form-control {
    color: var(--search-color);
    background-color: var(--search-bg);
    border: 0;
}

#tableSearch.form-control {
    color: var(--search-color);
    background-color: var(--search-bg);
    border: 0;
}

#dropReportMonthPicker.form-control {
    font-size: 0.875rem;
    font-weight: 400;
    border: 1px solid var(--border-color);
    border-right: 0;
}

#dropReportMonthPicker + .search-clear-btn {
    border: 1px solid var(--border-color);
    border-left: 0;
}

.light-mode #accordionSearch.form-control {
    border: 1px solid var(--border-color);
}

.light-mode #tableSearch.form-control {
    border: 1px solid var(--border-color);
}

.light-mode #ratesSearch.form-control {
    border: 1px solid var(--border-color);
}

#cardSearchInput.form-control {
    color: var(--search-color);
    background-color: var(--search-bg);
    border: 0;
}

.light-mode #cardSearchInput.form-control {
    border: 1px solid var(--border-color);
}

.form-select {
    background-color: var(--input-bg);
    color: var(--input-color);
    border: 0;
}

/* Phone = country-code select + national number, on every form that takes one.
   Phones keep the compact layout: a 6em select, and Bootstrap's input-group
   wrap moves the number onto its own line when the row is too tight. */
.input-group > .phone-country-select {
    flex: 0 0 6em;
    width: 6em;
}

/* From 576px the select fits names like "United Kingdom (+44)", the text
   phone_country_select.js shows while it is open. On a tight row (narrow
   columns, the side-by-side external-recipient rows) it gives way first -
   flex-shrink 10 - down to 6em, so the row never wraps; the number keeps room
   for a national number and takes what is left, and the label keeps its width
   to line up with the rows above. */
@media (min-width: 576px) {
    .input-group:has(> .phone-country-select) {
        flex-wrap: nowrap;
    }

    .input-group:has(> .phone-country-select) > .input-group-text {
        flex-shrink: 0;
    }

    .input-group > .phone-country-select {
        flex: 0 10 15em;
        min-width: 6em;
    }

    .input-group > .phone-country-select + .form-control {
        flex: 1 1 8.5em;
    }
}

.input-group-text {
    background-color: var(--input-addon-bg);
    color: var(--input-addon-color);
    width: 14em;
}

.input-group-text.short {
    width: 9.25em;
}

.form-opacity {
    background-color: var(--hl-bg);
    color: var(--text-color-form);
    opacity: 0.4;
    pointer-events: none;
}

.no-opacity {
    opacity: 1 !important;
}

.graph-datetime-select {
    background-color: var(--input-bg);
    color: var(--input-color);
    font-weight: 500;
    font-size: 1em;
    border: 1px solid var(--table-hover-bg);
    min-height: auto;
    padding: 0 0 0 .1em;
    margin: 0;
}

.form-check-input:checked {
    background-color: var(--check-on-bg);
    border-color: var(--check-on-bg);
}

textarea.form-control {
    min-height: 150px;
}

/* Cards are BORDERLESS, and that is the look to keep (owner ruling 2026-09-23).
   These rules used to say `border: 1px solid var(--navbar-border)`, but
   --navbar-border was a whole border shorthand ("1px solid ..."), so the value
   was invalid and the browser dropped it to no border - which is how every card
   has always rendered. Stated honestly now, so the result no longer depends on
   an accident and Bootstrap's own card border cannot come back. */
.card {
    background-color: var(--card-bg);
    color: var(--card-color);
    border: 0;
}

.card-header {
    background-color: var(--card-header-bg);
    color: var(--card-header-color);
    border-bottom: 0;
}

.card-header.clickable-header {
    background-color: var(--card-bg);
    color: var(--card-color);
    font-weight: 400;
    padding: 0.25rem 0.5rem 0.25rem 0.5rem;
    border: 0;
    border-radius: 0;
}

.card-header.clickable-header:hover {
    background-color: var(--card-header-hover-bg);
    color: var(--card-color);
}

.card-header.clicked {
    background-color: var(--alt-bg) !important;
    border: 3px solid var(--button-bg);
    border-bottom: 0;
}

.card-header.clicked:hover {
    background-color: var(--primary-bg) !important;
}

/* nav-link for Channel Cards */
.nav-pills .nav-link.active {
    background-color: var(--tab-active-bg);
    color: var(--tab-active-color) !important;
    border-bottom: 3px solid var(--tab-indicator-color);
}

.nav-pills .nav-link {
    font-size: smaller;
    font-weight: 500;
    border-bottom: 3px solid var(--tab-track-color);
    color: var(--tab-color) !important;
    padding: 0.5em .75em !important;
    border-radius: 0;
    border-top-left-radius: 8px;
    border-top-right-radius: 8px;
}

.nav-pills .nav-item .nav-link:hover {
    color: var(--tab-hover-color) !important;
}

.card-header a:hover,
.card-header a:hover i {
    color: var(--primary-bg);
}

.card-header-edit {
    background-color: var(--primary-bg);
    color: var(--text-color);
    font-weight: 600;
    font-size: 1.4em;
    padding-left: 0.2rem;
    border: 0;
    border-radius: 0.5rem;
}

.card-header-edit:active,
.card-header-edit:focus {
    background-color: var(--primary-bg);
    color: var(--text-color);
}

/* Flash message banner — scoped so it doesn't break Bootstrap .alert used by toasts */
#flashMessageContainer > #flash-message > .alert {
    z-index: 99999;
    color: var(--text-color);
    font-weight: bolder;
    text-align: center;
    position: fixed;
    border-radius: 0;
    top: 0;
    left: 0;
    width: 100%;
    opacity: 1;
}

/* DeltasmartNotifications toast overrides — reset the global .alert styles */
#ds-notification-container > .alert {
    position: relative;
    top: auto;
    left: auto;
    width: auto;
    border-radius: 0.375rem;
    text-align: left;
    font-weight: normal;
}

@keyframes slideIn {
    from { opacity: 0; transform: translateX(1rem); }
    to   { opacity: 1; transform: translateX(0); }
}


.error {
    background-color: var(--status-danger-bg);
}

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

.warning {
    background-color: var(--status-warning-bg);
}


/* The neutral button: the modal Close / Cancel buttons (a few selects and one
   link borrow its fill), and its hover. */
.light-bg {
    background-color: var(--button-neutral-bg);
    color: var(--button-neutral-color);
}

.light-bg-hover:hover {
    background-color: var(--button-neutral-hover-bg);
    color: var(--button-neutral-hover-color);
}

.gray-text {
    color: var(--gray) !important;
}

.table-head-text {
    --bs-table-color: var(--table-head-color);
}

.text-accent {
    color: var(--accent-color) !important;
}

.gray-dark-bg-text {
    color: var(--light-gray-text);
}

.gray-dark-bg-text:hover {
    color: var(--accent-color);
}

.gray-bg {
    background-color: var(--gray);
    color: var(--primary-bg);
}

/* Neutral surface swap. These classes replace an element's background with the
   page's alt surface, so they must also claim the ink that belongs to it. On a
   .button the brand button rule has already set color: var(--button-text) --
   the ink for the accent fill -- which a brand with a dark accent makes white:
   white on the light --alt-bg at ~1.09:1, i.e. invisible (the Show/Hide Graphs
   button in alerts/partials/triggered_alert_details.html). On a <div> these
   declarations are what the element already inherits, so they are a no-op. */
.contrast-background {
    background-color: var(--alt-bg) !important;
    color: var(--text-color);
}

.contrast-background-rounded {
    background-color: var(--alt-bg);
    color: var(--text-color);
    border-radius: 8px;
}

/* ... and its hover: the .button hover (0-1-1, earlier in this file) would swap
   in the accent fill. */
button.contrast-background-rounded:hover {
    background-color: var(--hl-bg);
    color: var(--text-color);
}

/* Borders */
.no_border {
    border: none !important;
}

.border-colour {
    border-color: var(--border-color);
}

.border-lt-grey {
    border: 1px solid var(--border-color);
    border-radius: 8px;
}

.border-b-lt {
    border-bottom: 1px solid var(--border-color);
}

.border-b-lt-big {
    border-bottom: 15px solid var(--border-color);
}

.border-t-lt {
    border-top: 1px solid var(--border-color);
}

.border-accent {
    border: 1px solid var(--accent-color) !important;
}

.light-mode .search-clear-btn {
    border: 1px solid var(--border-color);
    border-left: 0;
}

.search-clear-btn {
    border: 0;
}

/* Layout and spacing */
.row.flex-column {
    flex-direction: column;
}

.width-90 {
    width: 90%;
}

.mb-2px {
    margin-bottom: 2px;
}

.mt-n1em {
    margin-top: -1em;
}

.nmt-6 {
    margin-top: -6px !important;
}

.active-container {
    padding-top: 10px;
}

.max-width-100 {
    max-width: 100%;
}

/* Icons and sizes */
.logo {
    width: 60px;
    height: auto;
    opacity: 0.7;
}

/* Logo dark/light mode swap */
.logo-for-light { display: none; }
.logo-for-dark  { display: block; }

:root.light-mode .logo-for-light { display: block; }
:root.light-mode .logo-for-dark  { display: none; }

/* Large brand logo on the graphs site-selection prompt */
.site-prompt-logo {
    width: 60%;
    max-width: 600px;
}

.input-icon {
    width: 2.4em;
}

.ch-input-length {
    min-width: 100px;
    width: 150px;
}

.details-label {
    min-width: 170px;
}

.coord-input-length {
    width: 80px;
}

.label-length {
    min-width: 140px;
}

.label-length-s {
    min-width: 100px;
}

.font-xxs {
    font-size: .7em;
    font-weight: 400;
}

.icon-size-sm {
    font-size: .6em;
}

.icon-size-md {
    font-size: .8em;
}

.icon-size-lg {
    font-size: 1.2em;
}

.icon-size-xl {
    font-size: 1.5em;
}

.icon-size-xxl {
    font-size: 2.8em;
}

.icon-size-date::before {
    font-size: 1.7em;
    vertical-align: middle;
    line-height: normal;
}

/* Miscellaneous */
.border-accent-fill {
    border-color: var(--button-bg) !important;
}

.accordion-separator {
    /*border-bottom: 1px solid var(--border-color);*/
    height: 10px;
    background-color: var(--button-bg);
    margin-top: 1.25em;
}

.shadow {
    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); /* NEUTRAL */
}

.htmx-indicator {
    max-height: 1px;
    display: none;
}

.htmx-request.htmx-indicator {
    display: flex !important;
    max-height: revert;
}

.htmx-request.spinner-container {
    margin: 30px 30px;
}

.date-time-selector {
    margin-left: 0;
}

.cursor-pointer:hover {
    background-color: var(--table-hover-bg);
    color: var(--text-color) !important;
}

.cursor-pointer:hover .text-accent {
    color: var(--text-color) !important;
}

#siteDetails.htmx-added {
    opacity: 0;
}

#siteDetails {
    opacity: 1;
    transition: opacity 300ms ease-in-out;
}

.slider-title {
    position: absolute;
    top: .5em;
    left: 1em;
}

.user-settings-dropdown {
    min-width: 2em;
}

/* Accordion header gear toggle */
.actions-toggle {
    color: var(--bs-accordion-btn-color);
    text-decoration: none;
    z-index: 2;
}

.actions-toggle:hover {
    color: var(--accent-color);
}

/* Accordion header Insights link (6d): docked right beside the gear, same ink
   as the gear so it reads on both the collapsed surface and the expanded
   accent header. .no-gear docks it at the edge for roles without a gear. */
.accordion-header-insights {
    color: var(--bs-accordion-btn-color);
    text-decoration: none;
    z-index: 2;
    position: absolute;
    right: 2.1rem;
}

.accordion-header-insights:hover {
    color: var(--accent-color);
}

.accordion-header-insights.no-gear {
    right: 0.7rem;
}

/* On the expanded accent fill both docked links must switch to the surface's
   ink: their resting --bs-accordion-btn-color is a static value that never
   reacts to the fill change, which left dark icons on a dark brand accent. */
.accordion-button:not(.collapsed) .actions-toggle,
.accordion-button:not(.collapsed) .accordion-header-insights {
    color: var(--accordion-open-color);
}

/* Hovering an open row swaps the fill back to the neutral hover surface
   (.accordion-button:hover wins the background on file order), so every ink
   must revert WITH the fill: --accordion-open-color is the ink for the open
   fill only, and stranding it on the neutral hover surface leaves white-on-gray
   for brands with a light open-row ink. Title to the hover ink, docked links
   back to their resting color, direct link hover back to accent - identical to
   how a closed row behaves. */
.accordion-button:not(.collapsed):hover {
    color: var(--accordion-hover-color);
}

.accordion-button:not(.collapsed):hover .actions-toggle,
.accordion-button:not(.collapsed):hover .accordion-header-insights {
    color: var(--bs-accordion-btn-color);
}

.accordion-button:not(.collapsed):hover .actions-toggle:hover,
.accordion-button:not(.collapsed):hover .accordion-header-insights:hover {
    color: var(--accent-color);
}

/* Accordion header actions menu — desktop: horizontal icon strip */
.actions-menu {
    position: absolute !important;
    top: 1px;
    right: 2em;
    height: 2em;
    display: flex;
    align-items: center;
    background-color: var(--primary-bg);
    padding: 0.5rem;
    border-radius: 4px;
    box-shadow: 0 4px 6px var(--shadow-color);
    z-index: 1000;
    list-style: none;
    margin: 0;
}

.actions-menu .action-label {
    display: none;
}

.actions-menu .dropdown-divider {
    display: none;
}

.actions-menu .dropdown-item {
    display: flex;
    align-items: center;
    padding: 0.15rem;
    background: transparent;
    color: var(--menu-item-color);
    white-space: nowrap;
    width: auto;
}

.actions-menu .dropdown-item:hover,
.actions-menu .dropdown-item:focus {
    background: transparent !important;
    color: var(--menu-item-hover-color) !important;
}

.actions-menu .dropdown-item i {
    font-size: 1.25rem;
    margin-right: 0.25rem !important;
}

.interval-slide {
    height: auto;
    display: flex;
    align-items: center;
    position: absolute !important;
    top: 0;
    left: 4.85em;
    background-color: var(--primary-bg);
    padding: 0rem;
    border-radius: 4px;
    box-shadow: 0 4px 6px var(--shadow-color);
    transition: transform 0.01s ease-in-out;
    z-index: 2;
    pointer-events: auto;
}

.interval-slide[style*="display: flex"] {
    pointer-events: none;
}

/* Settings toggle */
.settings-toggle {
    color: var(--text-color);
    position: relative !important;
}

.settings-toggle:hover {
    color: var(--link-hover-color);
}

.dropdown-toggle .small-padding {
    padding: 0.25rem 0.25rem !important;
}

/* Graph/Chart */
.fixed-header {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 1000;
    background-color: var(--header-bg);
    -webkit-backdrop-filter: blur(12px) saturate(1.2);
    backdrop-filter: blur(12px) saturate(1.2);
    padding: 0 0.5em;
    /* Notched phones (head_core sets viewport-fit=cover): the status-bar inset
       becomes header padding, so the first row is never under the notch. 0 on
       every other device. header_height.js measures the header WITH it. */
    padding-top: env(safe-area-inset-top, 0px);
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.08); /* NEUTRAL */
    border-bottom: 1px solid var(--navbar-border-color);
}

/* Icon button hover in graphs header */
.fixed-header .nav-link.dropdown-toggle {
    border-radius: 0.375rem;
}

.fixed-header .nav-link.dropdown-toggle:hover {
    background-color: var(--nav-link-hover-bg);
}

.slider-trigger {
    border-radius: 0.375rem;
    transition: background-color 0.2s ease;
    padding: 4px 4px;
}

.slider-trigger:hover {
    background-color: var(--nav-link-hover-bg);
}

/* Page padding under the fixed header = the MEASURED header height (published
   as --ds-header-h on <html> by static/js/components/header_height.js, both
   dashboard bundles) + a breathing gap. The fallbacks reproduce the old 120px
   constant until the script runs; that constant was sized for the graphs
   desktop header (108px + 12px, so that page does not move by a pixel) and
   left a 44px dead band under the 76px phone header. The phone gap is set
   with the phone shell rules below. */
.page-container {
    padding-top: calc(var(--ds-header-h, 108px) + var(--ds-header-gap, 12px));
}

.date-range-selector {
    font-size: 1em !important;
}

.date-range-menu {
    min-width: fit-content;
}

.chart-container {
    border-radius: 8px;
    position: relative;
    height: 200px;
}

/* Hammer.js (graphs.html, docs/graphs_mobile_ux_plan.md 3.6) writes an inline
   touch-action: none on every chart canvas chartjs-plugin-zoom manages, at
   EVERY width, which would swallow page scrolling over a chart on any touch
   screen - so this rule is global, not in the phone clause. A stylesheet
   !important beats the inline value: one finger scrolls the page, two fingers
   reach the plugin (pinch), a horizontal drag pans a zoomed chart. Scoped to
   the graphs cards; the report and alert pages never load Hammer. */
#cardsContainer .chart-container canvas {
    touch-action: pan-y !important;
}

/* Graphs header row 2 (templates/dashboard/graphs.html): [spacer] [search]
   [actions] as one flex row at every width; the search grows to 480px. */
.ds-graphs-search {
    flex: 0 1 480px;
    max-width: 480px;
    min-width: 0;
}

/* The add-card placeholder's empty card (was an inline min-height). */
.ds-add-card-body {
    min-height: 300px;
}

.chart-container-alert {
    border-radius: 8px;
    position: relative;
    height: 250px;
}


.nav-main-dropdown {
    min-width: 200px;
}

.dropdown-submenu {
    position: relative;
}

.dropdown-submenu .dropdown-menu {
    top: 0;
    left: -100%;
    margin-top: -0.375rem;
}

.dropdown-submenu:hover > .dropdown-menu {
    display: block;
}

.dropdown-submenu > .dropdown-item::after {
    display: none;
}

.dropdown-submenu-left {
    right: 100%;
    left: auto !important;
}

.dropdown-submenu:hover > .dropdown-item {
    background-color: var(--menu-item-hover-bg);
    color: var(--menu-item-hover-color);
}



.resize-handle {
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    height: 10px;
    background-color: rgba(204, 204, 204, 0.15); /* NEUTRAL */
    border-bottom-left-radius: 8px;
    border-bottom-right-radius: 8px;
    cursor: ns-resize;
}

.sidebar-panel {
    position: fixed;
    top: 0;
    left: 0;
    height: 100vh;

    width: 0;
    z-index: 1050;
    background-color: var(--primary-bg);
    overflow-x: hidden;
    transition: 0.3s;
    box-shadow: 0 4px 8px var(--shadow-color);
}

/* Panel header (owner 2026-10-08): the page's name and the close button on
   their own line, the same grammar as the phone app-nav sheet (.ds-nav-sheet
   header: title + 44px masked close), so the X reads as the panel's and never
   as part of the site row below it. Left padding = the 12px gutter the site
   row and the list share. Replaces the 60px band the old absolute "x" link
   needed (and the phones' fixed corner "x"). */
.sidebar-head {
    display: flex;
    flex: none;
    align-items: center;
    padding: 10px 8px 10px 12px;
    border-bottom: 1px solid var(--border-color);
}

/* The title is the panel's name, so it outranks the sheet's 16px semibold:
   bold, a step bigger, in the brand's accent (owner 2026-10-08). */
.sidebar-title {
    margin: 0;
    font-size: 18px;
    font-weight: 700;
    color: var(--accent-color);
}

/* The close is the masked currentColor X (it already wears the theme ink), so
   insights.css's dark-mode invert for Bootstrap's black-image .btn-close
   (:root:not(.light-mode) .btn-close) would turn it dark; the id outranks it. */
#sidebarPanel .sidebar-head .btn-close {
    filter: none;
}

.sidebar-content {
    padding: 0;
}

/* Sidebar current-site card (docs/sidebar_current_site_plan.md). The panel is
   a column: the #sidebarCurrentSite slot stays put and only .sidebar-content
   scrolls, so nothing the list does (re-opening the remembered owner, a long
   search) can carry the card out of view. An empty slot takes no room. */
.sidebar-panel {
    display: flex;
    flex-direction: column;
}

/* Both reserve the same scrollbar gutter, always, so the pinned row and the
   list rows end on one right edge whether or not the list overflows (0px with
   overlay scrollbars). overflow-y:hidden makes the slot a scroll container,
   which is what scrollbar-gutter needs - so the row's gear menu uses Popper's
   fixed strategy (sidebar_current_site.html) to escape the clip. */
.sidebar-current-site {
    flex: none;
    overflow-y: hidden;
    scrollbar-gutter: stable;
}

.sidebar-panel .sidebar-content {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    scrollbar-gutter: stable;
}

/* One geometry at every width: the list spans the panel inside the same 12px
   gutter as the card. Bootstrap's .container capped it at 540 / 720px and
   centred it in the full-width panel between 576 and 991px, where nothing
   could line up with it (and its scrollbar shifted the centring). */
.sidebar-panel #sidebar-accordion-container {
    max-width: none;
}

/* The list's own top margin is room for nothing once the card sits above it. */
.sidebar-current-site:not(:empty) + .sidebar-content #sidebar-accordion-container {
    margin-top: 0.5rem !important;
}

.ds-current-site {
    display: flex;
    align-items: center;
    min-height: 40px;
    margin: 8px 12px 0;
    padding: 2px 4px 2px 12px;
    background-color: var(--accordion-bg);
    border: 1px solid var(--accordion-border-color);
}

/* A long name wraps to a second line, as the list rows' names do, rather
   than losing its end. */
/* No flex gap: the pin and the name carry their own spacing, so the view
   icon and the gear sit as close together as the list rows' docked pair. */
.ds-current-site-icon {
    margin-right: 8px;
}

.ds-current-site-name {
    margin-right: 8px;
    display: -webkit-box;
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    line-height: 1.25;
    font-weight: 600;
}

/* The other view, in the list's docked-icon ink (accordion-header-insights). */
.ds-current-site-view {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 0.15rem 0.4rem;
    color: inherit;
    text-decoration: none;
}

.ds-current-site-view:hover,
.ds-current-site-view:focus-visible {
    color: var(--accent-color);
}

/* "Where am I" in the list (docs/sidebar_current_site_plan.md, phase 2;
   components/sidebar_current.js sets .ds-current). VISUAL ONLY (owner
   2026-10-08): the current site's row gets a tint and an accent border, the
   owner that holds it the tint on its collapsed header (an open header already
   wears the accent fill). Inset shadows only, so no row grows or shifts. Table
   cells paint their fill through Bootstrap's inset 9999px shadow, so the tint
   joins the same stack, last (under the border lines). */
#sidebarPanel {
    --ds-current-tint: color-mix(in srgb, var(--accent-color) 16%, transparent);
    --ds-current-line: var(--accent-color);
}

#sidebarPanel tr.ds-current > * {
    box-shadow: inset 0 2px 0 var(--ds-current-line), inset 0 -2px 0 var(--ds-current-line),
                inset 0 0 0 9999px var(--ds-current-tint);
}

#sidebarPanel tr.ds-current > :first-child {
    box-shadow: inset 2px 0 0 var(--ds-current-line), inset 0 2px 0 var(--ds-current-line),
                inset 0 -2px 0 var(--ds-current-line), inset 0 0 0 9999px var(--ds-current-tint);
}

#sidebarPanel tr.ds-current > :last-child {
    box-shadow: inset -2px 0 0 var(--ds-current-line), inset 0 2px 0 var(--ds-current-line),
                inset 0 -2px 0 var(--ds-current-line), inset 0 0 0 9999px var(--ds-current-tint);
}

/* Owner: the org-role accordion's header button, or the owner/site-role
   table's heading line (the spread carries the tint past the text edge
   without moving it). */
#sidebarPanel .accordion-item.ds-current > .accordion-header .accordion-button.collapsed {
    background-color: var(--ds-current-tint);
}

#sidebarPanel [data-owner-row].ds-current:not(.accordion-item) {
    background-color: var(--ds-current-tint);
    box-shadow: 0 0 0 6px var(--ds-current-tint);
    border-radius: 2px;
}

.overlay-backdrop {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background-color: rgba(0, 0, 0, 0.5); /* NEUTRAL */
    z-index: 1040; /* Just below the sidebar */
    display: none; /* Hidden by default */
}


.overlay-backdrop.show {
    display: block;
    opacity: 1;
}


.grid-container {
    display: grid;
    grid-template-columns: repeat(2, 1fr); /* Mobile first: 2 columns */
    gap: 1rem; /* Your exact spacing */
}


.channel-btn {
    position: relative;
    padding-right: 30px; /* Make room for the gear icon */
    text-align: left;
}

.channel-setting-btn {
    position: absolute;
    right: 5px;
    top: 47%;
    transform: translateY(-50%);
    cursor: pointer;
}

.channel-setting-btn i {
    font-size: 0.8em;
}


.sortable-ghost {
    opacity: 0.2;
    background: color-mix(in srgb, var(--accent-color) 41%, transparent);
    border-radius: .5em;
}

.reason-textarea {
    transition: height 0.1s;
    min-height: 32px;
    max-height: 160px;
    padding-top: 0.25rem;
    padding-bottom: 0.25rem;
    font-size: 0.875rem;
    line-height: 1.5;
    /* Add more styling as needed to match input fields */
}

.neg-30-my {
    margin-left: -30px !important;
    margin-right: -30px !important;
}

.small-text {
    font-size: 0.8em;
}

.form-text-small {
    font-size: 0.875em;
}

.actions-invalid {
    border: 2px solid var(--status-danger) !important;
    border-radius: 4px;
}

.red {
    color: var(--status-danger);
}

.green {
    color: var(--status-success);
}

/* Table gear wrapper — gear + sliding actions-menu in table cells */
.table-gear-wrapper {
    position: relative;
    display: inline-block;
}

/* Table context: menu opens to the LEFT of the gear, vertically centered */
.table-gear-wrapper .actions-menu {
    top: 50%;
    right: 100%;
    transform: translateY(-50%);
    margin-right: 0.25rem;
}

/* Table gear toggle — bare icon, matches sibling icons (check-circle, x-circle) */
.table-gear-toggle {
    color: inherit;
    text-decoration: none;
    line-height: 1;
}

.table-gear-toggle:hover {
    color: var(--accent-color);
}

/* Table action dropdown trigger (Bootstrap dropdown version) — transparent gear */
.table-actions-btn {
    padding: 0.15rem 0.4rem;
    line-height: 1;
    background: transparent;
    border: none;
    color: inherit;
}

.table-actions-btn:hover,
.table-actions-btn:focus {
    color: var(--accent-color);
    background: transparent;
}

/* Mobile touch targets (44px minimum per WCAG 2.5.5) */
@media (max-width: 768px) {
    .accordion-header .actions-toggle {
        min-width: 44px;
        min-height: 44px;
        justify-content: center;
    }

    /* Actions menu — mobile: vertical labeled menu (position stays from desktop rule) */
    .actions-menu {
        flex-direction: column;
        align-items: stretch;
        height: auto;
        padding: 0.5rem 0;
        min-width: 10rem;
    }

    .actions-menu .action-label {
        display: inline;
    }

    .actions-menu .dropdown-divider {
        display: block;
    }

    .actions-menu .dropdown-item {
        padding: 0.625rem 1rem;
        min-height: 44px;
    }

    .actions-menu .dropdown-item i {
        font-size: 1rem;
        margin-right: 0.5rem !important;
    }

    /* Table context: menu drops BELOW the gear on mobile, right-aligned */
    .table-gear-wrapper .actions-menu {
        top: 100%;
        right: 0;
        transform: none;
        margin-right: 0;
        margin-top: 0.25rem;
    }

    .table-actions-btn {
        min-width: 44px;
        min-height: 44px;
        display: flex;
        align-items: center;
        justify-content: center;
    }

    .dropdown-menu .dropdown-item {
        padding: 0.625rem 1rem;
        min-height: 44px;
        display: flex;
        align-items: center;
    }
}

/* Media queries */
@media (max-width: 560px) {
    /* No root font rule here (graphs mobile phase 5, insights decision 1):
       phones run the browser default, 16px, exactly as the desktop does -
       the platform body size (Apple HIG 17pt, Material 16sp) and the size
       under which iOS Safari zooms a focused input - and the user's browser
       font preference stays in force. The 12px squeeze that lived here and
       the two page opt-outs (html:has(#insightsHeader / #graphPageHeader))
       are gone; docs/graphs_mobile_ux_plan.md sec 7.5 has the page sweep. */
    .label-length {
        min-width: 100px;
    }

    .modal-custom-size {
        max-width: 100vw;
    }

    .hidden-small {
        display: none;
    }

    .sidebar-panel.open {
        width: 100%;
    }

    .table {
        font-size: 0.85em;
    }
}

/* Medium screens */
@media (min-width: 380px) {
    .grid-container {
        grid-template-columns: repeat(3, 1fr);
    }

    .chart-container {
        height: 350px;
        position: relative;
        background-color: var(--bg-color);
    }

    .sidebar-panel.open {
        width: 100%;
    }
}

/* Medium screens */
@media (min-width: 576px) {
    .grid-container {
        grid-template-columns: repeat(4, 1fr);
    }

    .chart-container {
        height: 400px;
        position: relative;
        background-color: var(--bg-color);
    }
}

/* Larger screens */
@media (min-width: 768px) {
    .grid-container {
        grid-template-columns: repeat(5, 1fr);
    }

    .chart-container {
        height: 450px;
        position: relative;
        background-color: var(--bg-color);
    }

    .sidebar-panel.open {
        width: 100%;
    }
}

/* Desktop screens */
@media (min-width: 992px) {
    .grid-container {
        grid-template-columns: repeat(6, 1fr);
    }

    .chart-container {
        height: 500px;
        position: relative;
        background-color: var(--bg-color);
    }

    .sidebar-panel.open {
        width: 600px;
    }
}

/* Toggle switch for map address updates */
.map-address-toggle {
    transition: all 0.3s ease;
    padding: 8px 12px;
    border-radius: 6px;
    background-color: var(--primary-bg);
    border: 1px solid var(--border-color);
}

.map-address-toggle:hover {
    background-color: var(--alt-bg);
    border-color: var(--accent-color);
}

.map-address-toggle .form-check-input:checked {
    background-color: var(--check-on-bg);
    border-color: var(--check-on-bg);
}

.map-address-toggle .form-check-input:focus {
    box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--accent-color) 25%, transparent);
}

.map-address-toggle .form-check-label {
    cursor: pointer;
    user-select: none;
    transition: color 0.3s ease;
}

.map-address-toggle .form-check-input:checked + .form-check-label {
    color: var(--accent-color);
    font-weight: 500;
}

.map-address-toggle .form-check-input:not(:checked) + .form-check-label {
    color: var(--light-gray-text);
}

/* Subtle animation for toggle state changes */
.map-address-toggle .form-check-input {
    transition: all 0.3s ease;
}

/* Extra large screens */
@media (min-width: 1515px) {
    .grid-container {
        grid-template-columns: repeat(11, 1fr);
    }

    .chart-container {
        height: 500px;
        position: relative;
        background-color: var(--bg-color);
    }
}

/* ========================================
   Skeleton Loading States
   Provides visual feedback during data loading
   ======================================== */

/* Base skeleton animation */
@keyframes skeleton-pulse {
    0% {
        opacity: 0.6;
    }
    50% {
        opacity: 0.3;
    }
    100% {
        opacity: 0.6;
    }
}

/* Skeleton shimmer effect (more visually appealing) */
@keyframes skeleton-shimmer {
    0% {
        background-position: -200% 0;
    }
    100% {
        background-position: 200% 0;
    }
}

/* One gradient for every skeleton surface: the .skeleton blocks here and the
   graphs phone chart-box overlay (styles.css graphs phone clause). Its two
   colours are brand tokens (dark: the app surfaces, light: neutral greys). */
:root {
    --skeleton-gradient: linear-gradient(90deg, var(--skeleton-base) 25%, var(--skeleton-shine) 50%, var(--skeleton-base) 75%);
}

/* Base skeleton element */
.skeleton {
    background: var(--skeleton-gradient);
    background-size: 200% 100%;
    animation: skeleton-shimmer 1.5s ease-in-out infinite;
    border-radius: 4px;
}

/* Skeleton sidebar item */
.skeleton-sidebar-item {
    height: 44px;
    width: 100%;
    margin-bottom: 0.5rem;
}

/* Replicate Mode Toggle - larger to match button height */
.replicate-toggle .replicate-toggle-input {
    width: 2.5em;
    height: 1.25em;
    cursor: pointer;
}

.replicate-toggle .replicate-toggle-input:checked {
    background-color: var(--check-on-bg);
    border-color: var(--check-on-bg);
}

.replicate-toggle .form-check-label {
    cursor: pointer;
    user-select: none;
    font-weight: 500;
    white-space: nowrap;
}

.replicate-toggle .replicate-toggle-input:checked + .form-check-label {
    color: var(--accent-color);
}

/* Floating map overlay (used by site details, controllers, loggers) */
.floating-map {
    position: fixed;
    z-index: 1000;
    background: white; /* NEUTRAL: map container */
    border: 2px solid var(--border-color);
    border-radius: 8px;
    padding: 10px;
    box-shadow: 0 4px 8px var(--shadow-color);
    width: 60vw;
    max-width: 90vw;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
}

.floating-map .map-search-input {
    flex: 1;
    min-width: 150px;
    max-width: 400px;
    margin: 0 0.5rem;
}

/* Google Maps canvas (the div passed to google.maps.Map) - sites, controllers, loggers */
.map-frame {
    height: 60vh;
    width: 100%;
    position: relative;
    overflow: hidden;
}

/* Disabled save button styling */
#saveReportBtn:disabled {
    opacity: 0.6;
    cursor: not-allowed;
}
/* Template Badge Styles - for reports list */
.template-badge {
    background-color: var(--accent-color);
    color: var(--on-accent);
    padding: 2px 8px;
    border-radius: 4px;
    font-size: 0.75rem;
    font-weight: 500;
}

/* Template Mode Indicator - for builder sidebar */
.template-mode-indicator {
    background-color: color-mix(in srgb, var(--accent-color) 10%, transparent);
    border-left: 3px solid var(--accent-color);
    border-radius: 0 4px 4px 0;
}

.template-mode-indicator i {
    color: var(--accent-color);
}

/* =========================================================================
   Virtual Channels
   ========================================================================= */

.vc-expression {
    font-family: 'Courier New', Courier, monospace;
    font-size: 0.8em;
    color: var(--gray);
    white-space: nowrap;
}

.vc-expression-input {
    font-family: 'Courier New', Courier, monospace;
}

/* ── Expression Builder ── */

.vc-add-form {
    border: 1px solid var(--border-color);
}

/* Zone picker (reuses graph picker CSS via .channel-zones etc.) */
.vc-zones-container {
    max-height: 0;
    overflow: hidden;
    transition: max-height 0.3s ease-in-out;
}

/* Selected source pills (colored) */
.vc-selected-pill {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    padding: 3px 6px 3px 5px;
    border-radius: 5px;
    color: #fff;
    font-size: 0.78rem;
    line-height: 1.2;
    white-space: nowrap;
}

/* A source whose channel is no longer selectable on this site (deactivated, or a
   non-numeric group the picker excludes). It is deliberately still shown and still
   posted - dropping it would silently renumber every later {N} in the formula - so
   it needs to look different from a healthy source rather than identical to one. */
.vc-selected-pill-missing {
    opacity: 0.65;
    border-style: dashed !important;
    border-width: 1px;
}

/* Device lines. The device is the thing that disambiguates two channels sharing a display
   name, so it is always present but visually subordinate to the channel name. */
.vc-picker-device {
    display: block;
    font-size: 0.8em;
    line-height: 1.15;
    opacity: 0.7;
}

.vc-pill-device {
    font-size: 0.85em;
    opacity: 0.8;
}

.vc-pill-index {
    font-family: 'Courier New', Courier, monospace;
    font-size: 0.7rem;
    font-weight: 700;
    opacity: 0.85;
}

.vc-pill-remove {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    background: none;
    border: none;
    color: rgba(255, 255, 255, 0.7);
    padding: 0;
    margin-left: 2px;
    cursor: pointer;
    font-size: 0.85rem;
    line-height: 1;
    border-radius: 50%;
    width: 16px;
    height: 16px;
    transition: color 0.15s, background-color 0.15s;
}

.vc-pill-remove:hover {
    color: #fff;
    background-color: rgba(0, 0, 0, 0.25);
}

/* Expression toolbar */
.vc-expr-toolbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 6px;
    min-height: 34px;
}

.vc-expr-chip {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    padding: 3px 8px;
    border-radius: 5px;
    border: 1px solid;
    background: transparent;
    font-size: 0.78rem;
    font-weight: 500;
    cursor: pointer;
    transition: background-color 0.15s, opacity 0.15s;
    white-space: nowrap;
}

.vc-expr-chip:hover {
    opacity: 0.8;
}

.vc-expr-chip:active {
    transform: scale(0.96);
}

.vc-expr-chip-badge {
    font-family: 'Courier New', Courier, monospace;
    font-size: 0.7rem;
    font-weight: 700;
    padding: 1px 4px;
    border-radius: 3px;
    color: #fff;
}

.vc-expr-separator {
    width: 1px;
    height: 20px;
    background-color: var(--border-color);
    margin: 0 2px;
}

.vc-expr-op {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 30px;
    height: 28px;
    border-radius: 5px;
    border: 1px solid var(--border-color);
    background: var(--primary-bg);
    color: var(--text-color);
    font-size: 0.85rem;
    font-weight: 600;
    cursor: pointer;
    transition: background-color 0.15s, border-color 0.15s;
}

/* A neutral hover wash: only the surface changes. */
.vc-expr-op:hover {
    background: var(--hl-bg);
    border-color: var(--gray);
}

.vc-expr-op:active {
    transform: scale(0.94);
}

/* Visual formula preview */
.vc-formula-preview {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 4px;
    padding: 6px 10px;
    border-radius: 6px;
    background: var(--primary-bg);
    border: 1px solid var(--border-color);
    min-height: 34px;
    font-size: 0.82rem;
}

.vc-formula-equals {
    color: var(--gray);
    font-weight: 600;
    margin-right: 2px;
}

.vc-formula-tag {
    display: inline-block;
    padding: 2px 7px;
    border-radius: 4px;
    color: #fff;
    font-size: 0.76rem;
    font-weight: 500;
    white-space: nowrap;
}

.vc-formula-tag-unknown {
    background-color: var(--status-warning) !important;
    color: #000;
}

.vc-formula-op {
    font-family: 'Courier New', Courier, monospace;
    font-weight: 600;
    color: var(--text-color);
    white-space: pre;
}

.vc-formula-warn {
    color: var(--status-warning);
    font-size: 0.85rem;
}

.vc-formula-error {
    color: var(--status-danger);
    font-size: 0.8rem;
    font-weight: 500;
}

/* ── Task Monitor (bottom-right notification area) ── */
.ds-task-monitor {
    position: fixed;
    bottom: 1rem;
    right: 1rem;
    z-index: 9998;
    max-width: 380px;
    min-width: 300px;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

.ds-task-card {
    background: var(--primary-bg);
    border: 1px solid var(--border-color);
    border-radius: 8px;
    padding: 0.75rem 1rem;
    box-shadow: 0 4px 12px var(--shadow-color);
    transition: opacity 0.5s ease, transform 0.5s ease;
    color: var(--text-color);
    font-family: var(--font-family);
}

.ds-task-card.ds-task-running {
    border-left: 3px solid var(--accent-color);
}

.ds-task-card.ds-task-completed {
    border-left: 3px solid var(--status-success);
}

.ds-task-card.ds-task-failed {
    border-left: 3px solid var(--status-danger);
}

.ds-task-card.ds-unsaved-warning {
    border-left: 3px solid var(--status-warning);
    padding: 0.5rem 0.625rem;
}

.ds-task-phases {
    margin-top: 0.375rem;
    font-size: 0.7rem;
}

.ds-task-phase {
    display: flex;
    align-items: center;
    gap: 0.375rem;
    padding: 0.125rem 0;
    opacity: 0.8;
}

.ds-task-phase-bar {
    height: 3px;
    background: var(--border-color);
    border-radius: 2px;
    margin-top: 0.125rem;
    overflow: hidden;
}

.ds-task-phase-bar > div {
    height: 100%;
    background: var(--accent-color);
    border-radius: 2px;
    transition: width 0.3s ease;
}

/* Collapsed per-item failure list under a phase row (DROP recompute reports) */
.ds-task-phase-failures {
    margin: 0.125rem 0 0.125rem 1.125rem;
}

.ds-task-phase-failures > summary {
    cursor: pointer;
    color: var(--status-warning);
    font-size: 0.7rem;
}

.ds-task-phase-failures > ul {
    margin: 0.125rem 0 0;
    padding-left: 1rem;
    max-height: 8rem;
    overflow-y: auto;
    font-size: 0.7rem;
    opacity: 0.85;
}

.ds-task-actions {
    display: flex;
    gap: 0.5rem;
    margin-top: 0.375rem;
}

/* Rendered as <a> (plain URL actions) or <button> (FileViewer actions) —
   the button resets below keep both elements visually identical. */
.ds-task-action {
    font-size: 0.75rem;
    font-weight: 500;
    font-family: inherit;
    line-height: inherit;
    padding: 0.125rem 0.5rem;
    border-radius: 4px;
    text-decoration: none;
    background: none;
    cursor: pointer;
    color: var(--accent-color);
    border: 1px solid var(--accent-color);
    transition: background 0.15s, color 0.15s;
}

.ds-task-action:hover {
    background: var(--accent-color);
    color: var(--on-accent);
}

/* ── Unsaved Changes Notifier ── */
.ds-unsaved-layout {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.ds-unsaved-icon {
    flex-shrink: 0;
    display: flex;
    align-items: center;
    color: var(--status-warning);
    font-size: 1rem;
}

.ds-unsaved-body {
    flex: 1;
    min-width: 0;
}

.ds-unsaved-title {
    font-weight: 500;
    font-size: 0.875rem;
}

.ds-unsaved-sites {
    font-size: 0.75rem;
    opacity: 0.7;
}

.ds-unsaved-current-site {
    font-weight: 600;
    color: var(--accent-color);
}

.ds-unsaved-site-link {
    color: var(--gray);
    text-decoration: underline;
    cursor: pointer;
}

.ds-unsaved-site-link:hover {
    color: var(--accent-color);
}

.ds-unsaved-actions {
    display: flex;
    gap: 0.25rem;
    flex-shrink: 0;
}

.ds-unsaved-actions .btn {
    padding: 0.25rem 0.5rem;
    font-size: 0.875rem;
}

/* =========================================================================
   DROP Channel Configuration
   ========================================================================= */

.drop-section {
    background-color: var(--bg-color);
    border: 1px solid var(--border-color);
    border-radius: 8px;
    padding: 1rem;
    margin-bottom: 1rem;
}

.drop-slot {
    transition: all 0.2s;
}

.drop-slot-active {
    border-style: dashed !important;
    cursor: pointer;
}

.drop-slot-dragover {
    border-color: var(--accent-color) !important;
    background: color-mix(in srgb, var(--accent-color) 5%, transparent);
}

.drop-slot-selected {
    border-color: var(--accent-color) !important;
    box-shadow: 0 0 0 1px var(--accent-color);
}

.drop-channel-picker-container {
    background-color: var(--alt-bg);
    border-radius: 6px;
    padding: 0.75rem;
}

.drop-channel-picker {
    max-height: 300px;
    overflow-y: auto;
}

/* Override grab cursor for draggable picker buttons in DROP */
.drop-picker-btn[draggable="true"] {
    cursor: grab;
}

.drop-picker-btn[draggable="true"]:active {
    cursor: grabbing;
}

/* Fix dropdown menu clipping in DROP reports tables (dashboard, site modal, owner modal) */
#dropReportsTable .dropdown {
    position: static;
}

#dropReportsTable .dropdown-menu {
    position: absolute;
    z-index: 1050;
}

.table-responsive:has(#dropReportsTable) {
    overflow: visible;
}

/* ============================================================
   Shared file uploader / dropzone (DeltasmartUploader)
   ------------------------------------------------------------
   One unified drag-and-drop upload look used everywhere uploads
   are possible: site documents, controller/logger files,
   branding logos, and the report image library. Markup lives in
   templates/shared/partials/file_dropzone.html; behaviour lives
   in static/js/components/file_uploader.js. Uses the global
   theme variables so it renders correctly in light/dark.
   ============================================================ */
.ds-uploader {
    width: 100%;
}

.ds-dropzone {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 0.35rem;
    width: 100%;
    margin: 0;
    padding: 1.1rem 1rem;
    text-align: center;
    cursor: pointer;
    border: 2px dashed var(--border-color);
    border-radius: 8px;
    background-color: color-mix(in srgb, var(--accent-color) 3%, transparent);
    transition: border-color 0.15s ease, background-color 0.15s ease;
}

.ds-dropzone:hover {
    border-color: var(--accent-color);
}

/* Highlight while a file is dragged over the zone. */
.ds-dropzone.ds-dropzone--dragover {
    border-color: var(--accent-color);
    background-color: color-mix(in srgb, var(--accent-color) 12%, transparent);
}

/* The real <input type=file> is visually hidden but stays focusable and
   click-activatable via its wrapping <label> (native browse, no JS needed). */
.ds-dropzone__input {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

.ds-dropzone__icon {
    font-size: 1.5rem;
    line-height: 1;
    color: var(--accent-color);
}

.ds-dropzone__cta {
    font-size: 0.9rem;
    color: var(--text-color);
}

.ds-dropzone__browse {
    color: var(--accent-color);
    text-decoration: underline;
}

.ds-dropzone__filename {
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-color);
    word-break: break-all;
    max-width: 100%;
}

.ds-dropzone__hint {
    font-size: 0.75rem;
    color: var(--gray);
}

.ds-uploader__progress {
    margin-top: 0.5rem;
}

.ds-uploader__progress .progress {
    height: 0.5rem;
}

.ds-uploader__status {
    display: block;
    margin-top: 0.25rem;
    font-size: 0.75rem;
    color: var(--gray);
}

.ds-uploader__error {
    font-size: 0.8rem;
}

/* Lock the zone while an upload is in flight. */
.ds-uploader.is-uploading .ds-dropzone {
    pointer-events: none;
    opacity: 0.65;
}

/* ── Chips (.ds-chip) ────────────────────────────────────────────────────────
   ONE pill-chip vocabulary with TWO consumers, which is why it is its own
   section rather than part of either: the recipient picker's facet row
   (templates/shared/macros/recipient_picker.html) and the Site Settings list
   filter bars (templates/shared/macros/list_filter_bar.html). Named .rp-chip
   until phase 5 of the flat-list initiative - it stopped being the picker's
   the moment a second surface used it.

   The CLASSES are shared; the STATE HOOKS are deliberately not. A selected
   chip is data-rp-on inside the picker and data-ds-on outside it, and both
   selectors must survive: recipient_picker.js binds a DOCUMENT-level click
   listener acting on data-rp-* hooks wherever it finds them, so a filter-bar
   chip wearing one would be driven by the picker as well as by its own
   engine. .ds-chip-label / .ds-chip-count are this block's children, defined
   up with the filter bar because that is where a counted chip is used. */
.ds-chips {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 6px;
    margin-top: 9px;
}

/* A chipset is one facet: its chips sit flush as a segmented control, so two
   facets read as two questions rather than one row of eight equal options.
   A chipset wider than its row (the Devices page's five Status chips on a
   phone) scrolls sideways rather than clipping its last chip, and a chip never
   breaks its label across two lines ("All / types"). The scrollbar is hidden:
   the half-shown chip at the edge is the cue. */
.ds-chipset {
    display: inline-flex;
    max-width: 100%;
    border: 1px solid var(--chip-border-color);
    border-radius: 999px;
    overflow-x: auto;
    overflow-y: hidden;
    scrollbar-width: none;
}

.ds-chipset::-webkit-scrollbar {
    display: none;
}

.ds-chipset .ds-chip {
    flex-shrink: 0;
    white-space: nowrap;
    border: 0;
    border-radius: 0;
    border-right: 1px solid var(--chip-divider-color);
}

.ds-chipset .ds-chip:last-child {
    border-right: 0;
}

.ds-chip {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    padding: 3px 11px;
    border: 1px solid var(--chip-border-color);
    border-radius: 999px;
    background: transparent;
    color: var(--text-color);
    font-size: 0.78rem;
    font-weight: 500;
    line-height: 1.5;
    letter-spacing: 0.02em;
    opacity: 0.72;
    transition: background-color 0.12s ease, opacity 0.12s ease, color 0.12s ease;
}

.ds-chip:hover {
    opacity: 1;
    background: var(--chip-hover-bg);
}

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

/* data-ds-on is the same selected state for chips OUTSIDE the recipient picker
   (the Site Settings list filter bars). They cannot wear data-rp-on: the
   picker's document-level click listener acts on data-rp-* hooks wherever it
   finds them. */
.ds-chip[data-rp-on],
.ds-chip[data-ds-on] {
    background: var(--chip-on-bg);
    border-color: var(--chip-on-bg);
    color: var(--chip-on-color);
    opacity: 1;
}

.ds-chip[data-rp-on]:hover,
.ds-chip[data-ds-on]:hover {
    background: var(--chip-on-bg);
}

.ds-chip[data-ds-on] .ds-chip-count {
    opacity: 0.85;
}

.ds-chip .bi {
    font-size: 0.78rem;
}

/* Clear is an escape hatch, not an option: it only appears while something is
   actually filtering, and never carries the accent. */
.ds-chip--clear {
    margin-left: auto;
    opacity: 0.6;
}

/* ── Recipient picker (.rp) ──────────────────────────────────────────────────
   The shared "choose who gets this" surface: site alerts, data-report and DROP
   report recipients, insights snapshot recipients, insights share.
   Markup: templates/shared/macros/recipient_picker.html.
   Behaviour: static/js/components/recipient_picker.js.

   Lives here rather than in a feature stylesheet because the component appears
   on all three page shells (base.html, the standalone graphs page and the
   standalone insights page), and head_core.html gives every one of them
   styles.css. Colours are tokens only, so light-mode needs no overrides.

   Shape borrows the insights channel picker, this app's newest browse UI:
   pinned toolbar, scrolling body, pinned footer, pill chips that fill with the
   accent when active. */
.rp {
    border: 1px solid color-mix(in srgb, var(--text-color) 14%, transparent);
    border-radius: 10px;
    overflow: hidden;
    background: color-mix(in srgb, var(--text-color) 2%, transparent);
}

.rp-bar {
    padding: 10px 12px;
    border-bottom: 1px solid color-mix(in srgb, var(--text-color) 10%, transparent);
}

/* Search icon sits INSIDE the field (no input-group seams). */
.rp-search {
    position: relative;
}

.rp-search .bi-search {
    position: absolute;
    left: 10px;
    top: 50%;
    transform: translateY(-50%);
    font-size: 12px;
    opacity: 0.5;
    pointer-events: none;
}

.rp-search .form-control {
    padding-left: 30px;
    border: 1px solid color-mix(in srgb, var(--text-color) 14%, transparent);
    border-radius: 7px;
}

/* The Organisation control. A native <select>, dressed as a .ds-chip so the bar
   reads as one row of controls -- same height, radius, ink and accent-when-set.
   It is a select and not the .ds-menu-* popout used elsewhere because .rp is
   overflow:hidden and would clip anything opening out of this bar; the browser
   draws a select's own popup, so nothing can. */
/* No margin of its own: it is a flex child of .ds-chips, whose gap already
   spaces the row. Positioned because it draws its own caret. */
.rp-org {
    position: relative;
    display: inline-flex;
    color: var(--text-color);
}

/* Bootstrap's caret is a background SVG with a hard-coded near-black fill, which
   is all but invisible on the dark theme and wrong again on the accent fill. So
   it is switched off and redrawn here as a border chevron in currentColor:
   inherited from .rp-org, it follows the theme AND the set state for free, and
   costs neither a data URI per colour nor the icon font. */
.rp-org .form-select {
    max-width: 22ch;
    padding: 3px 26px 3px 11px;
    border: 1px solid var(--chip-border-color);
    border-radius: 999px;
    background-color: transparent;
    background-image: none;
    color: inherit;
    font-size: 0.78rem;
    line-height: 1.5;
    letter-spacing: 0.02em;
    opacity: 0.72;
}

.rp-org::after {
    content: '';
    position: absolute;
    right: 11px;
    top: 50%;
    width: 5px;
    height: 5px;
    border-right: 1.5px solid currentColor;
    border-bottom: 1.5px solid currentColor;
    transform: translateY(-70%) rotate(45deg);
    opacity: 0.7;
    pointer-events: none;
}

.rp-org .form-select:hover {
    opacity: 1;
    background-color: var(--chip-hover-bg);
}

.rp-org .form-select:focus {
    box-shadow: none;
    opacity: 1;
}

.rp-org .form-select:focus-visible {
    outline: 2px solid var(--accent-color);
    outline-offset: 1px;
}

/* Set filters carry the accent here exactly as a pressed chip does -- which
   matters more for this one than for the chips, because it is the only control
   that can be filtering before anybody has touched it. */
.rp-org--set {
    color: var(--chip-on-color);
}

.rp-org--set .form-select {
    background-color: var(--chip-on-bg);
    border-color: var(--chip-on-bg);
    opacity: 1;
}

/* The options are drawn by the browser, which does NOT inherit the pill's ink;
   without this the list is white-on-white on a dark theme. */
.rp-org .form-select option {
    background-color: var(--bg-color);
    color: var(--text-color);
}

.rp-list {
    overflow: auto;
}

/* The caller owns the rows, so neutralise the outer margins its table or
   form-check list brings with it. */
.rp-list > .table,
.rp-list > table {
    margin-bottom: 0;
}

/* A recipient table is mostly short values (a name, a role, a number) beside
   narrow switch columns. Left to itself the browser squeezes the text columns
   to fit and every cell wraps to two or three lines; the list scrolls sideways
   instead, which is what .rp-list's overflow is for. */
.rp-list > .table th,
.rp-list > .table td {
    white-space: nowrap;
    vertical-align: middle;
}

/* The switch columns carry no text, so they should claim no width. */
.rp-list > .table td:has(.form-switch),
.rp-list > .table td:has(.form-check-input) {
    width: 1%;
}

.rp-list > .table thead th {
    position: sticky;
    top: 0;
    z-index: 1;
    background: var(--primary-bg);
    font-size: 0.7rem;
    letter-spacing: 0.06em;
}

.rp-list .form-check {
    padding: 3px 12px 3px 34px;
    margin-bottom: 0;
}

.rp-list .form-check:hover {
    background: color-mix(in srgb, var(--text-color) 5%, transparent);
}

.rp-list .form-check .form-check-input {
    margin-left: -22px;
}

.rp-empty {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 7px;
    padding: 26px 12px;
    margin: 0;
    color: var(--gray);
    font-size: 0.82rem;
    text-align: center;
}

.rp-empty .bi {
    font-size: 1.15rem;
    opacity: 0.45;
}

.rp-foot {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 6px 10px;
    padding: 8px 12px;
    border-top: 1px solid color-mix(in srgb, var(--text-color) 10%, transparent);
    background: color-mix(in srgb, var(--text-color) 3%, transparent);
}

.rp-status {
    font-size: 0.76rem;
    color: var(--gray);
    letter-spacing: 0.02em;
}

/* The count of people actually picked - the one number worth emphasising. */
.rp-status strong {
    color: var(--text-color);
    font-weight: 600;
}

.rp-actions {
    display: inline-flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 4px;
    margin-left: auto;
}

.rp-actions-label {
    font-size: 0.76rem;
    color: var(--gray);
    margin-right: 1px;
}

.rp-action {
    border: 0;
    background: transparent;
    color: var(--accent-color);
    font-size: 0.78rem;
    font-weight: 600;
    padding: 2px 7px;
    border-radius: 6px;
    letter-spacing: 0.02em;
}

.rp-action:hover {
    background: color-mix(in srgb, var(--accent-color) 15%, transparent);
}

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

.rp-action--muted {
    color: var(--gray);
}

.rp-action--muted:hover {
    background: color-mix(in srgb, var(--text-color) 10%, transparent);
    color: var(--text-color);
}

/* Role reads as a person's standing, not a database value. */
.rp-role {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    padding: 1px 8px;
    border-radius: 999px;
    border: 1px solid color-mix(in srgb, var(--text-color) 18%, transparent);
    font-size: 0.72rem;
    letter-spacing: 0.02em;
    color: var(--gray);
    white-space: nowrap;
    text-transform: capitalize;
}

/* Admins carry the accent at low strength: visible when scanning, never loud. */
.rp-role--admin {
    border-color: color-mix(in srgb, var(--accent-color) 45%, transparent);
    background: color-mix(in srgb, var(--accent-color) 12%, transparent);
    color: var(--text-color);
}

/* Editors sit between admin and viewer: the same accent, half the strength, so
   the three tiers stay distinguishable at a glance without a second hue. */
.rp-role--editor {
    border-color: color-mix(in srgb, var(--accent-color) 25%, transparent);
    background: color-mix(in srgb, var(--accent-color) 6%, transparent);
    color: var(--text-color);
}

.rp-role-sep {
    opacity: 0.45;
}

/* "Admin only" marker (shared/macros/visibility.html admin_only_badge;
   docs/admin_only_visibility_plan.md D12). Worn by graph cards, insights
   dashboards and tabs that only the admin roles can see. Same accent tint as
   .rp-role--admin above so "admin" reads the same everywhere; theme tokens
   only, so it follows every brand in light and dark. */
.ds-admin-only {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    padding: 1px 8px;
    border-radius: 999px;
    border: 1px solid color-mix(in srgb, var(--accent-color) 45%, transparent);
    background: color-mix(in srgb, var(--accent-color) 12%, transparent);
    color: var(--text-color);
    font-size: 0.72rem;
    font-weight: 500;
    letter-spacing: 0.02em;
    line-height: 1.4;
    white-space: nowrap;
    vertical-align: middle;
}

.ds-admin-only .bi {
    font-size: 0.9em;
    line-height: 1;
}

/* The glyph alone, for tight spots (tab buttons, dropdown items, sidebar rows):
   no pill chrome, the accent carries the meaning, the aria-label the words. */
.ds-admin-only--compact {
    padding: 0;
    border: 0;
    background: none;
    color: var(--accent-color);
    font-size: 0.85em;
}

/* A group heading inside the list (the share panel's two standing groups).
   Sticky, so scrolling a long list never loses which group you are looking at. */
.rp-group {
    position: sticky;
    top: 0;
    z-index: 2;
    padding: 7px 12px 6px;
    background: var(--primary-bg);
    border-bottom: 1px solid color-mix(in srgb, var(--text-color) 8%, transparent);
    font-size: 0.76rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--text-color);
}

.rp-group-note {
    display: block;
    margin-top: 2px;
    font-size: 0.72rem;
    font-weight: 400;
    letter-spacing: 0.02em;
    text-transform: none;
    color: var(--gray);
}

.rp-group-count {
    color: var(--gray);
    font-weight: 500;
}

/* A section heading INSIDE a picker table - the alert pickers' "Recipient
   groups" / "Individuals" rows - scrolls with its rows: the column headers
   own the sticky top edge there (.rp-list > .table thead th above) and a
   second sticky band would paint over them. Its note wraps, or the colspan
   cell would widen the whole table (the picker's cells are nowrap by design). */
.rp-list > .table th.rp-group {
    position: static;
    white-space: normal;
}

/* The two-line roster row (insights recipients + share): name over email kills
   the dead middle column the old table grid left between name, email and role,
   and the switch reads against the person instead of floating at the far edge.
   Rows stay surface-owned markup; these classes are the shared grammar. */
.rp-row {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 7px 12px;
    border-bottom: 1px solid color-mix(in srgb, var(--text-color) 7%, transparent);
}

.rp-row:last-child {
    border-bottom: 0;
}

.rp-row:hover {
    background: color-mix(in srgb, var(--text-color) 5%, transparent);
}

.rp-row-who {
    flex: 1;
    min-width: 0;
    margin: 0;
}

/* On the share panel the who-block is the switch's <label>. */
label.rp-row-who {
    cursor: pointer;
}

.rp-row-name {
    font-weight: 600;
    font-size: 0.84rem;
    line-height: 1.35;
}

.rp-row-email {
    color: var(--gray);
    font-size: 0.74rem;
    line-height: 1.35;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Neutralise the .rp-list .form-check padding/hover above: inside an rp-row
   the row owns spacing and hover, the switch container is just a switch. */
.rp-row .form-check {
    padding: 0 0 0 2.5em;
    margin: 0;
    min-height: 0;
}

.rp-row .form-check:hover {
    background: transparent;
}

.rp-row .form-check .form-check-input {
    margin-left: -2.5em;
}

/* Scope rides as a pill on the row's right, so "what will this person get"
   scans in a column instead of hiding under ragged names. */
.rp-scope {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    padding: 1px 8px;
    border-radius: 999px;
    border: 1px solid color-mix(in srgb, var(--text-color) 14%, transparent);
    font-size: 0.72rem;
    letter-spacing: 0.02em;
    color: var(--gray);
    white-space: nowrap;
}

/* Skipped-at-fire-time is the one state worth colour: it is the silent
   failure the scope column exists to surface. */
.rp-scope--warn {
    border-color: color-mix(in srgb, #e8b33a 55%, transparent);
    background: color-mix(in srgb, #e8b33a 14%, transparent);
    color: var(--text-color);
}

.rp-scope--warn .bi {
    font-size: 0.72rem;
}

/* "N receiving" beside a panel heading - the summary the removed
   current-recipients table used to be a whole second list for. */
.rp-count {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    padding: 1px 9px;
    border-radius: 999px;
    border: 1px solid color-mix(in srgb, var(--text-color) 14%, transparent);
    background: color-mix(in srgb, var(--text-color) 3%, transparent);
    font-size: 0.74rem;
    font-weight: 400;
    color: var(--gray);
    white-space: nowrap;
}

.rp-count strong {
    color: var(--text-color);
    font-weight: 600;
}

/* Where the contact columns end and the three notify switches begin. */
.rp-list > .table .rp-col-notify {
    border-left: 1px solid color-mix(in srgb, var(--text-color) 10%, transparent);
}

/* Email addresses are the one long value in these tables. Truncating them
   keeps the row on one line and the table inside its frame; the full address
   stays available on hover and is rarely what you are scanning for anyway. */
.rp-truncate {
    max-width: 230px;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* Cells are nowrap so the columns stay one line, but a block inside a cell is
   a secondary line by intention - the insights scope note ("No overlapping
   sites - will be skipped") - and must be free to wrap. */
.rp-list > .table td > div {
    white-space: normal;
}

/* ── R5: missing-channel badge (audit 7.6) ────────────────────────────────
   "This widget points at a channel that no longer resolves." Rendered by
   shared/missing_channel_badge.js, which is the single renderer for BOTH the
   insights canvas and the report builder - so the rule lives in the shared
   stylesheet rather than in insights_widgets.css. It started there and was
   invisible in the report builder, which loads reports.css and never sees the
   insights sheet: one renderer with its CSS in a page-specific file is still
   two looks for one fact.

   Sits at the header's far end, after the title/pencil, so it never competes
   with the title for the ellipsis. flex:none because the label is short and
   must not truncate - a half-shown warning is worse than none.

   NOT hidden by a mode rule the way the insights gear/trash chips are: the
   badge is only ever PUT IN THE DOM on an edit surface, because both channel
   catalogues are viewer-scoped and Phase 11 settled that a narrowed viewer
   gets no indicator - a CSS-hidden badge would still answer that in devtools.

   The data-quality badges wear the same rule - a DROP total's "n sites
   not reporting", a metric's "n channels have no data" and a DROP Cost of
   Flow widget's "Meter 86% of DROP" - and show to every viewer: they count
   only channels the viewer can resolve.

   Fixed amber rather than a theme token: both widget canvases paint their
   cards light in either theme, so a literal reads the same everywhere the
   badge can appear. */
.widget-missing-badge,
.widget-partial-badge,
.widget-nodata-badge,
.widget-coverage-badge,
.widget-gap-badge {
    display: inline-flex;
    align-items: center;
    gap: 0.3em;
    flex: none;
    padding: 0.15em 0.5em;
    border: 1px solid #f5c518;
    border-radius: 999px;
    background: #fef8e3;
    color: #7a5b00;
    font-size: 0.72em;
    font-weight: 600;
    line-height: 1.5;
    white-space: nowrap;
    cursor: help;
}
.widget-missing-badge svg,
.widget-missing-badge i,
.widget-partial-badge svg,
.widget-partial-badge i,
.widget-nodata-badge svg,
.widget-nodata-badge i,
.widget-coverage-badge svg,
.widget-coverage-badge i,
.widget-gap-badge svg,
.widget-gap-badge i {
    flex: none;
}

/* ===========================================================================
   PHONE SHELL shared by the two standalone dashboards (graphs + insights)
   docs/insights_mobile_ux_plan.md sec 4 / decision 4: the app-nav sheet, the
   44px header targets and the sidebar phone grade are the pieces the graphs
   mobile project starts from, so they live here, not in insights.css.
   =========================================================================== */

/* ---- App-nav SHEET (shared/partials/dashboard_header_mobile_nav.html) ----
   A right-side offcanvas: theme tokens on Bootstrap's own variables, labelled
   48px rows in headed sections, safe-area padding. Only rendered below md
   (d-md-none), so nothing here needs a media query. ---- */
.ds-nav-sheet {
    --bs-offcanvas-width: min(320px, 86vw);
    --bs-offcanvas-bg: var(--primary-bg);
    --bs-offcanvas-color: var(--text-color);
    --bs-offcanvas-border-color: var(--border-color);
    padding-top: env(safe-area-inset-top, 0px);
    padding-bottom: env(safe-area-inset-bottom, 0px);
}

.ds-nav-sheet .offcanvas-header {
    padding: 10px 8px 10px 16px;
    border-bottom: 1px solid var(--border-color);
}

.ds-nav-sheet .offcanvas-title {
    font-size: 16px;
    font-weight: 600;
}

/* 44px close target; the X itself is the masked currentColor glyph the modal
   header uses (rule above), so it follows the theme ink. The sidebar panel's
   header (.sidebar-head) wears the same close. */
.ds-nav-sheet .btn-close,
.sidebar-head .btn-close {
    width: 44px;
    height: 44px;
    padding: 0;
    margin: -8px 0 -8px auto;
    color: var(--text-color);
    opacity: 0.8;
}

.ds-nav-sheet .offcanvas-body {
    padding: 4px 0 16px;
}

.ds-sheet,
.ds-sheet-list {
    list-style: none;
    margin: 0;
    padding: 0;
}

.ds-sheet-section + .ds-sheet-section {
    margin-top: 6px;
    padding-top: 6px;
    border-top: 1px solid var(--border-color);
}

.ds-sheet-heading {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px 16px 2px;
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--gray);
}

.ds-sheet-heading .bi {
    font-size: 13px;
}

/* One row = one tap: 48px tall, 16px label, a fixed-width leading icon so the
   labels align. Applies to <a> rows and the <label id="theme-toggle"> row alike. */
.ds-sheet-row {
    display: flex;
    align-items: center;
    gap: 14px;
    min-height: 48px;
    margin: 0;
    padding: 0 16px;
    font-size: 16px;
    line-height: 1.25;
    color: var(--menu-item-color);
    text-decoration: none;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}

.ds-sheet-row .bi {
    flex: 0 0 22px;
    font-size: 18px;
    text-align: center;
    opacity: 0.85;
}

.ds-sheet-row:hover,
.ds-sheet-row:active,
.ds-sheet-row:focus-visible {
    color: var(--menu-item-color);
    background-color: var(--menu-item-hover-bg);
}

.ds-sheet-row:hover .bi,
.ds-sheet-row:active .bi {
    color: var(--menu-item-hover-color);
    opacity: 1;
}

/* Install row (install_hint.js) and the other sheet actions: a <button>
   dressed as a row, so the browser's button chrome comes off. Focus keeps the
   resting row: .ds-sheet-row:focus-visible's wash stays on the link rows. */
.ds-nav-sheet .ds-sheet-row--button,
.ds-nav-sheet .ds-sheet-row--button:focus {
    width: 100%;
    border: 0;
    border-radius: 0;
    background: none;
    text-align: left;
}
.ds-nav-sheet .ds-sheet-row--button:hover,
.ds-nav-sheet .ds-sheet-row--button:active {
    background-color: var(--menu-item-hover-bg);
}
/* iOS instruction row: information, not a tap target - two lines, no hover. */
.ds-nav-sheet .ds-sheet-row--note,
.ds-nav-sheet .ds-sheet-row--note:hover {
    min-height: 56px;
    padding-top: 8px;
    padding-bottom: 8px;
    cursor: default;
    background: none;
}
.ds-sheet-row--note span {
    display: flex;
    flex-direction: column;
    gap: 2px;
}
.ds-sheet-row--note small {
    font-size: 12px;
    line-height: 1.3;
    color: var(--gray);
}

/* ---- BOTTOM sheets (graphs dashboard: #channelSheet, #channelPickerSheet in
   templates/dashboard/graphs.html; docs/graphs_mobile_ux_plan.md 5.1 / 5.2) ----
   The bottom variant of the sheet grammar above: content-sized, rounded top
   with a grab handle, a tightened header with a two-line title, a scrolling
   body. Unconditional like the nav sheet: the markup is only ever shown on
   phones (JS-gated), and every rule is scoped under the sheet class, so a DOM
   node a sheet hosts keeps its desktop styling once it is back home. ---- */
.offcanvas.ds-bottom-sheet {
    /* .offcanvas.offcanvas-bottom in Bootstrap 5.3 is (0,2,0) and owns
       height / max-height / border-top, so the sheet class is qualified. */
    --bs-offcanvas-height: auto;
    max-height: 88svh;
    padding-top: 0;
    border-top: 0;
    border-top-left-radius: 16px;
    border-top-right-radius: 16px;
    box-shadow: 0 -8px 30px var(--shadow-color);
}

.ds-bottom-sheet .ds-sheet-grab {
    flex: none;
    width: 36px;
    height: 4px;
    margin: 8px auto 0;
    border-radius: 2px;
    background-color: var(--border-color);
}

.ds-bottom-sheet .offcanvas-header {
    padding-top: 4px;
    padding-bottom: 8px;
}

.ds-bottom-sheet .offcanvas-title {
    min-width: 0;
}

.ds-bottom-sheet .offcanvas-title span,
.ds-bottom-sheet .offcanvas-title small {
    display: block;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.ds-bottom-sheet .offcanvas-title small {
    font-size: 12px;
    font-weight: 400;
    color: var(--gray);
}

.ds-bottom-sheet .offcanvas-body {
    padding: 4px 0 16px;
    overscroll-behavior: contain;
}

/* Two-line rows for the bottom sheets (the channel sheet's tab rows, the jump
   sheet's cards): a label with a caption under it and a trailing chevron. */
.ds-bottom-sheet .ds-sheet-row {
    padding-top: 6px;
    padding-bottom: 6px;
}

.ds-bottom-sheet .ds-sheet-grow {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 2px;
}

.ds-bottom-sheet .ds-sheet-grow small {
    font-size: 12px;
    line-height: 1.3;
    color: var(--gray);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.ds-bottom-sheet .ds-sheet-chevron {
    flex: 0 0 auto;
    font-size: 14px;
}

/* ---- Channel SHEET (#channelSheet, graphs/channel_sheet.js; plan 5.2): hosts
   the existing #channel-settings-form while open. The two-line rows above, a
   danger row, and the form graded for a thumb. Sizes from the prototype: 44px
   controls at 16px (the iOS zoom threshold), 36px swatches, a 48px switch
   row, a 44px Save. ---- */
.ds-channel-sheet .ds-sheet-row--danger,
.ds-channel-sheet .ds-sheet-row--danger:hover,
.ds-channel-sheet .ds-sheet-row--danger .bi {
    color: var(--bs-danger);
}

/* ---- Card JUMP SHEET (#cardJumpSheet, graphs/jump_sheet.js; plan 5.6,
   decision 11): the card index behind the header's Cards button, on the
   two-line rows above - the card title on one line, its channels / range /
   collapsed caption under it. ---- */
.ds-jump-sheet .ds-sheet-grow > span {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* ---- Channel PICKER SHEET (#channelPickerSheet, graphs/picker_sheet.js;
   plan 5.1, decision 8): FULL height (the prototype), hosting a card's picker
   tree - the .zones-container channel_display.js displayChannels builds -
   while open. The tree is the desktop's inline picker, restyled here into
   48px zone rows, 12px sub-zone captions and 44px channel rows with a colour
   dot before the name and a check after it (data_fetcher.js updateButtonColors
   stamps --ds-chip-color + .is-selected next to its inline fill). The
   builder's inline max-heights are its accordion: the outer clip (the header
   trigger's 0 / 800px) is lifted outright, a zone's 400px clip while it is
   open, and every clip while a search term is typed. ---- */
.offcanvas.ds-picker-sheet {
    --bs-offcanvas-height: 100svh;
    height: 100svh;
    max-height: 100svh;
    padding-top: env(safe-area-inset-top, 0px);
    border-radius: 0;
}

/* The search row sits between the header and the scrolling body. */
.ds-picker-sheet .ds-picker-search {
    position: relative;
    flex: none;
    display: flex;
    align-items: center;
    padding: 4px 16px 10px;
    border-bottom: 1px solid var(--border-color);
}

.ds-picker-sheet .ds-picker-search .bi-search {
    position: absolute;
    left: 30px;
    font-size: 16px;
    color: var(--gray);
    pointer-events: none;
}

/* 16px input: below that iOS Safari zooms the page on focus. */
.ds-picker-sheet .ds-picker-search .form-control {
    height: 44px;
    padding: 0 44px 0 42px;
    font-size: 16px;
    border-radius: 10px;
}

/* The clear target is ours (44px); the input's native cancel glyph would
   double it. */
.ds-picker-sheet .ds-picker-search .form-control::-webkit-search-cancel-button {
    -webkit-appearance: none;
    appearance: none;
}

.ds-picker-sheet .ds-picker-clear,
.ds-picker-sheet .ds-picker-clear:hover,
.ds-picker-sheet .ds-picker-clear:focus {
    position: absolute;
    top: 4px;
    right: 16px;
    width: 44px;
    height: 44px;
    padding: 0;
    border: 0;
    border-radius: 10px;
    background: transparent;
    box-shadow: none;
    color: var(--gray);
    font-size: 16px;
}

.ds-picker-sheet:not(.ds-searching) .ds-picker-clear {
    visibility: hidden;
}

.ds-picker-sheet .offcanvas-body {
    padding: 0 0 16px;
}

.ds-picker-sheet .ds-picker-footer {
    flex: none;
    padding: 8px 16px;
    border-top: 1px solid var(--border-color);
}

.ds-picker-sheet .ds-picker-footer .button {
    width: 100%;
    height: 44px;
    padding: 0 16px;
    border-radius: 10px;
    font-size: 15px;
    font-weight: 600;
}

/* The tree. */
.ds-picker-sheet .zones-container {
    max-height: none !important;
    overflow: visible;
}

.ds-picker-sheet .zone-container {
    padding: 0 !important; /* p-1 */
    border-bottom: 1px solid var(--border-color);
}

/* Zone header: a 48px row; the builder's +/- glyph moves to the trailing edge. */
.ds-picker-sheet .zone-container > .d-flex {
    min-height: 48px;
    padding: 0 16px;
    gap: 10px;
}

.ds-picker-sheet .zone-container > .d-flex > span {
    order: 2;
    margin: 0 0 0 auto !important; /* me-2 */
    font-size: 22px !important; /* the builder writes 1.2em inline */
    line-height: 1;
    color: var(--gray);
}

.ds-picker-sheet .zone-container h6 {
    font-size: 15px !important; /* font-xs */
    font-weight: 600;
}

.ds-picker-sheet .subzone-outer-container {
    flex-direction: column;
    padding-bottom: 6px;
}

.ds-picker-sheet .subzone-outer-container[style*="max-height: 400px"],
.ds-picker-sheet.ds-searching .subzone-outer-container {
    max-height: none !important;
}

/* Sub-zone caption + grid become a flat list. */
.ds-picker-sheet .subzone-outer-container > .d-flex {
    margin: 0 !important; /* m-1 */
}

.ds-picker-sheet .subzone-outer-container > .d-flex > p {
    margin: 0 !important;
    padding: 8px 16px 2px;
    font-size: 12px !important; /* font-xxs */
    letter-spacing: 0.02em;
}

.ds-picker-sheet .channel-grid {
    display: flex;
    flex: 1 1 auto;
    flex-direction: column;
    min-width: 0;
    padding: 0;
    border: 0;
    border-radius: 0;
}

/* Channel rows: 44px, name + unit, a colour dot before and a check after.
   Specificity (0,3,0)+ on purpose: .btn.channel-btn (0,2,0), the brand
   .btn:hover rule and updateButtonColors' INLINE fill and ink
   (background-color / color, hence !important) all lose to the row. */
.ds-picker-sheet .btn.channel-btn,
.ds-picker-sheet .btn.channel-btn:hover,
.ds-picker-sheet .btn.channel-btn:focus {
    display: flex;
    align-items: center;
    gap: 12px;
    width: 100%;
    min-width: 0;
    min-height: 44px;
    margin: 0 !important; /* me-1 */
    padding: 6px 16px;
    border: 0;
    border-radius: 0;
    background-color: transparent !important;
    color: var(--text-color) !important;
    box-shadow: none;
    font-size: 15px;
    line-height: 1.25;
    text-align: left;
    transition: none;
}

.ds-picker-sheet .btn.channel-btn:active {
    background-color: var(--menu-item-hover-bg) !important;
}

.ds-picker-sheet .channel-btn .channel-btn-unit {
    display: inline !important; /* hidden everywhere else (rule below) */
    margin-left: -6px; /* half the row's 12px flex gap: the unit hugs the name */
    font-size: 12px;
    color: var(--gray);
}

/* The colour dot: the chart colour once selected, an empty ring otherwise. */
.ds-picker-sheet .btn.channel-btn::before {
    content: '';
    flex: none;
    width: 14px;
    height: 14px;
    border-radius: 50%;
    border: 1px solid var(--border-color);
    background-color: var(--ds-chip-color, transparent);
}

.ds-picker-sheet .btn.channel-btn.is-selected::before {
    border-color: var(--ds-chip-color);
}

/* The check: an empty ring, filled with the accent and the bi-check-lg glyph
   once selected. */
.ds-picker-sheet .btn.channel-btn::after {
    content: '';
    flex: none;
    width: 24px;
    height: 24px;
    margin-left: auto;
    border-radius: 50%;
    border: 1.5px solid var(--border-color);
}

.ds-picker-sheet .btn.channel-btn.is-selected::after {
    content: "\f633";
    font-family: bootstrap-icons;
    font-size: 15px;
    line-height: 21px;
    text-align: center;
    border-color: var(--accent-color);
    background-color: var(--accent-color);
    color: var(--button-text);
}

/* Searching: a zone or sub-zone with no visible row goes with it. */
.ds-picker-sheet.ds-searching .zone-container:not(:has(.channel-btn:not([hidden]))),
.ds-picker-sheet.ds-searching .subzone-outer-container > .d-flex:not(:has(.channel-btn:not([hidden]))) {
    display: none !important;
}

/* ---- Header touch targets + sidebar PHONE grade ----
   Same clause as InsightsStack (stack.js): below md, or a landscape phone
   (short + coarse pointer), so a rotated phone keeps its tap sizes. ---- */
.ds-row-insights {
    display: none;
}

/* .ds-phone-only / .ds-desktop-only: ONE markup for both media, switched on
   this clause rather than on Bootstrap's md utilities (d-md-none and friends
   are width-only, so a landscape phone - wider than md - would get the
   desktop pieces). Used by the shared dashboard header partials (the nav
   toggler + sheet vs the desktop Menu / guide / Account cluster) and by the
   graphs card chrome (card_manager.js). Phone-only pieces are display:none
   at rest, so the desktop is untouched by construction. */
.ds-phone-only {
    display: none !important;
}

@media (max-width: 767.98px), ((max-height: 519.98px) and (pointer: coarse)) {
    :root {
        --ds-header-gap: 8px;
    }

    .ds-phone-only {
        display: inline-flex !important;
    }

    /* A phone-only control parked with the hidden attribute (the Cards button
       below three cards, graphs/jump_sheet.js): Bootstrap's [hidden] rule ties
       with the one above and loses on order, so the attribute needs its own. */
    .ds-phone-only[hidden] {
        display: none !important;
    }

    /* The nav sheet (#dashboardNavSheet) is an offcanvas: Bootstrap lays it
       out as a flex column, so it keeps flex here rather than inline-flex. */
    .offcanvas.ds-phone-only {
        display: flex !important;
    }

    .ds-desktop-only {
        display: none !important;
    }

    /* .ds-tap: the header row's navigator / kebab / menu buttons become plain
       44px squares (WCAG 2.5.5; the platform minimum). Overrides the global
       .nav-link padding and the Bootstrap margin utilities, both !important. */
    .fixed-header .ds-tap {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        flex-shrink: 0;
        width: 44px;
        height: 44px;
        padding: 0 !important;
        margin-right: 4px !important;
        border-radius: 0.375rem;
    }

    .fixed-header .navbar-toggler.ds-tap {
        border-color: transparent;
        color: var(--text-color);
    }

    .fixed-header .ds-tap .icon-size-xl {
        font-size: 22px;
    }

    /* Sidebar + owners-accordion grade. The panel chrome (close, sticky
       search) is keyed on .sidebar-panel (only the two dashboards host one);
       the accordion itself - header button, docked Insights link, row
       heights, row actions - is keyed on #ownersAccordionContainer, which the
       shared owners_sites_accordion.html partial renders on the owners PAGE
       as well as in the sidebar, so both surfaces get one grade (owner ask
       2026-09-08: the page's Insights link sat inside the gear's 44px box).
       Only the two hidden columns stay sidebar-only: the owners page keeps
       ID and ACTIVE, they fit there and mean something on a management page.
       Explicit px throughout so nothing here moves with
       .table { font-size: 0.85em } at <=560px or with the root. */
    /* Sweep finding 3 (2026-09-08) replaced the panel's 60px close band with
       a fixed corner X; since 2026-10-08 the panel's own header row
       (.sidebar-head, base rules) holds the X on every width, and the search
       row sticks just under it, inside the scrolling .sidebar-content. */
    .ds-current-site {
        min-height: 44px;
        padding-top: 0;
        padding-bottom: 0;
    }

    .ds-current-site-view {
        width: 44px;
        height: 44px;
        font-size: 18px;
    }

    /* The accordion's search row sticks to the screen top on every surface
       that hosts the owners/sites accordion: the sidebar (this rule's home)
       and the owners PAGE, whose search carries the same class. The page row
       inherits its container's surface colour; the sidebar paints its own. */
    .ds-sidebar-search {
        position: sticky;
        top: 0;
        z-index: 3;
        margin-top: 0 !important;
        padding: 6px 0;
        background-color: inherit;
    }

    .sidebar-panel .ds-sidebar-search {
        background-color: var(--primary-bg);
    }

    /* 16px input: below that iOS Safari zooms the page on focus. */
    .sidebar-panel .ds-sidebar-search .form-control,
    .sidebar-panel .ds-sidebar-search .search-clear-btn {
        min-height: 44px;
        font-size: 16px;
    }

    #ownersAccordionContainer .accordion-button {
        min-height: 44px;
        padding: 6px 96px 6px 12px;
        font-size: 14px;
    }

    /* The two docked header links become 44px squares: gear at the edge (the
       existing .accordion-header .actions-toggle rule sizes it), Insights just
       inside it; .no-gear docks Insights at the edge for roles without a gear. */
    #ownersAccordionContainer .accordion-header-insights {
        justify-content: center;
        right: 46px;
        width: 44px;
        height: 44px;
        padding: 0 !important;
    }

    #ownersAccordionContainer .accordion-header-insights.no-gear {
        right: 2px;
    }

    #ownersAccordionContainer .accordion-header-insights .bi {
        font-size: 18px;
    }

    #ownersAccordionContainer .table {
        font-size: 14px;
    }

    .sidebar-panel .ds-col-id,
    .sidebar-panel .ds-col-active {
        display: none;
    }

    /* The hidden ID cell took the current row's left border with it; the
       first visible cell draws it instead. */
    #sidebarPanel tr.ds-current > .ds-col-id + * {
        box-shadow: inset 2px 0 0 var(--ds-current-line), inset 0 2px 0 var(--ds-current-line),
                    inset 0 -2px 0 var(--ds-current-line), inset 0 0 0 9999px var(--ds-current-tint);
    }

    #ownersAccordionContainer .table > :not(caption) > * > * {
        padding: 2px 8px;
        vertical-align: middle;
    }

    #ownersAccordionContainer .table tbody tr {
        height: 44px;
    }

    #ownersAccordionContainer .table td:last-child {
        white-space: nowrap;
    }

    /* Row actions: the surfaced Insights link and the gear, 44px each. */
    #ownersAccordionContainer .ds-row-insights,
    #ownersAccordionContainer .table-gear-toggle {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 44px;
        height: 44px;
        vertical-align: middle;
        color: inherit;
        text-decoration: none;
        font-size: 18px;
    }

    #ownersAccordionContainer .ds-row-insights:hover,
    #ownersAccordionContainer .ds-row-insights:active {
        color: var(--accent-color);
    }

    /* The gear's sliding menu sits in a 44px-tall cell now; keep it clear of
       the row below (it is absolutely positioned inside .table-gear-wrapper). */
    #ownersAccordionContainer .table-gear-wrapper .actions-menu {
        right: 0;
    }
}

/* ---- Graphs dashboard PHONE grade (docs/graphs_mobile_ux_plan.md, phase 1)
   Same clause as the shared block above and as GraphsPhone.mode()
   (static/js/graphs/phone.js), so the chart profile (JS) and the card chrome
   (CSS) flip together. Every size is explicit px so nothing here moves with
   the root. The desktop page is untouched by construction: nothing below
   applies outside the clause, and the phone-only pieces (.ds-phone-only,
   shared block above) are display:none at rest. ---- */
.ds-phone-date-text,
.channel-btn .channel-btn-unit {
    display: none !important;
}

@media (max-width: 767.98px), ((max-height: 519.98px) and (pointer: coarse)) {
    /* -- Header (plan sec 4, decision 15). Hugs the viewport (mx-2 is a
       Bootstrap !important utility). Row 1 = 56px [site 44] [name 17px]
       [status icons] [menu 44]: the name owns the row. Row 2 = 36px of
       right-aligned 44x36 icons; the search field shows only while
       .ds-searching (search.js), across the whole row. */
    #graphPageHeader {
        margin-left: 0 !important;
        margin-right: 0 !important;
        padding-left: 4px;
        padding-right: 4px;
    }

    #graphPageHeader .ds-graphs-row1 {
        height: 56px;
        margin-bottom: 0 !important;
        border-bottom: 0;
    }

    #graphPageHeader .ds-graphs-identity {
        flex: 1 1 auto;
        min-width: 0;
    }

    #graphPageHeader #siteName {
        flex: 1 1 auto;
        min-width: 0;
        font-size: 17px;
        font-weight: 600;
        line-height: 1.2;
        text-align: left !important;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
    }

    #graphPageHeader .ds-graphs-row2 {
        height: 36px;
        margin-bottom: 0 !important;
    }

    #graphPageHeader .ds-graphs-row2 .ds-tap {
        height: 36px;
        margin-right: 0 !important;
    }

    #graphPageHeader .ds-graphs-actions {
        margin-left: 0 !important;
    }

    #graphPageHeader .ds-graphs-search {
        display: none;
    }

    #graphPageHeader.ds-searching .ds-graphs-search {
        display: flex;
        flex: 1 1 auto;
        max-width: none;
        height: 34px;
    }

    #graphPageHeader.ds-searching .ds-graphs-row2 > :not(.ds-search-only):not(.ds-graphs-search) {
        display: none !important;
    }

    #graphPageHeader:not(.ds-searching) .ds-search-only {
        display: none !important;
    }

    /* 16px input: below that iOS Safari zooms the page on focus. */
    #graphPageHeader .ds-graphs-search .form-control {
        height: 34px;
        padding: 0 10px;
        font-size: 16px;
    }

    #graphPageHeader .ds-graphs-search .search-clear-btn {
        height: 34px;
        min-width: 44px;
        padding: 0;
        font-size: 16px;
    }

    /* -- Page and card grid: 8px gutters, so a card edge sits 8px in. */
    .page-container {
        padding-left: 8px;
        padding-right: 8px;
    }

    #cardsContainer {
        --bs-gutter-x: 16px;
    }

    /* Move up / down and the phase-4 jump list land a card under the header. */
    #cardsContainer > [id^="card-"] {
        scroll-margin-top: calc(var(--ds-header-h, 92px) + 8px);
    }

    /* -- Card header, 48px (plan 3.5, decision 14): [title 14px] [chevron 44]
       [more 44]. The + / pencil / X are .ds-desktop-only; the menu items
       drive those same controls (card_manager.js setupCardMenu). Card rules
       are keyed on #cardsContainer, NOT .draggable-card: setupTitleEditing
       strips that class from the column while a title is edited (to park
       Sortable), which would drop every rule here mid-rename. */
    #cardsContainer .ds-graph-card-header {
        min-height: 48px;
        padding: 0 2px 0 14px;
    }

    #cardsContainer .ds-graph-card-title-wrap {
        flex: 1 1 auto;
        min-width: 0;
    }

    #cardsContainer .ds-graph-card-title {
        min-width: 0;
        font-size: 14px;
        font-weight: 600;
        line-height: 1.25;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
    }

    #cardsContainer .ds-tap-card {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 44px;
        height: 44px;
        margin: 0 !important;
        padding: 0 !important;
        border-radius: 6px;
    }

    #cardsContainer .ds-tap-card .icon-size-xl {
        font-size: 22px;
    }

    /* Menu items at 44px here as well: the global rule is max-width 768px
       only, which a landscape phone (844px wide) misses. */
    #cardsContainer .ds-card-menu .dropdown-menu {
        min-width: 200px;
    }

    #cardsContainer .ds-card-menu .dropdown-item {
        display: flex;
        align-items: center;
        min-height: 44px;
        padding: 0.625rem 1rem;
        font-size: 15px;
    }

    /* In-place rename inside the 48px header: a 40px input at 16px (no iOS
       zoom) taking the row, 44px tick / cross. setupTitleEditing writes the
       desktop width and min-width inline, hence !important. */
    #cardsContainer .ds-graph-card-header .card-header-edit {
        flex: 1 1 auto;
        width: auto !important;
        min-width: 0 !important;
        height: 40px;
        font-size: 16px;
    }

    #cardsContainer .ds-graph-card-header .save-title-btn,
    #cardsContainer .ds-graph-card-header .cancel-title-btn {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        flex-shrink: 0;
        width: 44px;
        height: 44px;
        margin: 0 !important;
    }

    /* -- Card body: 8px sides (pt-0 / ps-1 / pe-1 are !important utilities). */
    #cardsContainer .card-body {
        padding: 10px 8px 8px !important;
    }

    /* -- Channel chips (plan 3.5, decisions 14 + 16): 28px minimum height at
       11px on the app's 6px radius, carrying colour + name + (unit) + the
       interval tag. A tap opens the channel settings (channel_display.js);
       nothing is removed by a tap. The view-data icon gives way to the
       card menu's View data. */
    #cardsContainer [id^="selectedChannels-"] {
        margin-bottom: 0 !important;
        border-bottom: 0;
    }

    #cardsContainer .ds-chips-row {
        margin-bottom: 4px !important;
    }

    #cardsContainer .ds-chips-row .font-xxs {
        margin-right: 8px !important;
        font-size: 12px;
        line-height: 28px;
    }

    #cardsContainer .btn.channel-btn {
        display: inline-flex;
        align-items: center;
        gap: 5px;
        min-height: 28px;
        min-width: 0;
        padding: 4px 10px 4px 9px;
        border-radius: 6px;
        font-size: 11px;
        font-weight: 500;
        line-height: 1.2;
        text-align: left;
    }

    #cardsContainer .btn.channel-btn.has-interval-tag {
        padding-right: 10px;
    }

    #cardsContainer .channel-btn .channel-btn-unit {
        display: inline !important;
        opacity: 0.8;
        font-weight: 400;
    }

    #cardsContainer .channel-btn .interval-tag {
        top: -6px;
        right: -4px;
        min-width: 15px;
        height: 15px;
    }

    #cardsContainer .channel-btn .alert-icon-channel,
    #cardsContainer .channel-btn .stale-icon-device {
        margin-right: 0;
    }

    #cardsContainer .btn.channel-btn.ds-chip-add {
        background-color: transparent;
        color: var(--text-color);
        border: 1px dashed var(--gray);
        box-shadow: none;
    }

    #cardsContainer .ds-chip-add .bi {
        font-size: 14px;
    }

    #cardsContainer .ds-view-data {
        display: none;
    }

    /* -- Date row (plan 3.5, decision 14): the flatpickr ANCHOR becomes the
       44px field (calendar glyph + '1 Sep 10:30 - 7 Sep 10:30' at 12px), so
       the calendar keeps opening from the element it is bound to. The two
       disabled display inputs and their labels are desktop furniture. */
    #cardsContainer .ds-date-row {
        margin-bottom: 8px !important;
    }

    #cardsContainer .date-time-selector {
        width: 100%;
    }

    #cardsContainer .date-time-selector > .d-inline-block {
        display: none !important;
    }

    #cardsContainer .date-time-selector > .d-flex {
        flex: 1 1 auto;
        min-width: 0;
        gap: 8px;
        align-items: center !important;
    }

    #cardsContainer .date-time-selector [data-toggle="flatpickr"] {
        display: inline-flex;
        align-items: center;
        gap: 6px;
        flex: 1 1 auto;
        min-width: 0;
        height: 44px;
        padding: 0 10px;
        border: 1px solid var(--border-color);
        border-radius: 10px;
        background-color: var(--alt-bg);
        color: var(--text-color);
        font-size: 12px;
        text-decoration: none;
    }

    #cardsContainer .date-time-selector [data-toggle="flatpickr"] .bi {
        margin-right: 0 !important;
        font-size: 18px;
        color: var(--gray);
    }

    #cardsContainer .ds-phone-date-text {
        display: inline !important;
        min-width: 0;
        overflow: hidden;
        white-space: nowrap;
        text-overflow: ellipsis;
        font-variant-numeric: tabular-nums;
    }

    #cardsContainer .date-time-selector .input-group {
        flex: 0 0 auto;
        width: auto;
    }

    #cardsContainer .date-time-selector .date-range-selector {
        height: 44px;
        padding: 0 10px !important;
        border: 1px solid var(--border-color);
        border-radius: 10px !important;
        background-color: var(--alt-bg);
        color: var(--text-color);
        font-size: 12px !important;
    }

    #cardsContainer .date-range-menu .dropdown-item {
        display: flex;
        align-items: center;
        min-height: 44px;
        font-size: 15px;
    }

    /* -- Chart box (plan 3.3, decision 4): the rule owns the height
       (!important beats the inline height the desktop resize handle writes)
       and the handle itself is hidden. */
    #cardsContainer .chart-container {
        height: clamp(300px, 52vh, 420px) !important;
        /* Plan sec 6: a chart box below the fold skips layout and paint until
           it scrolls near. The explicit height above is its size, so nothing
           is guessed and nothing shifts, and Chart.js keeps sizing the canvas
           from this box, which stays laid out. NOT on the card body: the
           paint containment that comes with content-visibility would clip the
           chips' interval tag (top: -6px) and the interval dropdown, both of
           which overflow it. */
        content-visibility: auto;
    }

    #cardsContainer .resize-handle {
        display: none;
    }

    /* -- Skeleton (plan sec 6): GraphsLoading's fetch overlay (graphs/loading.js
       writes it with an inline background, hence the !important) reads as the
       app's shimmer block over the chart box instead of a spinner. */
    #cardsContainer .chart-loading-indicator {
        background: var(--skeleton-gradient) !important;
        background-size: 200% 100% !important;
        border-radius: 8px;
        animation: skeleton-shimmer 1.5s ease-in-out infinite;
    }

    #cardsContainer .chart-loading-indicator .spinner-border {
        display: none;
    }

    /* -- Reset zoom (plan 3.5 item 5): the engine-created .reset-zoom-btn
       becomes a 36px chip top-right over the plot. chart_engine.js writes
       top / right / font-size / padding inline, hence the !important. It
       first shows on phones with phase 2 (Hammer gives pinch; pinch fires
       the engine's onZoomStart). */
    #cardsContainer .reset-zoom-btn {
        top: 8px !important;
        right: 8px !important;
        height: 36px;
        padding: 0 12px !important;
        border: 1px solid var(--border-color);
        border-radius: 18px;
        background-color: var(--primary-bg);
        color: var(--text-color);
        font-size: 13px !important;
        font-weight: 500;
        line-height: 34px;
        box-shadow: 0 2px 6px var(--shadow-color);
    }

    /* -- Add card: a 56px dashed button instead of a 300px empty card. */
    #add-card-placeholder .card {
        border: 0;
        background: transparent;
        box-shadow: none;
    }

    #add-card-placeholder .ds-add-card-body {
        min-height: 0;
        padding: 0;
    }

    #add-card-placeholder #addCardBtn {
        width: 100%;
        height: 56px;
        border-style: dashed;
        border-radius: 12px;
        font-size: 15px;
        font-weight: 500;
    }

    /* -- No-site prompt (plan sec 4): the brand logo over one full-width 56px
       button at 16px. The button's Bootstrap sizing utilities (w-50, px-5,
       py-3, fs-2, mt-4) are !important, hence these. */
    #siteSelectionPrompt .site-prompt-logo {
        width: 70%;
        max-width: 280px;
    }

    #siteSelectionPrompt .button {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 100% !important;
        height: 56px;
        margin-top: 24px !important;
        padding: 0 16px !important;
        border-radius: 12px;
        font-size: 16px !important;
        font-weight: 600;
    }

    /* -- Notices (plan sec 4, decision 9): the shared TaskMonitor stack docks
       to the bottom edge as a full-width bar above the safe-area inset, so
       the unsaved-changes and download cards never sit over a chart. The
       page gains bottom padding only while a notice shows (TaskMonitor
       removes the container when it is empty). Both dashboards share this. */
    .ds-task-monitor {
        left: 0;
        right: 0;
        bottom: 0;
        /* Under the sheet and modal backdrops (Bootstrap 1040 / 1050): the
           desktop card's 9998 sat over the bottom of every phone sheet and
           fullscreen modal (phase-4 pass: the docked unsaved-changes bar hid
           the jump sheet's last row and would hide a modal's footer). */
        z-index: 1035;
        max-width: none;
        min-width: 0;
        gap: 0;
        padding-bottom: env(safe-area-inset-bottom, 0px);
        background-color: var(--primary-bg);
        box-shadow: 0 -2px 8px var(--shadow-color);
    }

    .ds-task-monitor .ds-task-card {
        border-radius: 0;
        border-right: 0;
        border-bottom: 0;
        box-shadow: none;
    }

    .ds-task-monitor .ds-task-card.ds-unsaved-warning {
        min-height: 56px;
        padding: 6px 8px 6px 14px;
    }

    .ds-task-monitor .ds-unsaved-title {
        font-size: 14px;
    }

    .ds-task-monitor .ds-unsaved-sites {
        font-size: 12px;
    }

    .ds-task-monitor .ds-unsaved-actions .btn {
        min-width: 44px;
        height: 44px;
        font-size: 18px;
    }

    /* -- Channel settings forms, graded for a thumb (plan 5.2 / 5.3). ONE
       grade for the Graph form (#channel-settings-form: in #channelSheet on a
       chip tap, in #channelModal from the sheet's tab rows or a right-click)
       and the Settings tab's device form (graphs_update_channel_form.html),
       which share the modal's markup family: .container.w-50 of
       .input-group-sm rows. The label span becomes a caption above a 44px
       control at 16px (the iOS zoom threshold), the colour input a 44px bar
       the swatches write into, the switch a 48px row, Save / Submit full
       width. Bootstrap's w-50 / mb-3 / ms-2 / gap-2 utilities are !important,
       hence the matches. In the clause, not unconditional under the sheet:
       the sheet only ever shows in phone mode (JS-gated on the same query),
       so the clause covers both hosts with one rule set. */
    :is(.ds-channel-sheet, #channelModal) :is(#channel-settings-form, #channel-settings-container form) .container {
        width: 100% !important;
        max-width: none;
        padding: 0 16px;
    }

    #channelModal :is(#channel-settings-form, #channel-settings-container form) .container {
        padding: 0; /* the modal body carries the 16px */
    }

    :is(.ds-channel-sheet, #channelModal) :is(#channel-settings-form, #channel-settings-container form) .input-group {
        display: block;
        margin-bottom: 12px !important;
    }

    :is(.ds-channel-sheet, #channelModal) :is(#channel-settings-form, #channel-settings-container form) .input-group-text {
        display: block;
        width: auto;
        padding: 0 0 4px;
        border: 0;
        border-radius: 0;
        background: transparent;
        font-size: 12px;
        line-height: 1.3;
        text-align: left; /* Bootstrap centres input-group-text */
        color: var(--gray);
    }

    :is(.ds-channel-sheet, #channelModal) :is(#channel-settings-form, #channel-settings-container form) .form-select,
    :is(.ds-channel-sheet, #channelModal) :is(#channel-settings-form, #channel-settings-container form) .form-control {
        display: block;
        width: 100%;
        height: 44px;
        padding: 0 12px;
        font-size: 16px;
        border-radius: 10px;
    }

    :is(.ds-channel-sheet, #channelModal) :is(#channel-settings-form, #channel-settings-container form) .form-control-color {
        padding: 4px;
    }

    :is(.ds-channel-sheet, #channelModal) :is(#channel-settings-form, #channel-settings-container form) #color-swatches {
        justify-content: flex-start;
        gap: 10px !important;
    }

    :is(.ds-channel-sheet, #channelModal) .color-swatch {
        width: 36px;
        height: 36px;
        border-radius: 10px;
    }

    /* channel_display.js writes the container's display inline (block / none
       by chart type), so the row grade sits on the .form-check inside it. */
    :is(.ds-channel-sheet, #channelModal) #force-min-zero-container {
        margin-bottom: 4px !important;
    }

    :is(.ds-channel-sheet, #channelModal) #force-min-zero-container .form-check {
        display: flex;
        align-items: center;
        justify-content: space-between;
        width: 100%;
        min-height: 48px;
        margin: 0 !important;
        padding: 0;
    }

    :is(.ds-channel-sheet, #channelModal) #force-min-zero-container .form-check-label {
        order: 1;
        font-size: 15px;
    }

    :is(.ds-channel-sheet, #channelModal) #force-min-zero-container .form-check-input {
        order: 2;
        flex: none;
        width: 48px;
        height: 28px;
        margin: 0;
    }

    :is(.ds-channel-sheet, #channelModal) :is(#channel-settings-form, #channel-settings-container form) #save-settings,
    :is(.ds-channel-sheet, #channelModal) :is(#channel-settings-form, #channel-settings-container form) .button {
        width: 100%;
        height: 44px;
        margin: 4px 0;
        padding: 0 16px;
        border-radius: 10px;
        font-size: 15px;
        font-weight: 600;
    }

    /* -- #channelModal / #channelDataModal (plan 5.3 / 5.5): FULLSCREEN by
       hand. Bootstrap's modal-fullscreen-*-down variants are width-only and
       cannot follow this clause's landscape branch, and .modal-custom-size is
       100vw at <=560 only. The dialog fills the viewport; the content is a
       column whose body scrolls while the header (44px close) and the footer
       (44px Close) stay put. */
    #channelModal .modal-dialog,
    #channelDataModal .modal-dialog {
        width: 100vw;
        max-width: none;
        height: 100%;
        margin: 0;
        align-items: stretch;
    }

    #channelModal .modal-content,
    #channelDataModal .modal-content {
        height: 100%;
        border: 0;
        border-radius: 0;
    }

    #channelModal .modal-header,
    #channelDataModal .modal-header {
        flex: none;
        min-height: 56px;
        padding: 6px 8px 6px 16px;
        border-radius: 0;
    }

    #channelModal .modal-title,
    #channelDataModal .modal-title {
        font-size: 16px;
        line-height: 1.25;
    }

    /* 44px close; the X itself stays the masked currentColor glyph. */
    #channelModal .modal-header .btn-close,
    #channelDataModal .modal-header .btn-close {
        width: 44px;
        height: 44px;
        margin: 0 0 0 auto;
        padding: 0;
    }

    #channelModal .modal-body,
    #channelDataModal .modal-body {
        flex: 1 1 auto;
        min-height: 0;
        margin-top: 0 !important; /* mt-4 */
        padding: 12px 16px 16px;
        overflow-y: auto;
        overscroll-behavior: contain;
    }

    #channelModal .modal-body > .mt-5,
    #channelDataModal .modal-body > .mt-5 {
        margin-top: 16px !important;
    }

    #channelModal .modal-footer,
    #channelDataModal .modal-footer {
        flex: none;
        padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px));
    }

    #channelModal .modal-footer .button,
    #channelDataModal .modal-footer .button {
        width: 100%;
        height: 44px;
        margin: 0;
        padding: 0 16px !important; /* pt-0 pb-0 */
        border-radius: 10px;
        font-size: 15px;
        font-weight: 600;
    }

    /* The four tab radios: a 44px segmented row. */
    #channelModal .btn-group {
        display: flex;
        width: 100%;
    }

    #channelModal .btn-group > .btn {
        display: inline-flex;
        flex: 1 1 0;
        align-items: center;
        justify-content: center;
        height: 44px;
        padding: 0 4px;
        font-size: 14px;
        white-space: nowrap;
    }

    /* -- Details tab (plan 5.3): the SHARED read-only partial
       (controllers/partials/wc_channel_details_form.html: every input
       disabled, one wrapper per column keyed by its id) becomes a two-column
       list of the useful fields. The allowlist is CSS - keyed wrappers hide,
       the chosen keys show - and graphs/channel_display.js relabels them on
       the swap. The virtual-channel partial (vc_channel_details_form.html)
       has no wrapper ids and readable labels already: its rows get the same
       label | value grade and its sections stay. */
    #channelModal #channel-details-container form {
        padding: 0 !important; /* p-3 */
        background: transparent;
    }

    #channelModal #channel-details-container .row {
        display: flex;
        flex-direction: column;
        margin: 0 !important;
    }

    /* Reading order (the partial emits column order): name, unit, zone,
       sub-zone, group, device, last edited, then the flags. */
    #channelModal #channel-details-container #zone { order: 1; }
    #channelModal #channel-details-container #sub_zone { order: 2; }
    #channelModal #channel-details-container #channel_group { order: 3; }
    #channelModal #channel-details-container :is(#controller_id, #logger_id) { order: 4; }
    #channelModal #channel-details-container :is(#controller_name, #logger_name) { order: 5; }
    #channelModal #channel-details-container #last_edited { order: 6; }
    #channelModal #channel-details-container #is_active { order: 7; }
    #channelModal #channel-details-container #is_archived { order: 8; }

    #channelModal #channel-details-container .input-container {
        width: 100%;
        padding: 0;
    }

    #channelModal #channel-details-container .input-container[id] {
        display: none;
    }

    #channelModal #channel-details-container .input-container:is(#channel_display_name, #channel_unit, #zone, #sub_zone, #channel_group, #controller_id, #controller_name, #logger_id, #logger_name, #last_edited, #is_active, #is_archived) {
        display: block;
    }

    #channelModal #channel-details-container .input-group {
        display: grid;
        grid-template-columns: minmax(0, 38%) minmax(0, 1fr);
        align-items: center;
        gap: 12px;
        min-height: 44px;
        margin: 0 !important; /* mb-2 */
        padding: 4px 0;
        border-bottom: 1px solid var(--border-color);
    }

    #channelModal #channel-details-container .input-group-text {
        display: block;
        padding: 0;
        border: 0;
        background: transparent;
        font-size: 13px;
        line-height: 1.3;
        text-align: left;
        white-space: normal;
        color: var(--gray);
    }

    /* Disabled inputs read as plain text: no box, the theme ink (iOS greys
       disabled text through -webkit-text-fill-color). */
    #channelModal #channel-details-container .form-control:disabled,
    #channelModal #channel-details-container .form-select:disabled {
        width: 100%; /* Bootstrap's input-group sizes controls at width: 1% for flex */
        min-width: 0;
        min-height: 36px;
        padding: 0;
        border: 0;
        border-radius: 0;
        background: transparent;
        font-size: 16px;
        color: var(--text-color);
        -webkit-text-fill-color: var(--text-color);
        opacity: 1;
    }

    #channelModal #channel-details-container .form-check-input {
        width: 24px;
        height: 24px;
        margin: 0;
        justify-self: start;
    }

    /* -- Alerts tab: the sidebar's table treatment (CHANNEL 1-3 are already
       d-none below lg in the partial). */
    #channelModal #alerts-container .table {
        margin-bottom: 0;
        font-size: 14px;
    }

    #channelModal #alerts-container .table > :not(caption) > * > * {
        padding: 4px 8px;
        vertical-align: middle;
    }

    #channelModal #alerts-container .table tbody tr {
        height: 44px;
    }

    #channelModal #alerts-container .table-actions-btn {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        min-width: 44px;
        min-height: 44px;
    }

    /* -- View data (plan 5.5): 14px tables; the first column (channel name /
       timestamp) sticky while the rest scrolls sideways - table-dark paints
       cells with --bs-table-bg (the opaque --table-bg here), repeated on the
       sticky cell so scrolled cells pass behind it - and 44px Download CSV /
       Show More / Close. */
    #channelDataModal h5 {
        margin-bottom: 8px;
        font-size: 15px;
    }

    #channelDataModal .table-responsive {
        margin-bottom: 12px !important; /* mb-4 */
    }

    #channelDataModal .table {
        margin-bottom: 0;
        font-size: 14px;
    }

    #channelDataModal .table > :not(caption) > * > * {
        padding: 6px 8px;
        white-space: nowrap;
        vertical-align: middle;
    }

    #channelDataModal .table th:first-child,
    #channelDataModal .table td:first-child {
        position: sticky;
        left: 0;
        z-index: 1;
        background-color: var(--bs-table-bg);
        border-right: 1px solid var(--bs-table-border-color);
    }

    #channelDataModal #downloadTableDataBtn,
    #channelDataModal #showMoreButtonRow .btn {
        min-height: 44px;
        padding: 0 14px !important; /* p-0 px-2 */
        border-radius: 10px;
        font-size: 14px;
    }

    body:has(.ds-task-monitor) .page-container {
        padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px));
    }
}

/* Landscape phone (short + coarse). After the portrait rules so these win
   on the shared clause. Sweep finding 2 (2026-09-08): the graphs header
   SCROLLS with the page here - pinned, its 93px was a quarter of a 412px
   screen. In flow it keeps its phone grade; the page padding that made room
   under it goes, and the card landing and the chart box measure from the
   screen edge. Graphs only: the insights header carries the tab strip and
   is the insights lane's call. */
@media (max-height: 519.98px) and (pointer: coarse) {
    #graphPageHeader.fixed-header {
        position: static;
        -webkit-backdrop-filter: none;
        backdrop-filter: none;
    }

    body:has(#graphPageHeader) .page-container {
        padding-top: var(--ds-header-gap, 8px);
    }

    #cardsContainer > [id^="card-"] {
        scroll-margin-top: 8px;
    }

    #cardsContainer .chart-container {
        height: clamp(220px, calc(100svh - 24px), 420px) !important;
    }
}

/* ---- Municipal rates: the period a Save just changed ----
   routes.update_period re-renders every period box (new dates can re-sort
   them) and marks the edited one's WRAPPER .period-updated
   (partials/periods_list.html); municipal_rates.js scrolls it into view. A
   ring in the brand accent that fades out, so the admin sees WHICH period
   changed without it staying loud. On the wrapper, not `> .ds-group`: Edit and
   Cancel swap a new box INTO the wrapper, which replayed the glow as if the
   period had changed again. The wrapper is only ever inserted by a Save. It
   has no margin of its own, so it is exactly the box's size; the radius is
   the .ds-group box's. */
.period-updated {
    border-radius: 8px;
    scroll-margin: 1rem;
    animation: period-updated 2.5s ease-out;
}

@keyframes period-updated {
    0%, 40% { box-shadow: 0 0 0 0.25rem color-mix(in srgb, var(--accent-color) 60%, transparent); }
    100%    { box-shadow: 0 0 0 0.25rem transparent; }
}
