/*
 * WISPHero's visual identity as CSS custom properties, consumed via
 * Tailwind's arbitrary-value syntax (e.g. bg-[var(--ph-surface)]) since
 * the app loads Tailwind from its plain CDN build with no config file -
 * that rules out a real tailwind.config.js darkMode setup, but CSS
 * variables work with zero config at all.
 *
 * Palette is a console/instrument-panel direction on purpose - a WISP
 * tech's tool for glancing at tower health between truck rolls, not a
 * generic SaaS dashboard - deep blue-black surfaces (not literal
 * Tailwind slate), one deliberate signal-indigo accent replacing the
 * generic blue-600 everywhere, and monospace (Atkinson Hyperlegible Mono, see
 * base.html) reserved for every metric/IP/timestamp so numbers read
 * like an instrument's own readout.
 *
 * Status-card fills (--ph-status-*, used by grid.js's qualityCardClass)
 * are deliberately muted rather than Tailwind's stock -600 swatches -
 * think backlit gauge lighting, not caution tape. Same reasoning as the
 * accent: one intentional set of hex values instead of default utility
 * colors. Kept theme-independent (no [data-theme="light"] override) on
 * purpose, same as the rest of this block - a saturated fill reads the
 * same regardless of theme, and white card text needs one contrast
 * target, not two.
 */
:root {
    /* READABILITY (2026-10-06): most of this app's readers have poor eyesight, so every text colour below is held to a contrast target on the surfaces it is
       actually used on (checked by tests_e2e/readability_audit.spec.js, which measures the real rendered page): text / secondary / muted clear 7:1 (WCAG AAA),
       faint clears 4.5:1 on the cards and rows it appears on, accent-text and the status-*-text colours clear 7:1 on the card surface, form-field edges 3:1.
       Dark = a dark blue-GRAY, not near-black (halation: bright text on pure black glares and blurs for astigmatic eyes), with the surfaces stepping up in
       lightness so cards read as cards. The accent has two jobs and so two tokens: --ph-accent is a FILL (white text on it), --ph-accent-text is accent-coloured
       TEXT/icons on a surface. */
    --ph-bg: #121923;
    --ph-surface: #1a2431;
    --ph-surface-alt: #223041;
    --ph-surface-strong: #2f3f54;
    --ph-border: #46576e;
    --ph-border-input: #71839c;
    --ph-text: #f5f8fa;
    --ph-text-secondary: #dbe3ea;
    --ph-text-muted: #b9c5d1;
    --ph-text-faint: #abb8c7;
    --ph-text-faintest: #a1aebd;
    --ph-accent: #3647d0;
    --ph-accent-hover: #4556e0;
    --ph-accent-text: #b0bbff;
    --ph-accent-text-hover: #c6cdff;
    --ph-accent-soft: #232d5e;
    --ph-status-good: #236447;
    --ph-status-warning: #755600;
    --ph-status-bad: #943d10;
    --ph-status-critical: #93263f;
    --ph-status-pending: #3b4657;
    /* The same five states as TEXT on a card surface (the fills above are for white text ON them). */
    --ph-status-good-text: #4ade9a;
    --ph-status-warning-text: #fbbf24;
    --ph-status-bad-text: #ffa066;
    --ph-status-critical-text: #ff8da1;
    --ph-status-pending-text: #b9c5d1;
    /* Text colours for a warning / a success / an error / an info line (the light theme needs much darker ones - the 400-level swatches are 1.7-2.7:1 on white). */
    --ph-warn: #fbbf24;
    --ph-ok: #4ade9a;
    --ph-bad: #ff8da1;
    --ph-info: #7dd3fc;
    /* Hover colour for a palette-coloured control that goes red on hover (a delete X, "Retry now"). Dark theme: the stock rose-300 the markup asks for. */
    --ph-bad-hover: #fda4af;
    /* Ping Tool strips (static/ping_tool.js reads these from the canvas): light latency bands behind the line (0-200 / 201-500 / 501+ ms), the line,
       the solid no-reply block and the mid grid line. */
    --ph-pt-band-good: rgba(74, 222, 154, 0.12);
    --ph-pt-band-warn: rgba(251, 191, 36, 0.16);
    --ph-pt-band-bad: rgba(255, 141, 161, 0.18);
    --ph-pt-line: #c6cdff;
    --ph-pt-loss: #e11d48;
    --ph-pt-grid: rgba(255, 255, 255, 0.10);
    /* Aliases for names the markup already uses: a hover fill one step up from the control's own, and "the" body text colour. */
    --ph-surface-hover: var(--ph-surface-strong);
    --ph-text-primary: var(--ph-text);
    --ph-font-sans: 'Atkinson Hyperlegible Next', ui-sans-serif, system-ui, 'Segoe UI', Roboto, sans-serif;
    --ph-font-mono: 'Atkinson Hyperlegible Mono', ui-monospace, 'SF Mono', Consolas, monospace;
}

[data-theme="light"] {
    --ph-bg: #f4f6f9;
    --ph-surface: #ffffff;
    --ph-surface-alt: #edf0f4;
    --ph-surface-strong: #dbe0e7;
    --ph-border: #b3bcc7;
    --ph-border-input: #7f8b99;
    --ph-text: #0f1419;
    --ph-text-secondary: #27303a;
    --ph-text-muted: #475260;
    --ph-text-faint: #4d5967;
    --ph-text-faintest: #5c6877;
    --ph-accent: #3340c8;
    --ph-accent-hover: #2a36b0;
    --ph-accent-text: #2f3cbd;
    --ph-accent-text-hover: #252fa0;
    --ph-accent-soft: #e8ebff;
    --ph-status-good-text: #065f46;
    --ph-status-warning-text: #92400e;
    --ph-status-bad-text: #9a3412;
    --ph-status-critical-text: #9f1239;
    --ph-status-pending-text: #475260;
    --ph-warn: #92400e;
    --ph-ok: #065f46;
    --ph-bad: #9f1239;
    --ph-info: #075985;
    --ph-bad-hover: #881337;
    --ph-pt-band-good: rgba(6, 95, 70, 0.10);
    --ph-pt-band-warn: rgba(180, 83, 9, 0.13);
    --ph-pt-band-bad: rgba(159, 18, 57, 0.11);
    --ph-pt-line: #252fa0;
    --ph-pt-loss: #be123c;
    --ph-pt-grid: rgba(15, 20, 25, 0.10);
}

body {
    font-family: var(--ph-font-sans);
}
/* base.html's <body class="h-[100dvh] overflow-hidden">: a browser without dvh (Chrome < 108, Safari < 15.4 - a smart-TV browser, which this app targets) drops that
   class's whole declaration, leaving body at height:auto and `overflow-hidden`, so a tall page was clipped and could not scroll. This line is the vh fallback:
   a browser with dvh reads both and keeps the second. (The escaped class is Tailwind's own, so this only touches the page shell.) */
body.h-\[100dvh\] { height: 100vh; height: 100dvh; }

/* READABILITY: the page's own text size is the browser's (16px unless the person changed it) - never forced smaller - with generous line spacing, and
   bright-on-dark text that is not hairline thin. */
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
body { line-height: 1.5; font-size: 1rem; }
/* A placeholder is a hint the person has to be able to read: Tailwind's own default (gray-400) is 2.4:1 on a white field. Still lighter than typed text so it does
   not look filled in. */
:root input::placeholder, :root textarea::placeholder, ::placeholder { color: var(--ph-text-faint); opacity: 1; }
/* Keyboard focus is always visible, and never thinner than 2px, on every theme (WCAG 2.4.7 / 2.4.11). :where() keeps this at zero specificity so a control that
   draws its own ring (focus:ring-*) still wins. */
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible { outline: 2px solid var(--ph-accent-text); outline-offset: 2px; }
/* ...except that ~30 text fields carry Tailwind's `focus:outline-none` (outline: 2px solid transparent, specificity 0,2,0) and kept only a 1px border-colour change
   as their focus cue - the sign-in form and the header search among them. Put the ring back on those; a field that draws its own ring (focus:ring-*) keeps just that. */
:is(input, select, textarea).focus\:outline-none:focus-visible:not([class*="focus:ring"]) { outline: 2px solid var(--ph-accent-text); outline-offset: 2px; }
/* Native form controls (checkbox, radio, range) take the accent so they stand out from the page. */
:root { accent-color: var(--ph-accent); }
/* Tailwind's palette-coloured text is chosen for a DARK page (the -400 / -300 levels). On the light theme those are 1.7-2.7:1 on white, so the same words get the
   darker token instead. The audit (tests_e2e/readability_audit.spec.js) measures the real result.
   Two kinds of element are NOT on the light page and must keep light text, which the rules below take care of: a thing that paints its own dark
   fill by CLASS (an error toast, a Synced badge: its fill goes light in the light theme, below) and a thing on a status card / list row (a .text-white subtree: the
   fill is theme-independent, so the tokens are re-pointed to light values there). Do NOT write `html :is(..., .hover\:text-x:hover)`: :is() takes the specificity of
   its most specific argument, so one :hover class in the list lifted every other class in it above Tailwind's own hover rule and killed the hover feedback. */
html :is(.text-rose-400, .text-rose-500, .text-red-400, .text-red-500) { color: var(--ph-bad); }
html[data-theme="light"] :is(.text-rose-300, .text-rose-200, [class*="text-rose-300/"]) { color: var(--ph-bad); }
html[data-theme="light"] :is(.text-emerald-300, .text-emerald-400, .text-emerald-500, .text-green-500, .text-green-400) { color: var(--ph-ok); }
html[data-theme="light"] :is(.text-amber-300, .text-amber-400, .text-amber-500, .text-amber-200, .text-amber-100, [class*="text-amber-100/"]) { color: var(--ph-warn); }
html[data-theme="light"] :is(.text-sky-400, .text-sky-300) { color: var(--ph-info); }
/* A red hover: the stock -100..-400 shades are pale on the light page, so the darkest red the theme has. */
html[data-theme="light"] :is(.hover\:text-rose-100, .hover\:text-rose-200, .hover\:text-rose-300, .hover\:text-rose-400):hover { color: var(--ph-bad-hover); }
/* Status cards and list rows paint a saturated fill in BOTH themes (white text, .text-white), so inside one the "text on the page" tokens above must stay the light
   values: the call-centre-note icon (amber), a card's Clear button (rose hover) were dark-on-dark in the light theme (1.0-1.2:1, invisible). The flip card's back face is
   the one light surface inside such a card: it puts the dark values back (below). */
html[data-theme="light"] .text-white { --ph-bad: #fecdd3; --ph-warn: #fde68a; --ph-ok: #a7f3d0; --ph-info: #bae6fd; --ph-bad-hover: #fecdd3; }
.ph-flip-back { --ph-bad: #9f1239; --ph-warn: #92400e; --ph-ok: #065f46; --ph-info: #075985; --ph-bad-hover: #881337; }
/* Error toasts and banners (`bg-rose-950 text-rose-200`), the Synced / Modified badges (`bg-sky-950 text-sky-400`, `bg-amber-950 text-amber-400`), the amber / rose
   warning banners (`bg-amber-950/40`, `bg-rose-950/30`) and the Reboot / Remove / Retry buttons (`bg-rose-900/40`, `bg-amber-800/60`) are dark tints written for the
   dark page. On the light page a dark tint is either a near-black box under the dark text tokens above (1.8-2.1:1) or, when translucent, a muddy tan under it (~3:1),
   so each becomes the matching LIGHT tint and the dark tokens sit on it (6.4-6.7:1, measured by the "transient states" tests in readability_audit.spec.js). Borders
   stay as written: a dark red edge on a pale rose box reads well. */
html[data-theme="light"] :is([class*="bg-rose-950"], [class*="bg-rose-900"]) { background-color: #ffe4e6; }
html[data-theme="light"] :is([class*="bg-amber-950"], [class*="bg-amber-800"]) { background-color: #fef3c7; }
html[data-theme="light"] [class*="bg-sky-950"] { background-color: #e0f2fe; }
html[data-theme="light"] :is([class*="hover:bg-rose-900"], [class*="hover:bg-rose-950"]):hover { background-color: #fecdd3; }
html[data-theme="light"] [class*="hover:bg-amber-800"]:hover { background-color: #fde68a; }
/* The flip card's back face is a fixed light grey in both themes; the -600 shades the markup uses on it are 2.8-4.1:1 there. */
.ph-flip-back .text-emerald-600 { color: #047857; }
.ph-flip-back .text-amber-600 { color: #92400e; }
.ph-flip-back .text-rose-600 { color: #be123c; }
/* Coloured buttons and badges with WHITE text: Tailwind's -500/-600 fills give white text only 2.5-4.8:1 (emerald-600 3.8, amber-600 3.2, rose-500 3.7). Anything that
   carries both a palette fill and text-white is moved to the -800 shade (7+:1) and its hover to -900. Dots, bars and switches (no text-white) keep the bright fill. */
html :is([class*="bg-rose-500"], [class*="bg-rose-600"], [class*="bg-red-500"], [class*="bg-red-600"])[class*="text-white"] { background-color: #9f1239; }
html :is([class*="bg-rose-500"], [class*="bg-rose-600"], [class*="bg-red-500"], [class*="bg-red-600"])[class*="text-white"]:hover { background-color: #881337; }
html :is([class*="bg-emerald-500"], [class*="bg-emerald-600"], [class*="bg-emerald-700"], [class*="bg-green-600"])[class*="text-white"] { background-color: #065f46; }
html :is([class*="bg-emerald-500"], [class*="bg-emerald-600"], [class*="bg-emerald-700"], [class*="bg-green-600"])[class*="text-white"]:hover { background-color: #064e3b; }
html :is([class*="bg-amber-500"], [class*="bg-amber-600"], [class*="bg-yellow-600"], [class*="bg-orange-600"])[class*="text-white"] { background-color: #92400e; }
html :is([class*="bg-amber-500"], [class*="bg-amber-600"], [class*="bg-yellow-600"], [class*="bg-orange-600"])[class*="text-white"]:hover { background-color: #78350f; }
html :is([class*="bg-sky-500"], [class*="bg-sky-600"])[class*="text-white"] { background-color: #075985; }
html :is([class*="bg-sky-500"], [class*="bg-sky-600"])[class*="text-white"]:hover { background-color: #0c4a6e; }
html :is([class*="bg-purple-600"], [class*="bg-violet-700"])[class*="text-white"] { background-color: #6b21a8; }

/* Every metric/IP/timestamp already used font-mono as a Tailwind
   utility class before this file existed - this just makes that
   instinct a real, deliberate typeface instead of the browser's
   generic monospace fallback. */
.font-mono, code, pre {
    font-family: var(--ph-font-mono) !important;
}

/* A barely-there grid over the whole window, felt more than seen - quiet
   atmosphere nodding to a radar/scope display without competing with real data.
   Opacity is intentionally tiny; this is not a decoration to notice.
   `position: absolute`, NOT `fixed` (it used to be fixed): a fixed element is its
   own compositor layer (it must not scroll with the page), and then every piece of
   content painted after it that overlaps it - the whole page, every Status card's
   text - is pushed into layers ABOVE it whose background is transparent, and text
   in such a layer is drawn with greyscale instead of sub-pixel (ClearType)
   smoothing: the "blurry, like a TV setting" Status cards. Measured in Chromium:
   white-on-colour card text had no colour fringe (greyscale) with the fixed grid
   and ~55 % fringed edge pixels (sub-pixel smoothing) without it. base.html's
   <body> is h-[100dvh] overflow-hidden (the document never scrolls, each page
   scrolls inside itself), so an absolutely positioned box over the initial
   containing block covers exactly what the fixed one did. */
body::before {
    content: '';
    position: absolute;
    inset: 0;
    pointer-events: none;
    z-index: -1;
    background-image:
        linear-gradient(var(--ph-border) 1px, transparent 1px),
        linear-gradient(90deg, var(--ph-border) 1px, transparent 1px);
    background-size: 48px 48px;
    opacity: 0.05;
}
/* Sharp text on a high-DPI screen (Windows at 150 % / 175 % scaling, a browser zoomed to 150 %): from a device scale of 1.5 Chromium composites every scrolling
   box ("prefer compositing to LCD text"), and a composited scroller only draws sub-pixel text when its own background is opaque. <main> is each page's scroll
   box and has no background of its own, so everything inside it - every Status card - was drawn with greyscale smoothing there (measured at 1.5x: no colour
   fringe on the card text; with this rule ~50 %, the same as at 1x). The page colour behind it is the same one, so nothing looks different (the 5 % grid above
   sits behind the box and is hidden there - it is a few levels of grey, invisible on most screens). A Tailwind bg-* class on a <main> still wins (0,1,0 > 0,0,1). */
@media (min-resolution: 1.5dppx) {
    main { background-color: var(--ph-bg); }
}

/* A native range slider draws its unfilled track from the browser's own light/dark
   scheme; left alone it is bright white on this dark theme. */
input[type="range"] { color-scheme: dark; }
[data-theme="light"] input[type="range"] { color-scheme: light; }

/* Card flashing (Settings > Status Card Style > Card Flashing) - an
   opacity pulse, not a color/size change, so it stays legible mid-pulse
   and doesn't fight the status color itself. animation-duration is set
   per element via inline style (grid.js's cardFlashDuration()), driven
   by each tier's admin-chosen rate - this class only supplies the
   keyframes and defaults. */
@keyframes ph-card-flash {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.55; }
}
.ph-card-flash {
    animation-name: ph-card-flash;
    animation-duration: 1.2s;
    animation-iteration-count: infinite;
    animation-timing-function: ease-in-out;
}
@media (prefers-reduced-motion: reduce) {
    .ph-card-flash { animation: none; }
}

/* The Status CARD's flash (grid.js cardFlashClass, card_prefs.js previewCardFlashClass): the fill itself pulses between the state colour and the SAME colour mixed
   toward black - never toward white, never a fade. The old whole-card opacity pulse blended the card into the page and dropped the white text to ~2.7:1 on a Bad card
   in the light theme (measured); a darker fill only ever RAISES the contrast of white text, so every frame of the pulse is at least as readable as the card at rest.
   Same meaning and rates as before (the duration is set per card from the admin's rate: good never, warning slow, bad medium, critical fast). The animation exists only
   on a card that is flashing - a card at rest has no animation, no layer. --ph-card-fill is the card's own fill (set inline with it). */
@keyframes ph-card-flash-bg {
    50% { background-color: color-mix(in srgb, var(--ph-card-fill, #000) 60%, #000); }
}
@supports not (background-color: color-mix(in srgb, red 50%, blue)) {
    @keyframes ph-card-flash-bg { 50% { box-shadow: inset 0 0 0 999px rgba(0, 0, 0, .4); } }
}
.ph-card-flash-bg {
    animation-name: ph-card-flash-bg;
    animation-duration: 1.2s;
    animation-iteration-count: infinite;
    animation-timing-function: ease-in-out;
}
@media (prefers-reduced-motion: reduce) {
    .ph-card-flash-bg { animation: none; }
}

/* Flip-to-diagnostics Status cards (double-tap/double-click a solo card -
   see _host_grid.html's own comment on its card markup and grid.js's
   toggleFlip/_setFlipRotation for the full mechanic). A single rotating
   face: .ph-flip-inner is the ONLY element that transforms, and grid.js
   swaps which face is MOUNTED (x-if) at this transition's own halfway
   point, so the front and back are never both visible/mismatched at once.
   `transition`, not a second `animation` - deliberately doesn't collide
   with .ph-card-flash's own `animation` above (see that class's own
   comment on why `animation-name` doesn't merge across two classes on the
   same element; a `transition` has no such gotcha, it isn't a named
   keyframe animation). Needs `perspective` on the OUTER card div for this
   rotateY to read as a true 3D flip rather than a flat horizontal squash.

   THE 3D EXISTS ONLY WHILE A CARD IS FLIPPING OR FLIPPED (.ph-flip-3d, put on
   the card by grid.js's flip3dIds - from the moment a flip starts until its
   flip-back transition has finished). A card at rest is plain, flat HTML:
   no perspective, no preserve-3d, `transform: none`. This is not a style
   preference: Chromium makes every element that has a 3D transform (even
   rotateY(0deg)) or sits in a preserve-3d scene its OWN compositor layer
   (measured with the CDP LayerTree domain: Trivial3DTransform on the inner
   face, Transform3DSceneLeaf on the card's whole content, Overlap on the
   card itself - three layers per card, on every card, all day), and text in a
   layer with a transparent background is drawn with greyscale instead of
   sub-pixel (ClearType) smoothing and, at a fractional display scale such as
   Windows' 125 % / 150 %, can be rasterised soft - "the status cards look
   blurry, like a TV setting". Never put perspective / transform-style:
   preserve-3d / will-change / a transform on a resting card. */
.ph-flip-inner {
    transition: transform 480ms cubic-bezier(0.4, 0, 0.2, 1);
}
.ph-flip-3d {
    perspective: 1000px;
}
.ph-flip-3d .ph-flip-inner {
    transform-style: preserve-3d;
}
.ph-flip-inner-flipped {
    transform: rotateY(180deg);
}
/* The back face mounts pre-rotated 180deg (grid.js only mounts it once the
   PARENT .ph-flip-inner is already mid-turn, past 90deg/edge-on) - once
   the parent finishes its own 180deg turn, 180 + 180 = 360deg, i.e.
   right-side-up, not mirrored.

   A fixed whitish-gray, not the front's own saturated quality color - real
   feedback that the back reads clearer as a calm, neutral surface with its
   own real red/yellow/green meters drawn on top (grid.js's PH_FLIP_TIER_
   COLORS) than as another solid block of the same color the front already
   is. Deliberately theme-independent (a literal color, not a --ph-* token) -
   same reasoning as the front's own qualityCardStyle(): a wall display reads
   this consistently regardless of light/dark mode. */
.ph-flip-back {
    transform: rotateY(180deg);
    background-color: #eef1f4;
    color: #1e293b;
}
/* The "+N more" / "Show fewer" button under a card's Recent outage line: a finger gets a 44px hit area that takes no layout room (the line must not grow for it). */
@media (pointer: coarse) { .ph-flip-more { display: inline-flex; align-items: center; min-height: 44px; margin-block: -13px; } }
@media (prefers-reduced-motion: reduce) {
    .ph-flip-inner { transition: none; }
}
/* A flipped card's back face (graphs, diagnostics) is far taller than its front, and the grid stretches every card in a row to the row's height - so
   the flipped card's row mates used to grow with it, big empty cards beside the one being looked at. grid.js's _syncFlipRowMates marks the cards in a
   flipped card's row; they keep the height they have on their own (the flipped card alone is tall, and the space under its row mates is left empty on
   purpose). The same goes for a grouped site's card (its host rows, and a "Show N more" it was opened with, make it taller than a single host's card):
   the single cards beside it are held, only the grouped card changes size. Rows with neither are untouched: their cards still stretch to one height,
   so every footer lines up. */
#ph-solo-grid > .ph-flip-mate { align-self: start; height: var(--ph-mate-h, auto); }
/* --ph-mate-h (set by grid.js's _syncFlipRowMates) is the height the row HAD before the flip - the same for every mate, so the cards beside the flipped one
   stay level with each other (a card with a line fewer, such as a down host with no uptime, would otherwise sit shorter than its neighbours) and their footers
   still line up. A definite height, not a min-height, so each card's footer can still be pushed to its bottom edge. */
/* EVERY card is its own height (owner, 2026-10-16: a DNS host's extra "Ping: answers" line made its whole row taller and left a blank gap under the name of every card beside
   it - "each card should stay its own size"). The grid no longer stretches a row's cards to the tallest one, and the row-mate hold above is switched off (a card only ever
   keeps the height its own content needs, so there is nothing to hold). The cards of a row therefore have ragged bottoms when their content differs - on purpose. */
#ph-solo-grid { align-items: start; }
#ph-solo-grid > *, #ph-solo-grid > .ph-flip-mate { height: auto; align-self: start; }

/* Header bell's one-time "ring" - plays once right after notifications
   go from off to on (see base.html's phAlertBell()), a decaying swing
   like a real bell settling, not a symmetric wobble. transform-box:
   fill-box makes the percentage transform-origin pivot around the
   icon's own bounding box (its top edge, where a real bell would be
   mounted) rather than the SVG viewport's origin. */
@keyframes ph-bell-ring {
    0% { transform: rotate(0deg); }
    10% { transform: rotate(16deg); }
    20% { transform: rotate(-14deg); }
    30% { transform: rotate(11deg); }
    40% { transform: rotate(-8deg); }
    50% { transform: rotate(6deg); }
    60% { transform: rotate(-4deg); }
    70% { transform: rotate(2deg); }
    85% { transform: rotate(-1deg); }
    100% { transform: rotate(0deg); }
}
.ph-bell-ring {
    transform-box: fill-box;
    transform-origin: 50% 0%;
    animation: ph-bell-ring 0.8s ease-in-out 1;
}
@media (prefers-reduced-motion: reduce) {
    .ph-bell-ring { animation: none; }
}

/* A second, stronger attention-getter for small header icons - real
   feedback that ph-card-flash's own opacity dip alone (designed to
   stay subtle/legible across many status cards at once, see its own
   comment above) wasn't noticeable enough for a single small 18px
   icon meant to flag something needing action (the header's Notes/
   call-center bell). A gentle scale pulse, layered on top of
   ph-card-flash's own opacity dip - same 1.2s timing as
   ph-card-flash's default duration so the grow and the fade land in
   sync instead of drifting against each other.

   This class's own `animation` shorthand lists BOTH keyframes
   (ph-card-flash AND ph-icon-pulse) directly, rather than relying on
   applying two separate classes together - `animation-name` (like any
   other CSS property) doesn't merge across classes of equal
   specificity, the later-defined rule simply wins outright, so
   `class="ph-card-flash ph-icon-pulse"` together would silently drop
   the flash and run the pulse alone (confirmed live via
   getComputedStyle().animationName before landing on this fix - it
   reported only "ph-icon-pulse", not both). Apply ONLY this one class
   (not ph-card-flash alongside it) wherever both effects are wanted. */
@keyframes ph-icon-pulse {
    0%, 100% { transform: scale(1); }
    50% { transform: scale(1.22); }
}
.ph-icon-pulse {
    animation: ph-card-flash 1.2s ease-in-out infinite, ph-icon-pulse 1.2s ease-in-out infinite;
    transform-origin: center;
}
@media (prefers-reduced-motion: reduce) {
    .ph-icon-pulse { animation: none; }
}

/* Status grid's empty-filtered-view "still checking" cue (real gap
   found live: a bare static message for a deliberately narrowed filter
   - e.g. "Issues only" with nothing currently wrong - read as broken/
   frozen, not as the good news it actually was reporting). A calm dot
   with two staggered expanding/fading rings - deliberately gentle, NOT
   the sharper ph-outage-ring pop above used for a real grid-outage
   marker - this is ambient reassurance ("the system is still actively
   checking"), never implying anything about real data one way or the
   other, same indeterminate-animation reasoning ph-gauge-sweep's own
   comment already states for its own "still working" spinner. Color
   comes from the element's own `color` (currentColor) - grid.js's own
   template sets it to the real "good" quality color inline, matching
   PH_QUALITY_COLORS rather than a generic Tailwind shade. */
@keyframes ph-empty-pulse-ring {
    0% { transform: scale(0.6); opacity: 0.35; }
    100% { transform: scale(1.6); opacity: 0; }
}
.ph-empty-pulse::before,
.ph-empty-pulse::after {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: 9999px;
    background: currentColor;
    opacity: 0.22;
    animation: ph-empty-pulse-ring 2.2s ease-out infinite;
}
.ph-empty-pulse::after { animation-delay: 1.1s; }
@media (prefers-reduced-motion: reduce) {
    .ph-empty-pulse::before, .ph-empty-pulse::after { animation: none; display: none; }
}

/* The Status grid's empty view ("Focused on issues only - no issues right now.", "No hosts match.", the TV's version): centred - across and down the room the
   page has left - with the pulse and the message sized by the window, so it reads from a phone to a wall display and in a cascaded window alike. Everything is a
   clamp() of the viewport (rem for the text, so the browser's text size scales it too); the block fills what is left of the window below the header (100dvh
   less 15rem - the header, the controls and the page padding; vh before dvh), so "centred down" means the middle of the screen, never so tall it makes the page
   scroll; no per-size rules. The pulse's rings grow to 1.6x, so the gap leaves them room. */
.ph-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: clamp(1rem, .6rem + 1.6vw, 2rem); text-align: center;
    min-height: clamp(14rem, calc(100vh - 15rem), 48rem); min-height: clamp(14rem, calc(100dvh - 15rem), 48rem); padding: clamp(1.5rem, 4vw, 3rem) clamp(1rem, 4vw, 2rem); }
/* The phone's 24h strip (~220px) sits above the grid, so when it is shown (the page's own copy, not hidden by x-show) the block leaves room for it as well - without
   that, every phone scrolled just to see the message. */
main:has([data-testid="quick-strip"]:not([style*="display: none"])) .ph-empty:not(.ph-empty-tv) { min-height: clamp(12rem, calc(100vh - 30rem), 48rem); min-height: clamp(12rem, calc(100dvh - 30rem), 48rem); }
.ph-empty-pulse { width: clamp(4.5rem, 3rem + 6vw, 8rem); aspect-ratio: 1; flex: none; margin-bottom: .5rem; }
.ph-empty-dot { width: 28%; height: 28%; }
.ph-empty-msg { max-width: min(38rem, 100%); font-size: clamp(1.1875rem, 1rem + 1vw, 1.875rem); line-height: 1.35; font-weight: 600; color: var(--ph-text-secondary); text-wrap: balance; }
/* A host's notes show as the same short "Note" tag on the Status card, styled EXACTLY like the DNS tag (grid.js cardBadges: `bg-black/25 text-white`, a plain quiet wash that
   blends into the card). Owner, 2026-10-16: no colour difference between the card note and the call-center note - the hover preview and the menu already say which one it is. */
.ph-note-on { background: #4338ca; color: #fff; border-radius: .375rem; box-shadow: 0 0 0 1px rgba(255, 255, 255, .85); }
.ph-note-on:hover, .ph-card-ico.ph-note-on:hover, .ph-card-more.ph-note-on:hover, .ph-tap-inline.ph-note-on:hover { background: #4f46e5; color: #fff; }       /* (the two-class forms beat Tailwind's hover:bg-white/15) */
.ph-tap-inline.ph-note-on { padding: 2px; }
/* The Status card's LOSS / LATENCY / JITTER labels: the sans face (Atkinson Hyperlegible Next), same 14px - its capitals are narrower than the mono's fixed width,
   so the row with "Show jitter" on fits a ~295px card on one line (measured: 290 -> 262px with typical numbers). The numbers stay mono (tabular). */
.ph-stat-lbl { font-family: var(--ph-font-sans); }

/* ---- The Status card's front face: one fixed row template (2026-10-15) --------------------------------------------------------------------------------------------
   The owner's screenshot: a Bad card whose "LATENCY 201ms [arrow] JITTER 41.0ms" row wrapped, grew the card and pushed every row mate's rows out of line. Now:
   * the name takes the slack (flex: 1) and everything under it is a row of one fixed line, so in a grid row the name starts at the same top and every row under it
     (address, group, status, LOSS / LATENCY / JITTER, the actions) sits at the same offset from the card's bottom - whatever the card's name or badges are;
   * each metric is a fixed SLOT (label + number + a reserved 16px for the trend arrow, tabular numerals, never wrapping); when the row cannot fit the worst numbers
     (100% / 1234ms / 41.0ms) in the card's width the LABELS get shorter, then the label goes above the number - never a wrap, never smaller type (container queries on the card);
   * the card is a size container (container-type) - that is what lets the row ask how wide ITS card is, even in a scaled / Cards-per-row grid. A container gets layout
     containment (no compositor layer); tests_e2e/card_crispness.spec.js still holds a resting card to zero layers. */
.ph-solo-card { container: phcard / inline-size; }
.ph-card-name { flex: 1 1 auto; min-width: 0; }
.ph-solo-card .ph-face:not([data-box="spacious"]) { row-gap: 2px; }                                                                   /* (the lines already have 1.3 line-height: 2px between them, not 4) */
.ph-addr-row { display: flex; flex-wrap: nowrap; align-items: center; gap: 0 .5rem; min-width: 0; height: 1.1375rem; }                           /* 18.2px = one 14px line at 1.3 */
.ph-addr-row > .ph-addr { flex: 0 1 auto; min-width: 5ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ph-badges { display: flex; flex: 0 1 auto; align-items: center; gap: .25rem; min-width: 0; overflow: hidden; padding: 1px; margin: -1px; }       /* (1px so the Note chip's white edge is not cut) */
.ph-badges .ph-chip { line-height: 1; white-space: nowrap; flex: none; }                                                                     /* a chip is 17px: never taller than the address line */
.ph-chip:is(a) { text-decoration: none; }
.ph-chip:is(a):hover, .ph-chip:is(a):focus-visible { text-decoration: underline; }
.ph-stats { display: flex; flex-wrap: nowrap; justify-content: space-between; align-items: baseline; column-gap: .5rem; padding-top: 0; border-top: 1px solid rgba(255, 255, 255, .3);
    white-space: nowrap; font-variant-numeric: tabular-nums; }
.ph-stat { display: inline-flex; flex: none; align-items: baseline; min-width: 0; }
.ph-stat > .ph-stat-lbl { margin-right: .3em; }
.ph-stat-val { display: inline-flex; align-items: baseline; }
.ph-stat-num { display: inline-block; text-align: right; font-weight: 700; }
.ph-stat[data-metric="loss"] .ph-stat-num { min-width: 4ch; }                                                                     /* 100% */
.ph-stat[data-metric="latency"] .ph-stat-num { min-width: 6ch; }                                                                  /* 1235ms */
.ph-stat[data-metric="jitter"] .ph-stat-num { min-width: 6ch; }                                                                   /* 41.0ms */
.ph-trend-slot { display: inline-flex; flex: none; width: 16px; align-items: center; justify-content: flex-end; }                  /* the arrow's room is ALWAYS taken, arrow or not */
.ph-lbl-short { display: none; }
/* Too narrow for the worst numbers on one line with the full labels (measured, tests_e2e/status_card_polish.spec.js sweeps 250-520px cards): with jitter on, the short labels
   (LAT / JIT) from 392px down; from 322px down (jitter on) or 262px down (jitter off) the label sits above the number instead. */
@container phcard (max-width: 391px) {
    .ph-stats[data-jitter="1"] .ph-lbl-full { display: none; }
    .ph-stats[data-jitter="1"] .ph-lbl-short { display: inline; }
}
@container phcard (max-width: 321px) {
    .ph-stats[data-jitter="1"] .ph-stat { flex-direction: column; align-items: flex-start; }
    .ph-stats[data-jitter="1"] .ph-stat > .ph-stat-lbl { margin-right: 0; font-size: .8125rem; line-height: 1.2; }
    .ph-stats[data-jitter="1"] .ph-lbl-full { display: inline; }
    .ph-stats[data-jitter="1"] .ph-lbl-short { display: none; }
}
@container phcard (max-width: 261px) {
    .ph-stats[data-jitter="0"] .ph-stat { flex-direction: column; align-items: flex-start; }
    .ph-stats[data-jitter="0"] .ph-stat > .ph-stat-lbl { margin-right: 0; font-size: .8125rem; line-height: 1.2; }
}

/* The card's "..." button (2026-10-15, owner: "too bulky - move it to the top right"): the card has NO button row. The one control is this "..." at the top right of the name row;
   Pause / Resume, Notes and every other action are rows of the menu it opens (grid.js cardActions). 28px with a mouse, 44px on a finger - the negative margins pull the
   button into the card's padding so it never makes the name row taller than the name itself. The dots sit BARE on the card's own fill (owner: the pill behind them looked off;
   the full 28 / 44px box is still the tap area): a darker wash only while the mouse is over it, while it is pressed, or while its menu is open - never left behind by a
   finger (hover is gated to devices that really hover). Shown on every card that has something in the menu (hasCardControls). */
.ph-card-head { display: flex; flex: 1 1 auto; align-items: flex-start; gap: .375rem; min-width: 0; min-height: 1.3em; }
.ph-card-head > .ph-card-name { flex: 1 1 0; min-height: 0; }
.ph-grid-root .ph-card-more.ph-card-more-solo { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 28px; height: 28px; margin: -4px -4px -4px 0; padding: 0;
    border-radius: .375rem; background: transparent; color: #fff; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.ph-grid-root .ph-card-more.ph-card-more-solo:active, .ph-grid-root .ph-card-more.ph-card-more-solo[aria-expanded="true"] { background: rgba(0, 0, 0, .24); }
@media (hover: hover) { .ph-grid-root .ph-card-more.ph-card-more-solo:hover { background: rgba(0, 0, 0, .24); } }
.ph-grid-root .ph-card-more.ph-card-more-solo:focus-visible { outline: 2px solid #fff; outline-offset: 1px; }
@media (pointer: coarse) {
    .ph-grid-root .ph-card-more.ph-card-more-solo { width: 44px; height: 44px; margin: -12px -8px -12px 0; }
}
/* The More menu: a popover beside the "..." button on a mouse, the bottom sheet on a finger (one set of rows, grid.js cardActions.mode). The sheet slides up (a CSS
   animation, so it replays each time the panel is shown); a popover just appears. */
@keyframes ph-sheet-in { from { transform: translateY(100%); } to { transform: translateY(0); } }
.ph-sheet-in { animation: ph-sheet-in .2s ease-out; }
@media (prefers-reduced-motion: reduce) { .ph-sheet-in { animation: none; } }
/* The grid root is `space-y-4`, which gives EVERY child (a position: fixed one too) margin-top: 1rem. The menu's full-window overlay (and the confirm dialog, the note preview) therefore started
   16px down: an undimmed 16px strip above the phone sheet's scrim, and the mouse popover opening 16px below its "..." button. A fixed layer has no business in the vertical rhythm. */
.ph-grid-root > .fixed { margin-top: 0 !important; }
.ph-actions-pop { position: absolute; width: min(19rem, calc(100vw - 16px)); max-height: calc(100vh - 16px); max-height: calc(100dvh - 16px); overflow-x: hidden; overflow-y: auto;
    overscroll-behavior: contain; border-radius: .5rem; background: var(--ph-surface); border: 1px solid var(--ph-border); box-shadow: 0 10px 30px rgba(0, 0, 0, .45); padding: .25rem; }
.ph-act-row { display: flex; align-items: center; gap: .75rem; width: 100%; min-height: 40px; padding: .375rem .625rem; border-radius: .375rem; text-align: left; font-size: .9375rem;
    color: var(--ph-text); cursor: pointer; }
.ph-act-row:hover, .ph-act-row:focus-visible { background: var(--ph-surface-hover); }
.ph-act-row:focus-visible { outline: 2px solid var(--ph-accent-text); outline-offset: -2px; }
.ph-act-row > svg { width: 1.25rem; height: 1.25rem; flex: none; color: var(--ph-text-muted); }
@media (pointer: coarse) { .ph-act-row { min-height: 48px; } }
/* A filled chip that is not a Tailwind palette colour: the DFS (radar) hit - white on a dark teal, 5.5:1. */
.ph-chip-dfs { background: #0f766e; color: #fff; }

/* "Cards per row" (the Displays menu on an ordinary desktop window, templates/_header.html): segmented choices - a filled accent (white text) for the one in use, outlined for the rest;
   32px tall with a mouse, a 44px finger target on touch; 13px text. */
.ph-cols-btn { min-height: 32px; min-width: 36px; padding-inline: .625rem; border-radius: .375rem; border: 1px solid var(--ph-border-input); color: var(--ph-text-secondary); font-size: .8125rem; font-weight: 600; cursor: pointer; }
.ph-cols-btn:hover:not(:disabled) { color: var(--ph-text); }
.ph-cols-btn:disabled { color: var(--ph-text-faint); border-color: var(--ph-border); cursor: not-allowed; }
.ph-cols-btn.ph-cols-on:disabled { color: #fff; opacity: .7; }
.ph-cols-btn.ph-cols-on, .ph-cols-btn.ph-cols-on:hover:not(:disabled) { background: var(--ph-accent); border-color: transparent; color: #fff; }
@media (pointer: coarse) { .ph-cols-btn { min-height: 44px; min-width: 44px; } }

/* The outages and high-latency dialogs' card (templates/_header.html): never taller than the window (vh first, dvh where the browser has it); the sheet clears a phone's home indicator. */
.ph-list-panel { max-height: min(88vh, 48rem); max-height: min(88dvh, 48rem); padding-bottom: env(safe-area-inset-bottom, 0px); }
/* A device's name is a link, and the link covers its whole row: a finger needs no 44px box on the name itself, a keyboard gets a ring around the row. */
.ph-row-link::after { content: ''; position: absolute; inset: 0; border-radius: .75rem; }
.ph-row-link:focus-visible { outline: none; }
.ph-row-link:focus-visible::after { outline: 2px solid var(--ph-accent-text); outline-offset: -2px; }
/* A TV across the room: the same, much larger. */
.ph-empty-tv { min-height: clamp(20rem, calc(100vh - 8rem), 80rem); min-height: clamp(20rem, calc(100dvh - 8rem), 80rem); }
.ph-empty-tv .ph-empty-pulse { width: clamp(7rem, 12vw, 16rem); }
.ph-empty-tv .ph-empty-msg { max-width: min(60rem, 100%); font-size: clamp(2rem, 1.2rem + 2.2vw, 4rem); }

/* Bandwidth-test "running" gauge (Customer Diagnostic + Devices >
   Routers modal's own Run Bandwidth Test buttons) - a needle
   continuously sweeping the full arc, not settling on any one
   position. The real MikroTik REST bandwidth-test call is a single
   blocking request that only returns a result once the whole test
   finishes (see services_mikrotik.py's own docstring) - there's no
   live incremental throughput to actually plot mid-test, so this is
   deliberately an INDETERMINATE "still working" animation, not a
   fabricated live reading. transform-origin is set inline per SVG
   (needs the gauge's own pivot coordinates, not a fixed CSS value). */
@keyframes ph-gauge-sweep {
    0%, 100% { transform: rotate(-62deg); }
    50% { transform: rotate(62deg); }
}
.ph-gauge-needle {
    animation: ph-gauge-sweep 1.6s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
    .ph-gauge-needle { animation: none; transform: rotate(0deg); }
}

/* Icon-rail hover label (admin.html/alerts.html's left sidebar) - one
   shared class instead of repeating this same long declaration on
   every one of the rail's ~11 icon buttons. Plain CSS, not a Tailwind
   group-hover utility, since the transform needs its own translateY
   component (vertical centering) composed with the hover-triggered
   scale, which utility classes can't express as one property. Pairs
   with each button's own `group` marker class (Tailwind's, just used
   here as a plain selector target) and :focus-visible so keyboard
   users get the same label a mouse hover would. */
.ph-rail-tooltip {
    /* fixed, not absolute: the rail scrolls when the window is short, and a scrolling box clips anything that sticks out of it sideways (overflow-y: auto
       also makes overflow-x clip - there is no way to keep one visible). A fixed label is never clipped by it; static/rail.js sets --ph-tip-y (the
       item's own vertical centre) when the item is hovered or focused. */
    position: fixed;
    left: 3.5rem;
    top: var(--ph-tip-y, 0px);
    margin-left: 0.25rem;
    padding: 0.375rem 0.625rem;
    border-radius: 0.375rem;
    background: var(--ph-surface-strong);
    border: 1px solid var(--ph-border-input);
    color: var(--ph-text);
    font-size: 0.875rem;
    font-weight: 500;
    white-space: nowrap;
    opacity: 0;
    pointer-events: none;
    transform: translateY(-50%) scale(0.95);
    transition: opacity 120ms ease, transform 120ms ease;
    transition-delay: 0ms;
    z-index: 60;
    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
}
.group:hover .ph-rail-tooltip,
.group:focus-visible .ph-rail-tooltip {
    opacity: 1;
    transform: translateY(-50%) scale(1);
    transition-delay: 300ms;
}
@media (prefers-reduced-motion: reduce) {
    .ph-rail-tooltip { transition: opacity 120ms ease; transform: translateY(-50%); }
}

/* Hides the native up/down spin buttons on a <input type="number"> -
   for a field like Latitude/Longitude where clicking +/-1 one step at a
   time is never a real workflow (real feedback: the arrows "seemed
   wrong" there). Firefox honors -moz-appearance: textfield on the input
   itself; Chrome/Safari need the ::-webkit-*-spin-button pseudo-elements
   hidden instead - textfield alone does nothing in WebKit. */
.ph-no-spinner {
    -moz-appearance: textfield;
}
.ph-no-spinner::-webkit-outer-spin-button,
.ph-no-spinner::-webkit-inner-spin-button {
    -webkit-appearance: none;
    margin: 0;
}

/*
 * Mobile edit sheet (map.js's _presentEditor(), templates/_map.html) -
 * every button/input inside here is raw HTML the map's own right-click
 * editors already build for a DESKTOP Leaflet popup (small, mouse-
 * sized: ~24-26px tall buttons, 11-12px text - fine with a cursor,
 * genuinely too small for a fingertip). Scoped to just this one
 * container rather than resizing every inline style at its source -
 * those same strings are still used for the real desktop popup too,
 * where the original compact sizing is correct and shouldn't change.
 * !important is deliberate here, not a shortcut - every element inside
 * has its own inline style (which normally beats a stylesheet rule
 * outright), and this rule's entire job is overriding exactly that.
 */
.ph-edit-sheet-content button {
    min-height: 40px !important;
    padding-top: 10px !important;
    padding-bottom: 10px !important;
    font-size: 15px !important;
}
.ph-edit-sheet-content input,
.ph-edit-sheet-content select {
    min-height: 40px !important;
    font-size: 16px !important;
}
.ph-edit-sheet-content [data-ph-ap-option],
.ph-edit-sheet-content [data-ph-site-option] {
    min-height: 40px !important;
    padding-top: 10px !important;
    padding-bottom: 10px !important;
    font-size: 15px !important;
    display: flex !important;
    align-items: center !important;
}

/* Real report: zooming out fast shows visible blank gaps ("small box
 * then the whole map") wherever a tile hasn't finished loading yet -
 * live-compared against a plain google.com/maps tab on the same
 * network and confirmed it shows the exact same gaps on a fast zoom
 * (see map.js's own _TILE_KEEP_BUFFER comment) - the fetch itself
 * isn't something this app can speed up. This IS something it can fix:
 * Leaflet's own default background for an unloaded tile is a flat
 * #ddd, which reads as a hole in the map against every basemap this
 * app actually uses (Google roadmap, OSM, OpenTopoMap - all light,
 * green/tan/blue-toned). A muted, land-toned fallback here means a
 * loading gap looks like "the same map, still sharpening up" instead
 * of "something's broken" - not theme-aware on purpose (this approximates
 * the BASEMAP's own light tone, unrelated to the app's own dark UI
 * chrome around it - a dark fallback here would look like a hole too,
 * just a differently-colored one). Mapbox's dark-v10 style (the one
 * genuinely dark basemap this app supports) is the one case this
 * undershoots slightly - a deliberate tradeoff since it's the least-used
 * provider of the four and its own CDN tiles load quickly regardless.
 *
 * !important is deliberate again here (see this file's own comment
 * above on .ph-edit-sheet-content for the general reasoning) -
 * leaflet.css's own `.leaflet-container { background: #ddd; }` loads
 * AFTER this stylesheet in admin.html (base.html's theme.css link vs.
 * admin.html's own <head> block), so a plain same-specificity rule
 * here loses the cascade tie and never actually applies.
 *
 * isolation: isolate is the real fix for a separate bug - every
 * Leaflet map's own internal panes/controls carry z-index values up
 * to 1000 (leaflet.css), and .leaflet-container's plain
 * `position: relative` (no z-index) does NOT contain those - a
 * stacking context only forms when z-index is also set, so with it
 * left at `auto` those high z-indexes compare directly against
 * unrelated page chrome in the root stacking context, like the
 * admin mobile nav sheet's own z-50 (admin.html) - real report: on
 * Customer Diagnostic's radar map, opening that sheet on mobile
 * showed the map tiles rendering ON TOP of it. isolation: isolate
 * forces a new stacking context here without needing an explicit
 * z-index number, containing every one of Leaflet's internal
 * z-indexes inside the map itself - fixes it for every Leaflet map
 * on the site (this one, the main Map tab, DFS Center), not just
 * the one that surfaced it. */
.leaflet-container {
    background: #d9dfd1 !important;
    isolation: isolate;
}

/* A lightning strike that has just been reported flashes where it landed (static/map.js _flashNewStrikes): a flare, a dip, a second
   flare, then it fades - the flicker of a real stroke. Lives on its own layer, so it shows even when the strike is inside a cluster. */
.ph-strike-flash { pointer-events: none; }
.ph-strike-flash span { display: block; width: 88px; height: 88px; border-radius: 9999px; background: radial-gradient(circle, rgba(255,255,255,1) 0%, rgba(255,255,255,1) 10%, rgba(253,224,71,1) 24%, rgba(253,224,71,.55) 46%, rgba(253,224,71,.18) 64%, rgba(253,224,71,0) 78%); box-shadow: 0 0 22px 8px rgba(253,224,71,.55); animation: ph-strike-flare 1.4s ease-out forwards; }
@keyframes ph-strike-flare {
    0% { transform: scale(.15); opacity: 0; }
    8% { transform: scale(.7); opacity: 1; }
    16% { transform: scale(.45); opacity: .55; }
    24% { transform: scale(.95); opacity: 1; }
    100% { transform: scale(1.5); opacity: 0; }
}
/* A grid outage that has just appeared while the layer is being watched (static/map.js _syncGridOutages): two rings spread out from it and
   the marker bounces. Animated on the pseudo-elements and the inner <svg> - never on the marker's own box, which Leaflet positions. */
.ph-grid-outage-marker.ph-outage-pop::before,
.ph-grid-outage-marker.ph-outage-pop::after { content: ''; position: absolute; left: 50%; top: 50%; width: 15px; height: 15px; margin: -7.5px 0 0 -7.5px; border-radius: 9999px; border: 3px solid #f97316; pointer-events: none; animation: ph-outage-ring 1.3s ease-out 2; }
.ph-grid-outage-marker.ph-outage-pop::after { border-color: #fdba74; animation-delay: .3s; }
.ph-grid-outage-marker.ph-outage-pop svg { animation: ph-outage-bounce .5s ease-out 3; }
@keyframes ph-outage-ring { 0% { transform: scale(1); opacity: .95; } 100% { transform: scale(5); opacity: 0; } }
@keyframes ph-outage-bounce { 0%, 100% { transform: scale(1); } 40% { transform: scale(1.7); } }
/* The loading bar along the bottom edge of a "loading..." chip (the Map's grid outages): a segment sweeps across for as long as the chip shows.
   It is never a percentage - nothing reports how far along the request is. The chip must be positioned (it is absolute). */
/* Not shown at all unless it is still needed after a moment: a quick answer should not flash the message up and away. */
.ph-show-after-delay { animation: ph-show-after-delay .01s linear .4s backwards; }
@keyframes ph-show-after-delay { from { opacity: 0; } to { opacity: 1; } }
.ph-loading-bar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; overflow: hidden; background: color-mix(in srgb, var(--ph-accent) 22%, transparent); }
.ph-loading-bar::after { content: ''; position: absolute; top: 0; bottom: 0; left: 0; width: 40%; background: var(--ph-accent); border-radius: 9999px; animation: ph-loading-sweep 1.3s ease-in-out infinite; }
@keyframes ph-loading-sweep { 0% { transform: translateX(-100%); } 100% { transform: translateX(250%); } }
@media (prefers-reduced-motion: reduce) {
    .ph-loading-bar::after { animation: none; width: 100%; opacity: .55; }
    .ph-strike-flash span { animation-duration: .01s; }
    .ph-grid-outage-marker.ph-outage-pop::before, .ph-grid-outage-marker.ph-outage-pop::after, .ph-grid-outage-marker.ph-outage-pop svg { animation-duration: .01s; }
}

/* ---- The phone menu: a flat line + a bottom sheet (static/phone_sheet.js; templates/admin.html and alerts.html, below lg) ----
   The handle is a 200x44 touch target around a 72x6 line, kept ABOVE the phone's own home / app-switch gesture strip (env(safe-area-inset-bottom) + 8px), and
   touch-action:none so the browser never starts a scroll or pull-to-refresh from a touch that began on it. The layer is always in the page (not x-show) so the
   sheet can follow a finger while it is still "closed": CSS shows it for [data-open] (settled open) or [data-dragging] (a finger is moving it); the script moves
   the panel with an inline transform during a drag, and the transitions below take over the moment it is let go. */
.ph-sheet-handle {
    /* z-index 15: above the page's own content, but BELOW the header's dropdown menus (z-[48], see the z-index scale at the top of templates/_header.html) - at 40
       the handle painted over the bottom of an open menu on a phone (the filter menu's last entry, "Pair a TV", could not be tapped) - and far below the sheets and
       dialogs (z-50). */
    position: fixed; left: 50%; bottom: calc(env(safe-area-inset-bottom, 0px) + 8px); transform: translateX(-50%); z-index: 15;
    width: 200px; height: 44px; padding: 0; border: 0; background: transparent; display: flex; align-items: center; justify-content: center;
    cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent;
}
.ph-sheet-handle:active { cursor: grabbing; }
/* The line is the accent colour with a white edge: it has to read on the dark page AND on the light status cards that scroll underneath it, which a
   plain grey line (the same grey as a card) does not. */
.ph-sheet-grip-line {
    display: block; width: 72px; height: 6px; border-radius: 6px; background: var(--ph-accent); opacity: .95;
    box-shadow: 0 0 0 2px rgba(255, 255, 255, .92), 0 2px 10px rgba(0, 0, 0, .4); transition: width .15s, opacity .15s;
}
.ph-sheet-handle:hover .ph-sheet-grip-line, .ph-sheet-handle:focus-visible .ph-sheet-grip-line { width: 84px; opacity: 1; }
.ph-sheet-handle:focus-visible { outline: none; }
.ph-sheet-handle:focus-visible .ph-sheet-grip-line { box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--ph-accent); }

.ph-sheet { position: fixed; inset: 0; z-index: 50; visibility: hidden; pointer-events: none; transition: visibility 0s linear .25s; }
.ph-sheet[data-open], .ph-sheet[data-dragging] { visibility: visible; transition: none; }
.ph-sheet[data-open] { pointer-events: auto; }
/* A drag that started on the (closed) handle must not put a full-screen layer under the finger: that would steal the tap's pointerup. */
.ph-sheet[data-dragging]:not([data-open]) { pointer-events: none; }
.ph-sheet-scrim { position: absolute; inset: 0; background: rgba(0, 0, 0, .5); opacity: 0; transition: opacity .2s ease; }
.ph-sheet[data-open] .ph-sheet-scrim { opacity: 1; }
.ph-sheet[data-dragging] .ph-sheet-scrim { transition: none; }
.ph-sheet-panel { transform: translateY(102%); transition: transform .25s cubic-bezier(.2, .8, .2, 1); overscroll-behavior: contain; touch-action: pan-y; }
.ph-sheet[data-open] .ph-sheet-panel { transform: none; }
.ph-sheet[data-dragging] .ph-sheet-panel { transition: none; }
/* The menu sheet is as tall as its content up to a cap, and the cap is the markup's `max-h-[70dvh]`. With the readability type scale the twelve sections (two columns
   on a phone: six 44px rows) + the header + Sign Out need ~475px, which a 667px phone's 70% (467px) no longer holds - the list scrolled by a few pixels - and a 568px
   phone's (398px) by far more. Up to 88% of the screen on any screen that is not a landscape phone (that one keeps its own 92-94% cap, `max-height: 500px` variant),
   so all twelve sections AND Sign Out show at once on every portrait phone down to 320x568, with no entry smaller than 44px. A taller screen is unaffected: the sheet
   still ends where its content does. (Two classes, so this beats the one-class Tailwind utility whatever order the CDN injects its stylesheet in; the vh line is the
   fallback for a browser without dvh.) */
@media (min-height: 501px) {
    .ph-sheet > .ph-sheet-panel { max-height: 88vh; max-height: 88dvh; }
}
/* A desktop window narrower than 1024px (cascaded, tiled or snapped windows) used to lose the side menu and get only the phone's thin handle at the
   bottom. A device with a real pointer (a mouse or trackpad: hover + fine) keeps the side rail from 700px up - its icons are labelled by hover tooltips,
   which only such a device has. A touch screen keeps the 1024px rule (a tablet cannot hover, so the rail's icons would be unlabelled there - the original
   reason for the width rule). The classes: .ph-rail the side menu, .ph-rail-shell the row it sits in, .ph-rail-hide the phone's handle + sheet,
   .ph-rail-main the page's scroll area (it had room reserved at the bottom for the handle). admin.html and alerts.html share them; phone_sheet.js closes an
   open sheet when this query starts to match. */
/* The side menu has 13 entries (a navigation rail is meant for 3-7; Material Design says to expand it or use a drawer beyond that), which needs about 550px.
   A laptop browser window is often shorter than that, and the last entries (Settings) were cut off. Two things: (1) it scrolls - the scrollbar hidden, a soft shadow
   at the top / bottom edge saying there is more that way (plain CSS "scroll shadows": the cover layers are `local`, so they hide the shadow at the very ends);
   (2) with a mouse it gets more compact on a short window, so most windows need no scrolling at all. Touch screens keep their finger-sized buttons and just scroll. */
nav.ph-rail {
    overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: none;
    background-color: var(--ph-surface);
    background-image:
        linear-gradient(var(--ph-surface) 30%, transparent), linear-gradient(transparent, var(--ph-surface) 70%),
        radial-gradient(farthest-side at 50% 0, rgba(0, 0, 0, .3), transparent), radial-gradient(farthest-side at 50% 100%, rgba(0, 0, 0, .3), transparent);
    background-repeat: no-repeat;
    background-position: center top, center bottom, center top, center bottom;
    background-size: 100% 36px, 100% 36px, 100% 12px, 100% 12px;
    background-attachment: local, local, scroll, scroll;
}
nav.ph-rail::-webkit-scrollbar { display: none; }
@media (max-height: 760px) and (pointer: fine) {
    nav.ph-rail { gap: 2px; padding-block: .5rem; }
    nav.ph-rail > :is(a, button) { width: 2.25rem; height: 2.25rem; }
}
@media (max-height: 520px) and (pointer: fine) {
    nav.ph-rail { gap: 0; padding-block: .25rem; }
    nav.ph-rail > :is(a, button) { width: 2rem; height: 2rem; }
}
/* A touch screen wide enough for the rail (an iPad held sideways): the rail's 40px icons become 44px (the rail itself is 56px wide). */
@media (pointer: coarse) {
    nav.ph-rail > :is(a, button) { width: 2.75rem; height: 2.75rem; }
}
@media (min-width: 700px) and (max-width: 1023.98px) and (hover: hover) and (pointer: fine) {
    nav.ph-rail { display: flex; }
    div.ph-rail-shell { flex-direction: row; }
    div.ph-rail-hide { display: none; }
    main.ph-rail-main { padding-bottom: 1.5rem; }
}
/* The phone's handle (.ph-sheet-handle: 44px tall, 8px above the screen's bottom edge, in the middle) is drawn over the bottom ~52px of every page's scroll
   box. The box's padding-bottom lets the LAST content scroll clear of it, but a control that something else scrolls to - a keyboard Tab, an in-page link,
   scrollIntoView, a test's click - stopped at the box's bottom edge, right under the handle (a full-width button's centre was covered: tests_e2e/
   responsive_tools.spec.js, DFS Center's TDWR button at 844x390). scroll-padding keeps such a control above the handle. */
@media (max-width: 1023.98px) {
    main.ph-rail-main { scroll-padding-bottom: 4.5rem; }
}
/* A page's scroll box (<main>) is always as wide as the page area, so its scrollbar sits at the window's right edge; a narrow page caps its
   CONTENT instead (admin.html's Sites / Hosts / Users / Agents tabs, alerts.html, /customer-diagnostic, both /public pages, the agent's local
   status page). A capped <main> (max-w-7xl / -5xl / -4xl / -3xl) put the scrollbar at the edge of a centred column, mid-window on a wide screen -
   "the Hosts page has its own scroll" (reported again 2026-10-08). The children come out exactly as they were inside the capped <main>: the old
   cap minus main's own 1rem padding each side, centred - 7xl 80rem -> 78rem (the default), 5xl 64rem -> 62rem, 4xl 56rem -> 54rem, 3xl 48rem ->
   46rem. Every page using these has p-4 on its <main>; a page with other side padding needs its own numbers. width: 100% matters - in a flex
   column an auto-margin child would otherwise shrink to its content. A fixed / absolute child (an overlay put straight into <main> one day) is
   left alone. tests_e2e/admin_tab_scroll_edge.spec.js measures every page that uses this. */
main.ph-main-capped > :not(.fixed, .absolute) { width: 100%; max-width: 78rem; margin-inline: auto; }
main.ph-main-capped.ph-main-cap-5xl > :not(.fixed, .absolute) { max-width: 62rem; }
main.ph-main-capped.ph-main-cap-4xl > :not(.fixed, .absolute) { max-width: 54rem; }
main.ph-main-capped.ph-main-cap-3xl > :not(.fixed, .absolute) { max-width: 46rem; }
/* An Admin tab's modals (Host / Site / User form, Bulk Import, the Hosts confirm) sit inside the tab's space-y-4 stack, and space-y gave each
   full-screen overlay a 16px margin-top: the dimmed backdrop stopped 16px short of the top of the window (the header showed through, undimmed),
   every dialog sat 8px low, and a tall one touched the bottom edge of a phone. A fixed overlay never wants that margin. (main + two classes:
   beats the space-y utility, which is three selectors' worth.) */
main .ph-admin > .fixed.inset-0 { margin-top: 0; }
.ph-sheet-grip { background: transparent; border: 0; touch-action: none; }
@media (prefers-reduced-motion: reduce) {
    .ph-sheet, .ph-sheet-panel, .ph-sheet-scrim { transition-duration: .01s; }
    .ph-sheet-grip-line { transition: none; }
}

/* ---- Admin > Settings: "Find a setting" (static/settings_ui.js: phSettingsFind) ----
   While a search is typed, every card and section is either .ph-find-hit or .ph-find-miss. !important is the point: a card on another Settings tab is
   display:none from Alpine's x-show (an inline style), and a hit has to show through it; the tab itself is never touched, so clearing the search
   puts the page back exactly as it was. A hit whose section is collapsed gets opened by the script (and closed again afterwards). */
.ph-settings-finding .ph-settings-cards > .ph-find-hit { display: grid !important; }
.ph-settings-finding .ph-settings-cards .ph-find-miss { display: none !important; }
.ph-settings-finding [data-settings-tabs] { opacity: .55; }

/* ---- Admin > Settings: the tile grid (templates/admin.html: div.ph-settings-cards, templates/_settings_ui.html: settings_tile; docs/SETTINGS_LAYOUT.md) ----
   Owner, 2026-11-10: "the grids are not aligned and look very unprofessional - make all the grids line up, use the full screen on a laptop, still work on
   a phone". ONE layout for every Settings tab:
   - A 12-track grid. An ordinary tile spans --ph-span tracks: 12 on a phone (one column), 6 from 768px (two), 4 from 1500px (three), 3 from 2300px
     (four). A tab of four tiles stays 2x2 at three-column widths, and a tab of 2 / 3 / 5 / 6 / 9 tiles does not go to four columns (static/settings_ui.js
     counts the tiles showing: data-tiles). A row that is not full (the last row of a tab) shares the whole width between its tiles: settings_ui.js
     marks them data-ph-fill="<tracks>" - so a tab never ends in a blank hole beside its last tile.
   - Every tile is TWO grid rows, its head (title + one line) and its body, shared with the tiles beside it (subgrid): the titles, the one-line
     descriptions and the rule under them run level across a row, and the first sections of neighbouring tiles start on the same line.
   - A row is ONE height - tops and bottoms line up - and the Save bar that ends a tile sits on the tile's bottom edge, so side-by-side Save bars are level.
     The one exception (owner, 2026-10-20: "the data collapses but the long box is still there"): while some tile on the tab has a section open, a tile
     whose sections are ALL closed is only as tall as its own list - closing a section always visibly shrinks its tile.
   - size='full' on settings_tile (.ph-tile--full) spans the whole width: tables and long forms (Mail server, Backup & Restore, UISP coverage, Users, Public page).
   Selectors are two or three classes on purpose: they beat Tailwind's one-class utilities whatever order the stylesheets load in. Nothing here is
   positioned or contained (no container queries: `container-type` would trap the fixed-position "?" help panels and dialogs inside a tile). */
.ph-settings { --ph-set-gap: 1.25rem; --ph-set-pad: 1.25rem; }
.ph-settings .ph-settings-cards {
    --ph-span: 12;
    display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: var(--ph-set-gap); align-items: stretch; padding-bottom: 1rem;
}
@media (min-width: 768px) { .ph-settings .ph-settings-cards { --ph-span: 6; } }
@media (min-width: 1500px) {
    .ph-settings .ph-settings-cards { --ph-span: 4; }
    .ph-settings .ph-settings-cards[data-tiles="4"] { --ph-span: 6; }
}
@media (min-width: 2300px) {
    .ph-settings .ph-settings-cards { --ph-span: 3; }
    .ph-settings .ph-settings-cards[data-tiles="2"] { --ph-span: 6; }
    .ph-settings .ph-settings-cards:is([data-tiles="3"], [data-tiles="5"], [data-tiles="6"], [data-tiles="9"]) { --ph-span: 4; }
}
.ph-settings .ph-settings-cards > * { margin: 0; min-width: 0; grid-column: span var(--ph-span); grid-row: span 2; }
.ph-settings .ph-settings-cards > .ph-tile--full, .ph-settings .ph-settings-cards > [data-ph-fill="12"] { grid-column: 1 / -1; }
.ph-settings .ph-settings-cards > [data-ph-fill="6"] { grid-column: span 6; }
.ph-settings .ph-settings-cards > [data-ph-fill="4"] { grid-column: span 4; }

/* The tile: a quiet surface, its head ruled off from its body. The head row is shared across the grid row (subgrid), so the rule is one line. */
.ph-settings .ph-tile {
    position: relative; display: grid; grid-template-rows: subgrid; row-gap: 0; align-content: start;
    padding: var(--ph-set-pad); background: var(--ph-surface); border: 1px solid var(--ph-border); border-radius: .75rem;
}
@supports not (grid-template-rows: subgrid) { .ph-settings .ph-tile { grid-template-rows: auto 1fr; } }
.ph-settings .ph-tile-head { padding-bottom: .875rem; border-bottom: 1px solid var(--ph-border); }
.ph-settings .ph-tile-title { margin: 0; font-size: 1rem; line-height: 1.375rem; font-weight: 700; letter-spacing: -.005em; color: var(--ph-text); text-wrap: balance; }
.ph-settings .ph-tile-sub { margin: .25rem 0 0; max-width: 72ch; font-size: .875rem; line-height: 1.3rem; color: var(--ph-text-muted); text-wrap: pretty; }
.ph-settings .ph-tile-body { display: flex; flex-direction: column; gap: .875rem; min-width: 0; padding-top: .875rem; }
/* The head's rule already separates the first section: no second rule right under it. */
.ph-settings .ph-tile-body > .ph-sub:first-child { border-top: 0; padding-top: 0; }
/* A section's title: sentence case, the size of body text, a step under the tile title. Every header row is at least 2rem, so a row with a "?" and one
   without are the same height (44px on a touch screen: the coarse-pointer rule further down). */
.ph-settings .ph-sub-title { font-size: .875rem; line-height: 1.3rem; font-weight: 650; color: var(--ph-text-secondary); }
@media (min-width: 1024px) { .ph-settings .ph-sub-head { min-height: 2rem; } }

/* The Save bar that ends a tile sits on its bottom edge: the tile's last block grows into the row's spare height and its own last Save bar is pushed down
   (margin-top: auto). Three shapes reach a bar: a one-form tile (its form root: a flex-col or space-y block with the bar as last child), the last
   section's body, and a wrapper div at the end of that body. */
.ph-settings .ph-tile-body > :last-child { flex: 1 1 auto; }
.ph-settings .ph-tile-body > .ph-sub:last-child,
.ph-settings .ph-tile-body > .ph-sub:last-child > [id^="ph-sub-"],
.ph-settings .ph-tile-body > :last-child:is(.flex-col, [class*="space-y-"]):has(> [data-form-actions]:last-child),
.ph-settings .ph-tile-body > .ph-sub:last-child > [id^="ph-sub-"] > div:last-child:has(> [data-form-actions]:last-child) { display: flex; flex-direction: column; flex: 1 1 auto; }
.ph-settings .ph-tile-body > :last-child > [data-form-actions]:last-child,
.ph-settings .ph-tile-body > .ph-sub:last-child > [id^="ph-sub-"] > [data-form-actions]:last-child,
.ph-settings .ph-tile-body > .ph-sub:last-child > [id^="ph-sub-"] > div:last-child > [data-form-actions]:last-child { margin-top: auto; }
/* The exception above: something on this tab is open, and this tile's sections are all closed - it hugs its own list. (A hidden tile on another tab
   does not count: x-show leaves style="display: none;" on it.) */
.ph-settings .ph-settings-cards:has(> :not([style*="display: none"]) .ph-sub-head > button[aria-expanded="true"])
    > .ph-tile:has([data-settings-section]):not(:has(.ph-sub-head > button[aria-expanded="true"])) { align-self: start; grid-template-rows: auto auto; }

/* Two-up fields inside a tile (Tailwind's grid grid-cols-2): from 640px each "label / box / hint" cell is a 3-row subgrid, so in one row the labels,
   the boxes and the hints of side-by-side fields share their lines - a label that wraps to two lines no longer pushes its box below its neighbour's.
   A cell with more than three parts keeps its own flow. Anything else in such a grid (a switch row) spans the three rows, centred on them. */
@media (min-width: 640px) {
    .ph-settings .ph-tile .grid:is(.grid-cols-2, .sm\:grid-cols-2):not(.ph-keep-grid) > div:has(> label:first-child + *):not(:has(> :nth-child(4))) {
        display: grid; grid-template-rows: subgrid; grid-row: span 3; row-gap: .25rem; align-content: start;
    }
    .ph-settings .ph-tile .grid:is(.grid-cols-2, .sm\:grid-cols-2):not(.ph-keep-grid) > div:has(> label:first-child + *):not(:has(> :nth-child(4))) > label:first-child { align-self: end; margin-bottom: 0; }
    .ph-settings .ph-tile .grid:is(.grid-cols-2, .sm\:grid-cols-2):not(.ph-keep-grid) > div:has(> label:first-child + *):not(:has(> :nth-child(4))) > :nth-child(2) { align-self: start; }
    .ph-settings .ph-tile .grid:is(.grid-cols-2, .sm\:grid-cols-2):not(.ph-keep-grid):has(> div > label:first-child + *) > :not(div:has(> label:first-child + *)):not(.col-span-2) { grid-row: span 3; align-self: center; }
    /* A field cell with more than three parts keeps its own flow but still takes the same three rows, starting at the top, so the cell beside it
       (a subgrid) keeps its label on the first line instead of being pushed under this one's whole height. */
    .ph-settings .ph-tile .grid:is(.grid-cols-2, .sm\:grid-cols-2):not(.ph-keep-grid) > div:has(> label:first-child + *):has(> :nth-child(4)) { grid-row: span 3; align-self: start; }
}

/* A tablet (768-1023px) shows two tiles side by side, about 370px each, but Tailwind's sm: breakpoint still sees a 768px+ screen: a four-up field grid
   (sm:grid-cols-4, the customer category defaults) would squeeze four boxes into one narrow tile. Two up there, as on a laptop. */
@media (min-width: 768px) and (max-width: 1023.98px) {
    .ph-settings .ph-tile .grid.sm\:grid-cols-4:not(.ph-keep-grid) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* A heading inside a section (Tailwind "text-xs font-bold uppercase tracking-wider": RF Defrag Matrix, Network Quality Alerts, All providers ...):
   sentence case like the section titles above it, one weight lighter and in the muted colour, so it reads as a step down, not as a shout. */
.ph-settings .ph-tile-body .text-xs.font-bold.uppercase.tracking-wider { text-transform: none; letter-spacing: normal; font-weight: 650; color: var(--ph-text-muted); }

/* The Public page tab holds ONE tile (a form shown step by step); Users & permissions one tile, no wider than the Users tab it used to be (1280px):
   a name / email / role table stretched across a 2560px screen is hard to read along a row. */
.ph-settings .ph-settings-cards > [data-settings-tile="users"] { max-width: 80rem; }
/* Mail server (templates/_settings_mail_server.html): FIRST on the System tab - a setting people find stressful gets room. A row is the label on the left
   and the field on the right from lg (like UISP's own Mail Server page), stacked on a phone. */
.ph-settings .ph-settings-cards > [data-settings-tile="mail-server"] { order: -2; }
/* Backup & Restore (templates/_settings_backups.html): its UISP-style table needs the room - right under the Mail server. */
.ph-settings .ph-settings-cards > [data-settings-tile="backup-restore"] { order: -1; }
/* UISP coverage (templates/_settings_uisp_coverage.html): counts and a list of devices - first on the Integrations tab, right above CRM & UISP Sync. */
.ph-settings .ph-settings-cards > [data-settings-tile="uisp-coverage"] { order: -1; }
.ph-settings [data-mail-form] { max-width: 62rem; }
.ph-settings .ph-mail-row { display: flex; flex-direction: column; gap: .5rem; }
.ph-settings .ph-mail-label { display: flex; align-items: center; gap: .375rem; min-width: 0; }
@media (min-width: 1024px) {
    .ph-settings .ph-mail-row { display: grid; grid-template-columns: 16rem minmax(0, 36rem); column-gap: 2.5rem; align-items: start; }
    .ph-settings .ph-mail-label { min-height: 2.5rem; }                                               /* the label sits level with the field's first line */
}
/* "Advanced": a native disclosure inside a section, closed until opened (settings_advanced). The marker is our own chevron. */
.ph-settings .ph-adv > summary { list-style: none; }
.ph-settings .ph-adv > summary::-webkit-details-marker { display: none; }
.ph-settings .ph-adv > summary svg { transition: transform .15s; }
.ph-settings .ph-adv[open] > summary svg { transform: rotate(180deg); }
@media (prefers-reduced-motion: reduce) { .ph-settings .ph-adv > summary svg { transition: none; } }

/* ---- Admin > Settings on a phone or tablet (templates/admin.html: div.ph-settings, div.ph-settings-cards) ----
   Descendant selectors on purpose: two classes beat Tailwind's one-class utilities whatever order the CDN injects its stylesheet in, so
   none of this needs !important. Everything sits inside a max-width query - lg (1024px) and up is untouched - or a touch screen: an iPad held
   sideways is 1024px+ wide (the desktop layout) but is still a thumb, and iOS still zooms into a field under 16px, so `(pointer: coarse)` gets the
   same control sizes at any width. */
@media (max-width: 1023.98px), (pointer: coarse) {
    /* 44px is the smallest target a thumb hits reliably; under 16px iOS Safari zooms the whole page in when a field is focused. */
    .ph-settings :is(input:not([type="checkbox"], [type="radio"], [type="color"], [type="file"], [type="range"]), select, textarea) {
        min-height: 44px;
        font-size: 16px;
    }
    /* A toggle row: the whole row is the tap target, not just the 36x20 switch. */
    .ph-settings label:has(> button[role="switch"]) { min-height: 44px; }
    /* Each card's own Save button, and its status message (which used to be cut to "Error: sav..." by truncate + max-w-[70%]). */
    .ph-settings .justify-end.border-t > button { min-height: 44px; padding-inline: 1.25rem; }
    .ph-settings .justify-end.border-t > span.truncate { max-width: none; white-space: normal; text-align: right; flex: 1 1 auto; }
}
@media (max-width: 639.98px) {
    /* A forced 2-column grid leaves ~150px per field at 375px, which clips text/select fields (SMTP host, SSL domain, Kubra GUIDs,
       OID pairs). Grids holding one of those go to a single column; number-only grids stay 2-up, where a short number fits fine. */
    .ph-settings .grid.grid-cols-2:has(input:not([type="checkbox"], [type="radio"], [type="number"], [type="color"], [type="range"]), select) {
        grid-template-columns: minmax(0, 1fr);
    }
    .ph-settings .grid.grid-cols-2:has(input:not([type="checkbox"], [type="radio"], [type="number"], [type="color"], [type="range"]), select) > .col-span-2 {
        grid-column: 1 / -1;
    }
    /* Three coloured tiles in a 2-column grid strand one tile on its own row (the Status Card Style preview grid opts out: it is a
       picture of the phone grid, so it keeps its own columns). */
    .ph-settings .grid.grid-cols-2.sm\:grid-cols-3:not(.ph-keep-grid) { grid-template-columns: minmax(0, 1fr); }
    /* Two number fields side by side: a label that wraps to two lines pushes its input below its neighbour's. Bottom-aligning the
       cells puts both inputs on one line whatever the labels do. */
    .ph-settings .grid.grid-cols-2:has(> div > label.block + input[type="number"]) { align-items: end; }
    /* Tiles run edge to edge: main's 16px padding plus the tile's own 16px left ~309px of a 375px screen for the fields. */
    .ph-settings .ph-settings-cards > * { margin-inline: -1rem; border-radius: 0; border-inline-width: 0; }
}

/* === resp-misc (responsive day run): global base rules + auth / first-run pages === */

/* Native controls follow the theme: scrollbars, <select> popups, date pickers and autofill are drawn in the page's colour scheme. Without this a dark
   page got light-grey scrollbars and a light autofill fill (with this theme's light text on it - unreadable). base.html's data-theme switch drives it. */
:root { color-scheme: dark; }
[data-theme="light"] { color-scheme: light; }

/* Every page's real scroller is its own <main>. Its overscroll must not chain out to the page: on Chrome for Android that chain is the browser's OWN
   pull-to-refresh, which would reload the page on top of (or, on the Settings form, instead of the guard against) base.html's phPullToRefreshInit. */
main { overscroll-behavior-y: contain; }

/* -- Sign in / forgot + reset password / pair a display / first-run setup (templates/_auth_shell.html, setup_wizard.html) --
   Two-class selectors (.ph-auth .x) so they beat Tailwind's one-class utilities whatever order the CDN injects its stylesheet in. */
/* Autofill (a password manager filling the form) paints its own fill and leaves the text colour alone: put this page's own colours back. */
/* Any autofilled box starts this do-nothing animation: static/form_state.js listens for it and hands the value to the form (an autofill fires no
   input event, so a Save button would otherwise stay greyed out with the box visibly filled). */
@keyframes ph-autofill-start { from { outline-color: transparent; } to { outline-color: transparent; } }
/* A secret box (data-form-secret) a browser / password manager filled: static/form_state.js holds the value back and asks first. */
.ph-secret-fill { margin-top: .375rem; padding: .5rem .625rem; border: 1px solid var(--ph-border-input); border-radius: .5rem;
    background: var(--ph-surface-alt); color: var(--ph-text-secondary); font-size: .8125rem; line-height: 1.35; }
.ph-secret-fill-actions { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .375rem; }
/* The Sonar "Next step" panel / banner (templates/_sonar_next_step.html) and the button "Do it here" points at (static/sonar_connect_guide.js). */
.ph-sonar-next { border: 2px solid var(--ph-accent); background: var(--ph-surface-alt); }
.ph-next-highlight { outline: 3px solid var(--ph-accent); outline-offset: 3px; }
input:-webkit-autofill { animation: ph-autofill-start 1ms; }
.ph-auth input:-webkit-autofill,
.ph-auth input:-webkit-autofill:hover,
.ph-auth input:-webkit-autofill:focus {
    -webkit-text-fill-color: var(--ph-text);
    caret-color: var(--ph-text);
    box-shadow: 0 0 0 100px var(--ph-bg) inset;
}
@media (max-width: 400px) {
    /* 320px screens: the card is 288px wide; 32px of padding a side leaves 224px for the fields. */
    .ph-auth .ph-auth-card { padding: 1.5rem; }
    .ph-auth .ph-auth-card-fun { padding-top: 2.5rem; }
}
@media (pointer: coarse) {
    /* A thumb-sized form: 44px fields and buttons; 16px text so iOS Safari does not zoom the whole page in when a field is focused. */
    .ph-auth input:not([type="hidden"]) { min-height: 44px; font-size: 16px; }
    .ph-auth button { min-height: 44px; }
    .ph-auth #ph-theme-toggle { width: 44px; height: 44px; font-size: 1.125rem; }
    /* The links under the form ("Back to sign in", "Sign in with a password instead") and "Forgot password?" beside the label. */
    .ph-auth a.block, .ph-auth p > a { display: flex; align-items: center; justify-content: center; min-height: 44px; }
    .ph-auth .ph-auth-side-link { display: inline-flex; align-items: center; min-height: 44px; }
}
/* A phone held sideways (or any short window): a tall card has to scroll, so make it as short as it can be first - a smaller mascot, less padding,
   tighter gaps - and drop the mascot altogether when even that leaves no room for the fields. */
@media (max-height: 520px) {
    .ph-auth .ph-auth-mascot { width: 4rem; height: 4rem; margin-bottom: -1rem; }
    .ph-auth .ph-auth-card { padding: 1.25rem; }
    .ph-auth .ph-auth-card-fun { padding-top: 1.75rem; }
    .ph-auth .ph-auth-card > :not([hidden]) ~ :not([hidden]) { margin-top: 1rem; }
}
@media (max-height: 400px) {
    .ph-auth .ph-auth-mascot { display: none; }
    .ph-auth .ph-auth-card-fun { padding-top: 1.25rem; }
}
/* -- First-run pages (setup_wizard.html, setup_restore.html): the whole window, on one shared grid --
   The card fills the window less a fluid gutter (1rem on a phone, 3vw, at most 3rem) and stops at 120rem, centred - that is 1920px at the normal
   16px root and grows with the big-screen root scale-up below, so a 4K screen gets bigger type, not a postage stamp, and a 5K one no endless lines.
   Field tracks: 1 below 768px, 2 from 768px, 3 from 1024px. Every field is a 3-row SUBGRID (label / box / hint), so in one row the labels, the boxes
   and the hints of side-by-side fields start on the same lines even when only one of them has a hint (or a label that wraps). From 1024px the two
   top sections - "This server" (1 track) and "Your admin account" (2 tracks) - share their rows too (subgrid rows), so their headings and boxes
   line up across the sections. Long hint paragraphs keep a readable measure (75ch); the page itself does not. */
.ph-auth.ph-setup-page { padding: clamp(1rem, 3vw, 3rem); }
.ph-auth .ph-setup-card { width: 100%; max-width: 120rem; margin-inline: auto; padding: clamp(1.25rem, 2.5vw, 2.5rem); }
.ph-setup-head { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.75rem 1.5rem; align-items: start; }
.ph-setup-head > .ph-theme-text-row { justify-self: end; }
.ph-setup-grid, .ph-setup-options {
    display: grid; grid-template-columns: minmax(0, 1fr); align-items: start;
    column-gap: clamp(1rem, 2vw, 2rem); row-gap: 1.25rem;
}
.ph-setup-sec { grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; row-gap: 1.25rem; align-content: start; min-width: 0; }
.ph-setup-options { row-gap: 0.5rem; }
.ph-setup-sec > .ph-setup-h2 { grid-column: 1 / -1; }
.ph-setup-col { grid-column: 1 / -1; min-width: 0; }
.ph-setup-fill { grid-column: 1 / -1; display: grid; row-gap: 0.5rem; min-width: 0; }
.ph-setup-field { display: grid; grid-template-rows: subgrid; grid-row: span 3; row-gap: 0.375rem; align-content: start; min-width: 0; }
.ph-setup-field > label { align-self: end; }                         /* a one-line label next to a wrapped one sits on the box, not above a gap */
.ph-setup-prose { max-width: 75ch; }
.ph-setup-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.75rem; }
.ph-setup-actions > button, .ph-setup-actions > a { width: 100%; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; padding-inline: 1.5rem; }
.ph-setup-foot { display: flex; flex-direction: column; align-items: center; gap: 0.5rem 1.5rem; text-align: center; }
@media (min-width: 640px) {
    .ph-setup-head { grid-template-columns: minmax(0, 1fr) auto; }
    .ph-setup-head > .ph-theme-text-row { grid-column: 2; grid-row: 1; }        /* the top-right slot, level with the title; never over it */
    .ph-setup-head > .ph-setup-title { grid-column: 1; grid-row: 1; }
    .ph-setup-actions > button, .ph-setup-actions > a { width: auto; min-width: 16rem; }
}
@media (min-width: 768px) {
    .ph-setup-grid, .ph-setup-options { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    .ph-setup-foot { flex-direction: row; flex-wrap: wrap; justify-content: space-between; text-align: left; }
}
@media (min-width: 1024px) {
    .ph-setup-grid, .ph-setup-options { grid-template-columns: repeat(3, minmax(0, 1fr)); }
    .ph-setup-grid > .ph-setup-sec-server { grid-column: 1 / span 1; grid-row: 1 / span 7; grid-template-rows: subgrid; }
    .ph-setup-grid > .ph-setup-sec-admin { grid-column: 2 / span 2; grid-row: 1 / span 7; grid-template-rows: subgrid; }
    .ph-setup-agree > .ph-setup-terms { grid-column: span 2; }
    .ph-setup-grid > .ph-setup-fill { grid-column: 1 / span 2; }                  /* the update switch on track 1, the terms on tracks 2-3 */
    /* setup_restore.html: two equal columns (a file | Dropbox); their headings share the first line. */
    .ph-setup-grid.ph-setup-grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    .ph-setup-grid-2 > .ph-setup-col { grid-column: auto; }
    .ph-setup-grid-2 > .ph-setup-col + .ph-setup-col { border-top: 0; padding-top: 0; }
}
/* A big screen (a 4K monitor or TV at 100%): everything on these pages is sized in rem, so a larger root size scales the card, the type and the
   theme button together instead of leaving a 384px card in the middle of 3840px. */
/* (A percentage, not px: 125% / 162.5% are 20px / 26px at the default 16px but still follow a larger browser text size that the person has chosen.) */
@media (min-width: 2200px) and (min-height: 1000px) { :root:has(.ph-auth) { font-size: 125%; } }
@media (min-width: 3200px) and (min-height: 1500px) { :root:has(.ph-auth) { font-size: 162.5%; } }

/* -- Admin > Settings on a very wide screen --
   The Settings page is deliberately full width (max-w-none); its column count is set by the tile grid above (four columns from 2300px). Past 2900px
   the whole page stops growing at 2800px, centred. */
@media (min-width: 2900px) {
    .ph-settings { width: 100%; max-width: 2800px; margin-inline: auto; }
}

/* -- Admin > Settings on a touch screen, at any width --
   Beyond the fields and Save buttons above: the small action buttons (Sync Now, Check Now, Copy, Remove, Edit, the customer-class tabs...) were
   24-32px tall on a phone held sideways, on a tablet, and on an iPad held sideways (where the layout is the desktop one). The switches, the "?" help
   buttons and the section chevrons are left alone: their target is a whole row / a 44px hit area of their own. */
@media (pointer: coarse) {
    .ph-settings button:not([role="switch"], [aria-label^="Help:"], [aria-label$="show or hide"]) { min-height: 44px; min-width: 44px; }
    /* A collapsible section's header row is the tap target for its chevron (the button's ::after covers it): 44px tall on desktop-width tablets too. */
    .ph-settings .ph-sub-head { min-height: 44px; }
    .ph-settings details > summary { min-height: 44px; }
}
/* === end resp-misc === */

/* === header alerts dropdown (templates/_header.html, base.html phAlertBell()) ===
   One calm card per alert: a 4px severity rail down the left edge (--ph-qa-rail: the theme's own --ph-status-*-text colour for what the alert says), a round glyph
   tinted from it, the title (up to three lines before it is cut), the body (two lines), a mono time readout. Clicking the card marks it read and it stays, dimmed (.ph-qa-read),
   with the word "Read" (and a check) where the unread dot was; the right-hand column (.ph-qa-side) holds the x - a real 32px button (44px on touch: the coarse rule below) - and, when
   there is somewhere to send the alert, an Open link (.ph-qa-open) under it; a dismissed card becomes a "Dismissed - Undo" strip (.ph-qa-undo) that stays until the panel is reopened.
   The meta row (.ph-qa-meta: dot / Read, time, x3, kind, state) WRAPS as a whole when the card is narrow (a 320px phone) and nothing in it ever breaks internally: a chip or the
   time that wrapped onto two lines was the "CSS looks messed up" report (the "Data source" chip became a two-line chip, "4d ago" broke into "4d" / "ago").
   Text is >= 13px and uses the theme tokens (both themes). color-mix() tints the glyph chip; a browser without it just shows the glyph without the tint. */
.ph-qa { position: relative; display: flex; align-items: flex-start; background: var(--ph-surface-alt); border: 1px solid var(--ph-border); border-radius: .75rem; overflow: hidden; transition: background-color .15s ease, opacity .15s ease; }
.ph-qa-slide { transition: transform .2s ease, background-color .15s ease, opacity .15s ease; }
.ph-qa::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--ph-qa-rail, var(--ph-border-input)); transition: opacity .15s ease; }
@media (hover: hover) { .ph-qa:hover { background: var(--ph-surface-strong); } }
.ph-qa-main { flex: 1 1 auto; min-width: 0; display: flex; align-items: flex-start; gap: .75rem; padding: .75rem .25rem .75rem 1rem; text-align: left; color: inherit; cursor: pointer; border-radius: .75rem 0 0 .75rem; }
.ph-qa-main:focus-visible, .ph-qa-x:focus-visible, .ph-qa-open:focus-visible, .ph-qa-link:focus-visible { outline: 2px solid var(--ph-accent-text); outline-offset: -2px; }
.ph-qa-chip { flex: none; display: grid; place-items: center; width: 2rem; height: 2rem; border-radius: 9999px; color: var(--ph-qa-rail, var(--ph-text)); background: var(--ph-surface-strong); background: color-mix(in srgb, var(--ph-qa-rail, #888) 20%, transparent); }
.ph-qa-title { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; overflow-wrap: anywhere; font-size: .9375rem; line-height: 1.3; font-weight: 700; color: var(--ph-text); }
.ph-qa-body { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; margin-top: .125rem; font-size: .8125rem; line-height: 1.35; color: var(--ph-text-muted); }
.ph-qa-meta { display: flex; flex-wrap: wrap; align-items: center; gap: .25rem .5rem; margin-top: .375rem; font-family: var(--ph-font-mono); font-size: .8125rem; line-height: 1.2; color: var(--ph-text-faint); font-variant-numeric: tabular-nums; }
.ph-qa-meta > * { flex: none; white-space: nowrap; }
.ph-qa-kind { font-family: var(--ph-font-sans); font-size: .8125rem; line-height: 1.25; font-weight: 600; padding: .0625rem .4375rem; border-radius: 9999px; background: var(--ph-surface); border: 1px solid var(--ph-border); color: var(--ph-text-secondary); }
.ph-qa-readmark { display: inline-flex; align-items: center; gap: .25rem; font-family: var(--ph-font-sans); font-size: .8125rem; font-weight: 600; color: var(--ph-text-secondary); }
.ph-qa-dot { flex: none; width: .5rem; height: .5rem; border-radius: 9999px; background: var(--ph-accent-text); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ph-accent-text) 22%, transparent); }
/* The right-hand column: the x on top, the Open link (when the alert has somewhere to go) under it. */
.ph-qa-side { flex: none; display: flex; flex-direction: column; align-items: center; gap: .125rem; margin: .5rem .5rem 0 0; }
.ph-qa-x, .ph-qa-open { flex: none; display: grid; place-items: center; width: 2rem; height: 2rem; border-radius: .5rem; cursor: pointer; transition: background-color .15s ease, color .15s ease; }
.ph-qa-x { color: var(--ph-text-muted); }
.ph-qa-open { color: var(--ph-accent-text); }
@media (hover: hover) { .ph-qa-x:hover { background: color-mix(in srgb, var(--ph-bad) 18%, transparent); color: var(--ph-bad); } }
@media (hover: hover) { .ph-qa-open:hover { background: var(--ph-accent-soft); color: var(--ph-accent-text-hover); } }
/* Read: the card steps back (no dot, the word "Read" in its place, a lighter title, a fainter rail) but stays where it is. */
.ph-qa-read { background: transparent; }
@media (hover: hover) { .ph-qa-read:hover { background: var(--ph-surface-alt); } }
.ph-qa-read::before { opacity: .55; }
.ph-qa-read .ph-qa-title { font-weight: 500; color: var(--ph-text-secondary); }
.ph-qa-read .ph-qa-chip { opacity: .7; }
.ph-qa-read .ph-qa-body, .ph-qa-read .ph-qa-meta { color: var(--ph-text-muted); }
.ph-qa-undo { display: flex; align-items: center; gap: .75rem; padding: .5rem .5rem .5rem 1rem; min-height: 2.75rem; font-size: .9375rem; border: 1px dashed var(--ph-border-input); border-radius: .75rem; color: var(--ph-text-secondary); }
.ph-qa-link { display: inline-flex; align-items: center; gap: .125rem; min-height: 2rem; padding: 0 .5rem; border-radius: .5rem; font-size: .8125rem; font-weight: 600; color: var(--ph-accent-text); cursor: pointer; white-space: nowrap; }
@media (hover: hover) { .ph-qa-link:hover { background: var(--ph-surface-alt); color: var(--ph-accent-text-hover); } }
.ph-qa-count { white-space: nowrap; padding: .0625rem .5rem; border-radius: 9999px; font-size: .8125rem; font-weight: 700; background: var(--ph-accent-soft); color: var(--ph-accent-text); }
@media (prefers-reduced-motion: reduce) { .ph-qa, .ph-qa-slide, .ph-qa::before, .ph-qa-x, .ph-qa-open { transition: none; } }
@media (pointer: coarse) {
    /* A finger: the x and the Open link are 44px targets (the header panels' coarse rule asks for the same), 4px apart so a thumb that lands between them hits neither. */
    .ph-qa-side { margin: .25rem .25rem 0 0; gap: .25rem; }
    .ph-qa-x, .ph-qa-open { width: 2.75rem; height: 2.75rem; }
    .ph-qa-link { min-height: 2.75rem; }
}
/* === end header alerts dropdown === */

/* === header summary (templates/_quick_view.html header_style, placed by _header.html) ===
   The Status page's 24h numbers - uptime, worst latency (a link to the slowest device), outages, high latency, down now - in the top menu bar between the logo and
   the icons, from 768px up. It is a middle child of the header with `flex: 1 1 0` and `container-type: inline-size`: its width is only whatever the bar has LEFT
   after the logo and the icon row, and being a size container its content never asks the bar for room - so it cannot make the bar wrap. Its -.75rem left margin
   takes back one of the header's two 12px gaps (logo | summary | icons): the bar wraps exactly where it did without the summary. The container queries below drop
   things as the room shrinks - the long labels, outages, uptime, high latency, down, then everything - and when it can show not even one number the page's own
   24h strip takes over (_quick_view.html: store.headerFits, kept in step with the 111px below). Left-aligned after the logo (a centred one would jump when the
   search box grows on focus). A browser without container queries never shows it (the strip is shown instead). Text is >= 13px (labels) / 16px (numbers) and the
   two lines are 32px tall in all - the bar's own content box is 35px, so the bar does not grow. Theme tokens, both themes. */
.ph-hdr { --ph-hdr-gap-x: .5rem; }                                             /* the header's column gap: Tailwind's gap-2 (8px), gap-3 (12px) from 640px - set here so the summary's margin follows it */
@media (min-width: 640px) { .ph-hdr { --ph-hdr-gap-x: .75rem; } }
header.ph-hdr { column-gap: var(--ph-hdr-gap-x); }
.ph-hdr-sum { display: none; }
@supports (container-type: inline-size) {
    .ph-hdr-sum { flex: 1 1 0%; min-width: 0; container-type: inline-size; container-name: phsum; }
    @media (min-width: 768px) { .ph-hdr-sum { display: block; margin-left: calc(-1 * var(--ph-hdr-gap-x)); } }       /* takes back one of the header's two gaps (logo | summary | icons), so the bar wraps exactly where it did without the summary */
}
.ph-hdr-sum-in { display: flex; align-items: center; justify-content: flex-start; gap: .5rem; height: 100%; padding-left: 1rem; white-space: nowrap; overflow: hidden; }
@supports (overflow-x: clip) { .ph-hdr-sum-in { overflow: visible; overflow-x: clip; overflow-y: visible; } }       /* sideways it is cut (the numbers never spill onto the logo or the icons); up and down the finger hit areas above may stick out */
.ph-sum-stat { display: flex; flex-direction: column; gap: 0; padding: 0 .5rem; border-radius: .5rem; text-decoration: none; color: inherit; line-height: 1.1; flex: 0 0 auto; }
.ph-sum-label { font-size: .8125rem; color: var(--ph-text-faint); }
.ph-sum-label .ph-l-long { display: none; }
.ph-sum-val { font-family: var(--ph-font-mono); font-size: 1rem; font-weight: 700; color: var(--ph-text); font-variant-numeric: tabular-nums; }
.ph-sum-bad { color: var(--ph-status-critical-text); }
.ph-sum-warn { color: var(--ph-status-warning-text); }
.ph-sum-link[href] { cursor: pointer; }
@media (hover: hover) { .ph-sum-link[href]:hover { background: var(--ph-surface-alt); } .ph-sum-link[href]:hover .ph-sum-label { color: var(--ph-text-secondary); text-decoration: underline; text-underline-offset: 3px; } }
.ph-sum-link[href]:focus-visible { outline: 2px solid var(--ph-accent-text); outline-offset: -2px; }       /* inset: the bar clips what is outside the box */
@media (pointer: coarse) { .ph-sum-link { padding-block: 6px; margin-block: -6px; min-width: 44px; justify-content: center; } }       /* a finger: a 44px hit area (32 + 6 + 6) that takes no layout room - the bar must not grow for it (a tablet upright puts the summary on the logo's line) */
/* In rem (the text scales with the browser's text size, so must the room it needs), each with ~20px of headroom for a longer number (123 down, "10.2 s") - and
   _quick_view.html's PH_SUM_MIN_REM is the last of them. */
@container phsum (min-width: 38rem) { .ph-sum-label .ph-l-long { display: inline; } .ph-sum-label .ph-l-short { display: none; } }
/* Measured (short labels, the widest values - "10.2 s", 1234, 123 - and 1.25rem of headroom): worst 7.25, + down 10.75, + DFS hits 14.75, + high latency 18.15, + uptime 23.4,
   + outages 27.9 (the DFS hits tile is 2.9rem wide, exactly the high latency tile's width, so it takes the old "high latency" cut-off and everything before it moves up by
   3.4rem = 2.9 + the .5rem gap); the full labels need 38 with all six. Drop order as the room shrinks: outages, uptime, high latency, DFS hits, down, then everything
   (worst stays longest). DFS hits is the number that says "act now", so it outlives the three 24-hour statistics. */
@container phsum (max-width: calc(27.9rem - .02px)) { .ph-sum-stat[data-sum="outages"] { display: none; } }
@container phsum (max-width: calc(23.4rem - .02px)) { .ph-sum-stat[data-sum="uptime"] { display: none; } }
@container phsum (max-width: calc(18.15rem - .02px)) { .ph-sum-stat[data-sum="high"] { display: none; } }
@container phsum (max-width: calc(14.75rem - .02px)) { .ph-sum-stat[data-sum="dfs"] { display: none; } }
@container phsum (max-width: calc(12.25rem - .02px)) { .ph-sum-stat[data-sum="down"] { display: none; } }
@container phsum (max-width: calc(8.25rem - .02px)) { .ph-hdr-sum-in { display: none; } }
/* With customer hosts (.ph-sum-split, docs/DOWN_COUNTING.md) two more numbers follow DFS - Customers down, Customer outages - and the down / outages
   labels are longer ("Infra down"): the customers' numbers go first as the room shrinks, the long labels need more room, and outages goes a little sooner. */
@container phsum (min-width: 38rem) and (max-width: calc(56rem - .02px)) { .ph-sum-split .ph-sum-label .ph-l-long { display: none; } .ph-sum-split .ph-sum-label .ph-l-short { display: inline; } }
@container phsum (max-width: calc(41rem - .02px)) { .ph-sum-stat[data-sum="cust-outages"] { display: none; } }
@container phsum (max-width: calc(34.5rem - .02px)) { .ph-sum-stat[data-sum="cust-down"] { display: none; } }
@container phsum (max-width: calc(30.5rem - .02px)) { .ph-sum-split .ph-sum-stat[data-sum="outages"] { display: none; } }
/* === end header summary === */

/* === header icon row (templates/_header.html, _cast_header_button.html): one fluid spacing system ===
   The icons at the top right (filter funnel, cast, Displays, Notes, Paused, Alerts, avatar) used to be 28px bordered boxes 12px apart next to three BARE 18px
   bells 30px apart, whose count badges overlapped each other, with the avatar glued to the window's edge. Now every icon is the same bordered square box
   (--ph-hdr-btn), icons inside a group are --ph-hdr-gap apart, the groups (tools | bells | avatar) are set apart by --ph-hdr-group-gap more, a count badge sits on
   its own button's corner (never on a neighbour), and the header keeps a margin at the window's edge. The box is a STEP (28 / 32 / 36px by window width - 28 below 1280px so an uploaded logo of ~170px still fits beside the row at 1024px: whole
   pixels, so a 1px border and the centred icon never land on a half pixel); the gaps and the margin are clamp()s of the window's width. So a full-screen desktop,
   a cascaded (narrow) mouse window, a tablet and a phone each get proportionate room with no per-size rules. The widths were chosen so the row still fits on one
   line beside the logo wherever it did before (a 700px mouse window has ~28px to spare; at 1024px an uploaded logo up to ~185px wide still fits beside the row). On a touch screen the box is raised to 44px by the coarse block below
   (min-width/min-height), the gap is a finger's width and the group gap at most 6px (a tablet held sideways has ~35px to spare with the search box focused), and
   on a phone the group gap is 0 (a 320px phone has 2px to spare). Two-class selectors (.ph-hdr .x) beat Tailwind's one-class utilities (w-7, h-7, gap-3, ...)
   whatever order the CDN injects its stylesheet in. `display` is deliberately NOT set on a button: several carry lg:hidden / hidden / flex utilities. */
.ph-hdr {
    --ph-hdr-btn: 1.75rem;                                            /* 28px in a narrow window */
    --ph-hdr-gap: clamp(.25rem, -.2rem + 1.2vw, .75rem);              /* 4px on a phone ... 12px full-screen */
    --ph-hdr-group-gap: clamp(0rem, -.8rem + 2.3vw, 1.25rem);         /* 0 on a phone ... 20px at 1440px and up, on top of the gap */
}
@media (min-width: 1280px) { .ph-hdr { --ph-hdr-btn: 2rem; } }       /* 32px */
@media (min-width: 1600px) { .ph-hdr { --ph-hdr-btn: 2.25rem; } }    /* 36px full-screen */
header.ph-hdr { padding-inline: clamp(1rem, -.58rem + 2.4vw, 2rem); }  /* 16px until ~1100px, 32px from ~1800px: the avatar is never against the window's edge */
.ph-hdr .ph-hdr-row, .ph-hdr .ph-hdr-tools { gap: var(--ph-hdr-gap); }
.ph-hdr .ph-hdr-row > .ph-hdr-bell, .ph-hdr .ph-hdr-row > .ph-hdr-grp { margin-left: var(--ph-hdr-group-gap); }
.ph-hdr .ph-hdr-row > .ph-hdr-bell ~ .ph-hdr-bell { margin-left: 0; }  /* Notes / Paused / Alerts are ONE group: only the first one is set apart (~, not +: a <script> may sit between them) */
.ph-hdr .ph-hdr-btn { width: var(--ph-hdr-btn); height: var(--ph-hdr-btn); padding: 0; justify-content: center; }
.ph-hdr .ph-hdr-btn:not(.ph-hdr-avatar) { border-radius: .5rem; }
@media (hover: hover) { .ph-hdr .ph-hdr-btn:not(.ph-hdr-avatar):hover { background: var(--ph-surface-alt); } }   /* (a tapped button on a touch screen must not stay lit) */
/* A count badge hangs 4px off the button's top-right corner (a wide "40" or "99+" reaches 4px into the gap and no further: the smallest gap is 5px, so it
   never lands on a neighbour's box), clear of the 18px glyph's middle. The dot variant sits just inside the corner. */
.ph-hdr .ph-hdr-btn > span.absolute { top: -4px; right: -4px; }
.ph-hdr .ph-hdr-btn > span.absolute.w-2 { top: 3px; right: 3px; }
/* The cast control's icon is the same square as its neighbours (its "more" arrow beside it is the same height). */
.ph-hdr [data-testid="cast-split"] { height: var(--ph-hdr-btn); }
.ph-hdr [data-testid="cast-split"] > button:first-child { width: var(--ph-hdr-btn); height: var(--ph-hdr-btn); }
.ph-hdr [data-testid="cast-split"] > button:last-child { height: var(--ph-hdr-btn); }
/* === end header icon row === */

/* === resp-status (responsive day run): the Status experience - shared header (templates/_header.html), host grid / list / cards
   (_host_grid.html, static/grid.js), alerts feed, display surfaces ===
   Everything touch-related sits inside (pointer: coarse) - the device's PRIMARY pointer is a finger, whatever its width - so a laptop with a
   mouse is byte-for-byte what it was. Two-class selectors (.ph-hdr .x) beat Tailwind's one-class utilities whatever order the CDN injects its
   stylesheet in, so none of this needs !important. */
@media (pointer: coarse) {
    /* Header icon row on a touch screen: 5px between 44px boxes (a thumb lands between them), at most 6px more between the groups, and the side margin the header
       always had (a tablet held sideways has ~35px to spare with the search box focused: see the header icon row block above). */
    .ph-hdr { --ph-hdr-gap: .3125rem; --ph-hdr-group-gap: clamp(0rem, -1.6rem + 4.5vw, .375rem); }
    header.ph-hdr { padding-inline: 1rem; }
    /* The header's icon buttons (search, filter funnel, Displays, Notes, Paused, Alerts, avatar) were 18-28px: a thumb-sized box, centred on the
       same icon. The count badges and dots stay inside the button's own corner instead of hanging off it into the neighbour's box (the gap between
       the buttons is the header icon row's --ph-hdr-gap: 4px on a phone, more on a tablet). */
    .ph-hdr .ph-hdr-btn { min-width: 44px; min-height: 44px; justify-content: center; }
    .ph-hdr .ph-hdr-btn > span.absolute { top: 2px; right: 2px; }
    .ph-hdr .ph-hdr-btn > span.absolute.w-2 { top: 8px; right: 8px; }
    /* 16px stops iOS Safari zooming the page in when a field takes focus; 44px is the smallest target a thumb hits reliably. */
    .ph-hdr :is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"], [type="hidden"]), select, textarea) { font-size: 16px; min-height: 44px; }
    /* Infrastructure / Customer / All / Paused. */
    .ph-hdr .ph-hdr-seg button { min-height: 44px; min-width: 44px; padding-inline: .75rem; }
    /* Everything inside the header's menus and dialogs (filter funnel, Displays, Notes, Paused, Alerts, profile, User Settings): each row a
       finger-sized box, and the filter pills 8px apart. The toggle switches keep their look (their whole row is the target, below); the little
       "full pause note" info button stays a glyph beside its line. */
    .ph-hdr .fixed :is(button, a):not([role="switch"], [aria-label="View full pause note"]) { min-height: 44px; min-width: 44px; }
    .ph-hdr .fixed .flex.flex-wrap.gap-1 { gap: .5rem; }
    .ph-hdr .fixed label:has(> button[role="switch"]) { min-height: 44px; }
}
@media (pointer: coarse) and (max-width: 339px) {
    .ph-hdr { --ph-hdr-gap: .25rem; }          /* a 320px phone: the row is 286px of 288px with 4px gaps - 5px would need a third header row */
}
/* A phone held sideways (or any short touch screen): the header never scrolls away, so it is kept as short as its 44px buttons allow. */
@media (pointer: coarse) and (max-height: 500px) {
    header.ph-hdr { padding-block: .25rem; }
}

/* -- The host grid (templates/_host_grid.html): cards, list rows, the notes / confirm dialogs -- */
/* The "..." button that stands in for a card's small icons: nothing on a mouse, a 44px target on a finger. Its negative margins take back the
   room a 44px box would add to the card's title row (the row is ~20px tall), so a card is no taller than it was. */
.ph-grid-root .ph-card-more { display: none; }
/* The grid root's `space-y-4` gives every child but the first a 16px top margin - a full-screen overlay (the confirm dialog, the card actions sheet) would
   start 16px down and leave the header undimmed above it. Same fix as `main .ph-admin > .fixed.inset-0` above. */
.ph-grid-root > .fixed.inset-0 { margin-top: 0; }
@media (pointer: coarse) {
    .ph-grid-root .ph-card-ico { display: none; }
    .ph-grid-root .ph-card-more { display: inline-flex; align-items: center; justify-content: center; min-width: 44px; min-height: 44px; margin-block: -12px; margin-right: -10px; }
    /* A card's address (the link to the device's own web page) is a 16px-tall link: its hit box grows up and down into the card's padding. */
    .ph-grid-root .ph-card-link { padding-block: 14px; margin-block: -14px; }
    /* The table's own small buttons (List view on a tablet). */
    .ph-grid-root .ph-tap-inline { display: inline-flex; align-items: center; justify-content: center; min-width: 44px; min-height: 44px; margin-block: -14px; }
    /* Fields inside the grid (a card's note editor, the pause dialog's note): 16px, or iOS zooms the page in when one takes focus. */
    .ph-grid-root :is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"], [type="hidden"]), select, textarea) { font-size: 16px; }
    /* "Show N more" / "Issues only" under a big fleet, "Pause / Resume this site" above the list, the confirm dialog's buttons and the error banner's
       X. Not the card-actions sheet (:not([data-testid="card-actions"])): its own rows are already min-h-[48px] in the template, and this rule's
       44px would otherwise win the specificity tie and shrink them back down. */
    .ph-grid-root [data-testid="render-window-note"] button,
    .ph-grid-root #ph-list-view > div:first-child > button,
    .ph-grid-root .fixed.inset-0:not([data-testid="card-actions"]) button:not([role="switch"]),
    .ph-grid-root .fixed.bottom-4 button { min-height: 44px; min-width: 44px; }
    /* The error banner sits above the round cast button (bottom-right, 48px + 16px), not on top of it. */
    .ph-grid-root .fixed.bottom-4 { bottom: 5rem; }
    /* The pause dialog's toggle rows ("This is scheduled maintenance"): the whole labelled row is the target. */
    .ph-grid-root label:has(> button[role="switch"]) { min-height: 44px; }
}

/* -- Anything that just needs a finger-sized box (.ph-tap) and the Alerts page (templates/alerts.html) -- */
@media (pointer: coarse) {
    .ph-tap { min-width: 44px; min-height: 44px; }
    .ph-alerts .ph-alert-x { width: 44px; height: 44px; top: .25rem; right: .25rem; font-size: 1.25rem; }
    .ph-alerts .ph-alert-row { padding-right: 3rem; }
    .ph-alerts label:has(> button[role="switch"]) { min-height: 44px; }
    .ph-alerts .border-2 :is(button, a.inline-block):not(.ph-alert-x) { min-height: 44px; display: inline-flex; align-items: center; }
    /* The gear menu (phones) and the left rail (a tablet held sideways, 1024px+): 44px rows and icons. */
    .ph-alerts .fixed button:not([role="switch"]) { min-height: 44px; }
    .ph-alerts nav a { min-width: 44px; min-height: 44px; }
}
/* === end resp-status === */

/* ---- The "?" help popup's content (templates/_help.html, static/help.js) ----
   The popup itself is Tailwind in the macro; these give the how-to inside it one consistent look. */
.ph-help-body h4 { margin: 0 0 .375rem; font-size: .8125rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--ph-text-muted); }
.ph-help-body p { margin: 0; }
.ph-help-body b, .ph-help-body strong { font-weight: 600; color: var(--ph-text); }
.ph-help-body a { color: var(--ph-accent-text); text-decoration: underline; text-underline-offset: 2px; }
.ph-help-body code { font-family: var(--ph-font-mono); font-size: .9375rem; padding: .05rem .3rem; border-radius: .25rem; background: var(--ph-surface-alt); color: var(--ph-text); word-break: break-word; }
.ph-help-does { color: var(--ph-text); }
/* Numbered steps: a round number badge per step. Counter, not list-style, so the badge lines up with the first line however long the step is. */
.ph-help-steps { list-style: none; margin: 0; padding: 0; counter-reset: ph-step; display: grid; gap: .5rem; }
.ph-help-steps > li { counter-increment: ph-step; position: relative; padding-left: 1.875rem; }
.ph-help-steps > li::before { content: counter(ph-step); position: absolute; left: 0; top: .125rem; width: 1.25rem; height: 1.25rem; border-radius: 9999px; display: flex; align-items: center; justify-content: center; font-size: .8125rem; font-weight: 700; background: var(--ph-accent-soft); color: var(--ph-accent-text); }
.ph-help-notes { list-style: none; margin: 0; padding: 0; display: grid; gap: .375rem; }
.ph-help-notes > li { position: relative; padding-left: 1rem; }
.ph-help-notes > li::before { content: ''; position: absolute; left: .125rem; top: .6em; width: .3125rem; height: .3125rem; border-radius: 9999px; background: var(--ph-text-faint); }
.ph-help-warn { padding: .5rem .75rem; border-left: 3px solid var(--ph-warn); border-radius: 0 .375rem .375rem 0; background: var(--ph-surface-alt); color: var(--ph-text); }
@media (max-width: 639.98px), (pointer: coarse) {
    .ph-help-body { font-size: 1.0625rem; }
}

/* ---- Phone / tablet pass: the Devices, DFS Center and Map tabs (templates/_devices.html, _dfs.html, _map.html; static/devices.js) ----
   Scoped to each page's own root (.ph-devices / .ph-dfs / .ph-map) and to .ph-stack (a table wrapper). Descendant selectors on purpose: two
   selectors beat Tailwind's one-class utilities whatever order the CDN injects its stylesheet in, so none of this needs !important (the one
   exception is the Map's JS-built editor sheet, whose fields carry inline styles - it says so). Everything sits inside a max-width query:
   lg (1024px) and up - the desktop - is untouched. */

/* Below md (768px) a table is a list of cards: a row is a card, its first cell the card's title, every other cell "label ... value" (the label
   is the cell's data-label). The header row goes away - Devices puts a Sort control above its list instead. From md up nothing changes. */
@media (max-width: 767.98px) {
    div.ph-stack { overflow-x: visible; }
    .ph-stack table { display: block; width: 100%; min-width: 0; }
    /* Hidden to the eye but still THERE for a screen reader: display:none would drop the column names (and, with the table's own layout gone, the
       whole table structure) from iOS VoiceOver - the templates give every table/row/cell an explicit ARIA role for the same reason. The "?"
       buttons in a header cell go (a control nobody can see must not be tabbable); Devices repeats them beside its Sort control. */
    .ph-stack thead { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
    .ph-stack thead button { display: none; }
    .ph-stack tbody { display: block; }
    .ph-stack tr { display: block; padding: .5rem 0; }
    .ph-stack td { display: flex; align-items: baseline; justify-content: space-between; gap: .75rem; padding: .25rem 1rem; text-align: right; overflow-wrap: anywhere; }
    .ph-stack td::before { content: attr(data-label); flex: 0 0 auto; max-width: 45%; text-align: left; font-family: var(--ph-font-sans); font-size: 14px; font-weight: 600; text-transform: none; letter-spacing: normal; color: var(--ph-text-muted); }
    /* No data-label: a cell that is all controls (a row of buttons), the card's title (its first cell) or an empty-state sentence (colspan). */
    .ph-stack td:not([data-label]) { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-start; gap: .5rem; text-align: left; }
    .ph-stack td:not([data-label])::before { content: none; }
    .ph-stack td:first-child { display: block; padding-top: .625rem; }
    .ph-stack td[colspan] { display: block; padding: 1.5rem 1rem; text-align: center; }
    /* A cell holding a long sentence or a form control takes the whole row, its label above it. */
    .ph-stack td.ph-stack-wide { display: block; text-align: left; }
    .ph-stack td.ph-stack-wide::before { display: block; max-width: none; margin-bottom: .25rem; }
    /* A cell that only fills a desktop column (an icon). */
    .ph-stack td.ph-stack-hide { display: none; }
    /* A long value that a desktop column cuts off with an ellipsis wraps in its card instead. */
    .ph-stack td.truncate { white-space: normal; max-width: none; }
}

/* Sizing for a thumb: a phone or tablet by width, or ANY touch screen - an iPad in landscape (1024/1180 wide) and a tablet with a keyboard are
   touch devices with no hover, so a 24px button there is as hard to hit as on a phone (resp-tools, responsive day run: measured 4-23 sub-44px
   controls per tab at 1024x768 and 1180x820 touch before this). A mouse at 1024+ is untouched. */
@media (max-width: 1023.98px), (pointer: coarse) {
    /* Fields: 16px stops iOS Safari zooming the page in on focus; 44px is the smallest target a thumb hits reliably. */
    :is(.ph-devices, .ph-dfs, .ph-map) :is(input:not([type="checkbox"], [type="radio"], [type="color"], [type="file"], [type="range"], [type="hidden"]), select, textarea) { min-height: 44px; font-size: 16px; }
    :is(.ph-devices, .ph-dfs, .ph-map) :is(input[type="range"], input[type="file"]) { min-height: 44px; }
    /* Buttons: 44px both ways. Not the toggle switches (their whole row is the target, below), the "?" help buttons (24px to look at, a
       44px hit area from their own ::before) or anything marked .ph-tap-ok (the RF Defrag overview's colour tiles: a data grid of hundreds). */
    :is(.ph-devices, .ph-dfs, .ph-map) button:not([role="switch"], [aria-label^="Help:"], .ph-tap-ok) { min-height: 44px; min-width: 44px; }
    :is(.ph-devices, .ph-dfs, .ph-map) label:has(> button[role="switch"]) { min-height: 44px; }
    :is(.ph-devices, .ph-dfs, .ph-map) label:has(> input[type="checkbox"], > input[type="radio"]) { min-height: 44px; }

    /* Map: Leaflet's own zoom buttons (30px) and the layers panel. */
    .ph-map .leaflet-touch .leaflet-bar a { width: 44px; height: 44px; line-height: 44px; }
    /* Attribution / scale sit above the phone's pull-tab and cast button (both live in the bottom 4rem), not under them. */
    .ph-map .leaflet-bottom { bottom: 4rem; }
    /* Never taller than the room between the layers button and the pull-tab; scrolls inside itself past that. Its rows are 44px tall on a
       phone, so the gap between them shrinks (space-y-3 leaves 12px). 15.5rem, not 15: measured - the panel starts 192px down on a phone and the
       pull-tab is 51px tall, so a panel tall enough to reach its cap (the Network switches made it so) ended 3px under the tab with 15rem. */
    .ph-map .ph-layers-panel { max-height: calc(100dvh - 15.5rem); overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; }
    .ph-map .ph-layers-panel > :not([hidden]) ~ :not([hidden]) { margin-top: .25rem; }
    /* Leaflet's popup close X (24px). */
    .ph-map .leaflet-container a.leaflet-popup-close-button { width: 44px; height: 44px; padding: 0; font-size: 26px; line-height: 44px; text-align: center; }
    /* The right-click editors' fields are styled inline (raw HTML shared with the desktop popup), so this is the one place !important is needed. */
    .ph-edit-sheet-content input, .ph-edit-sheet-content select { min-height: 44px !important; font-size: 16px !important; }
    .ph-edit-sheet-content button, .ph-edit-sheet-content [data-ph-ap-option], .ph-edit-sheet-content [data-ph-site-option] { min-height: 44px !important; }
}

/* === resp-admin (responsive day run): Admin tabs Dashboard / Sites / Hosts / Users / Agents / History / Customer Diagnostic, the standalone
   /customer-diagnostic page, their modals, and the phone menu ===
   Every one of those tab roots carries .ph-admin (templates/admin.html, customer_diagnostic_page.html); the shared confirm modal and error toast
   at the bottom of admin.html carry it too. Descendant selectors on purpose (two selectors beat Tailwind's one-class utilities whatever order the
   CDN injects its stylesheet in). Sizing rules sit in one query: a phone or tablet by width, or ANY touch screen (an iPad Pro is 1024+ wide). */
@media (max-width: 1023.98px), (pointer: coarse) {
    /* Fields: 16px stops iOS Safari zooming the page in on focus; 44px is the smallest target a thumb hits reliably. */
    .ph-admin :is(input:not([type="checkbox"], [type="radio"], [type="color"], [type="file"], [type="range"], [type="hidden"]), select, textarea) { min-height: 44px; font-size: 16px; }
    .ph-admin input[type="file"] { min-height: 44px; }
    /* Buttons: 44px both ways, except the toggle switches and the little round "?" / chip-remove buttons - those keep their look and get a 44px
       hit area from a ::before instead (a real 44px "?" next to a 12px label would blow the row up). */
    .ph-admin button:not([role="switch"], [aria-label^="Help:"], .ph-tap-ok, .ph-hit44) { min-height: 44px; min-width: 44px; }
    /* A small tag that is also a button (the "Missing Class" chip): keeps its size, gets the same invisible 44px hit area. */
    .ph-admin button.ph-hit44 { position: relative; }
    .ph-admin button.ph-hit44::before { content: ''; position: absolute; inset: -13px -8px; }
    .ph-admin button.rounded-full.w-4.h-4 { min-width: 0; min-height: 0; width: 24px; height: 24px; font-size: 13px; position: relative; }
    .ph-admin button.rounded-full.w-4.h-4::before, .ph-admin button[role="switch"]::before { content: ''; position: absolute; }
    .ph-admin button.rounded-full.w-4.h-4::before { inset: -10px; }
    .ph-admin button[role="switch"]::before { inset: -12px -6px; }
    .ph-admin label:has(> button[role="switch"]), .ph-admin label:has(> input[type="checkbox"], > input[type="radio"]) { min-height: 44px; }
    /* A bare checkbox in a list row: its label is the 44px target (its negative margin gives back the room, so the row does not grow). */
    .ph-admin label.ph-check44 { width: 44px; height: 44px; margin: -10px; }
    .ph-admin input[type="checkbox"] { width: 20px; height: 20px; }
    /* A row in a searchable pick list (History's host picker). */
    .ph-admin .ph-pick-row { min-height: 44px; display: flex; align-items: center; }
    /* A small text link in a caption: a 44px-tall target that gives its height back with a negative margin. */
    .ph-admin a.ph-link44 { display: inline-flex; align-items: center; min-height: 44px; margin-block: -14px; }
    /* Customer Diagnostic's weather radar: a map inside a scrolling page. One finger dragging UP/DOWN over it must scroll the PAGE (it used
       to pan the map, so a phone held the page hostage wherever the map was under the thumb); one-finger dragging is off on touch screens
       (customer_diagnostic.js _initRadarMap), so two fingers pinch / move the map. */
    .ph-admin #customerRadarMap.leaflet-container { touch-action: pan-y; }
    .ph-admin .leaflet-touch .leaflet-bar a { width: 44px; height: 44px; line-height: 44px; }
}
/* History > Compare on a phone: the legend stacks one row per host (long names do not sit side by side in 335px), so the chart gets 1.4rem more
   height per host past the third instead of squeezing its lines into what is left. */
@media (max-width: 639.98px) {
    .ph-admin .ph-compare-holder { height: calc(18rem + max(0, var(--ph-rows, 0) - 3) * 1.4rem); }
}
/* People who asked their device for less motion get no slide/fade on the Admin tabs' switches, toggles and modals. */
@media (prefers-reduced-motion: reduce) {
    .ph-admin, .ph-admin *, .ph-admin *::before, .ph-admin *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; }
}/* === end resp-admin === */

/* === resp-tools (responsive day run): Map / Devices / DFS Center on every screen size ===
   The phone rules for these three pages are in "Phone / tablet pass" above (their touch-size block now also covers ANY touch screen). What is
   here is what that pass did not cover: very wide screens and short (landscape) ones. */

/* The "Loading…" line above Devices / DFS Center's content is display:none once the data is in, but it still counts as a previous sibling for
   space-y-4: the content got 16px of margin above it for nothing (measured: 56px between the header and the page title, on a phone held sideways
   that is 17% of a 320px screen). */
.ph-devices.ph-devices > p[x-show="loading"] + div, .ph-dfs.ph-dfs > p[x-show="loading"] + div { margin-top: 0; }

/* A phone held sideways (short AND touch: 320-430px of height, of which the header takes 52 and the Map / RF Defrag toggle another 56): the map was
   207px tall on a 568x320 screen and a bottom sheet (85% of that) 176px - a title bar and one line. Measured before this: sheets 176 / 223 / 273px at
   568x320 / 667x375 / 844x390. Now: the toggle floats over the map's bottom-left corner (its row is gone, the map gets 56px back); a sheet takes the
   whole screen (its own X, a swipe down and Escape close it) and the layers panel is a drawer down the right side instead of a 80px strip. */
@media (max-height: 500px) and (pointer: coarse) {
    .ph-map .ph-map-wrap { position: relative; }
    .ph-map .ph-mode-row-over { position: absolute; bottom: .5rem; left: .5rem; z-index: 1100; }
    .ph-map [data-ph-drag-sheet] { position: fixed; top: 0; bottom: 0; max-height: none; margin-top: 0; border-radius: 0; }
    /* padding-bottom: the phone's floating cast button sits over this corner (it is outside the map's stacking context, so nothing in here can paint above it) -
       room under the last row lets it scroll clear of the button instead of ending up beneath it. */
    .ph-map .ph-layers-panel { position: fixed; top: 0; right: 0; bottom: 0; width: min(20rem, 75vw); max-height: none; margin: 0; padding-bottom: 4.5rem; border-radius: 0; }
    /* The drawer above runs down the whole right-hand edge of the screen - exactly where the "Map layers" button sits. Painted over the button (it is the
       later sibling) it hid the one control that closes it again; the button lifted above it instead (z-index) sat on the drawer's first toggles. Now the
       button steps aside to the drawer's left edge while it is open: nothing is covered, and the same button still closes it ("the trigger stays reachable
       while its own panel is open", as everywhere else this app collapses a menu into a drawer). */
    .ph-map .ph-layers-open { right: calc(min(20rem, 75vw) + .75rem); transition: right .15s ease; }
    @media (prefers-reduced-motion: reduce) { .ph-map .ph-layers-open { transition: none; } }
    /* The error toast hangs from the right edge, but on a screen this short the zoom buttons (top left) and the layers button (top right) are within reach of
       it: kept 4rem from both edges (a button is 44px + its 12px inset), and capped to the room above it - the map, not the header. A long message scrolls. */
    .ph-map [data-ph-error-toast] { right: 4rem; max-width: min(24rem, calc(100vw - 8rem)); max-height: max(3rem, calc(100dvh - var(--ph-toast-b, 80px) - 4.5rem)); overflow-y: auto; }
}

/* A big screen: the sheets ran the full width of the map (1862px at 1920, 3383px at 3440) - a pinglog strip and a list of links stretched into lines
   nobody can follow. Capped at 64rem and centred (the bottom edge and the height are unchanged). */
@media (min-width: 1100px) {
    .ph-map [data-ph-drag-sheet] { max-width: 64rem; margin-inline: auto; }
}

/* A very wide screen (2560, an ultra-wide 3440): Devices and DFS Center are tables and stat cards; stretched over 3300px a row's name and its
   status sat a metre apart. Capped and centred; 1920 and below are untouched (their content is 1800px wide at most). The Map is not capped:
   it is the one page here that wants every pixel. */
@media (min-width: 1900px) {
    .ph-devices, .ph-dfs { max-width: 2000px; margin-inline: auto; }
    /* ...and the RF Defrag Matrix, which is a table on the Map page (the map itself is not capped). */
    .ph-map .ph-defrag-card { max-width: 2000px; margin-inline: auto; width: 100%; }
}
/* === end resp-tools === */

/* ---- Review follow-ups (readability pass) -------------------------------------------------------------------------------------------------------- */
/* The call-centre note editor sits on a saturated status card in BOTH themes: its hint text must stay light there. The global ::placeholder rule above takes the
   theme's faint token, which is dark grey in the light theme (~1.5:1 on the card fill). More specific than that rule, so it wins. */
.ph-grid-root textarea[class*="placeholder-white"]::placeholder, .ph-grid-root input[class*="placeholder-white"]::placeholder { color: rgba(255, 255, 255, .9); }
/* The palette remap above moves every white-text coloured badge to its -800 shade, which can equal the status card's own fill (an AP-lockup chip on a critical
   card, a Grid Outage chip on a "bad" card): the chip's SHAPE vanished. A thin light inner edge keeps it a chip on any fill. */
.ph-grid-root :is([class*="bg-rose-600"], [class*="bg-amber-600"], [class*="bg-orange-600"], [class*="bg-yellow-600"], [class*="bg-emerald-600"])[class*="text-white"][class*="text-micro"] { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .5); }
/* Leaflet draws every tooltip, attribution and scale line at 11-12px by default; nothing in this app is read below 13px. */
.leaflet-container { font-size: .8125rem; }
.leaflet-container .leaflet-control-attribution, .leaflet-container .leaflet-control-scale-line { font-size: .8125rem; }

/* The connection banner (templates/_host_grid.html, data-testid="connection-banner"): a status plate. The panel sits on the page's own surface with a faint amber
   wash from the left (the "caution" token, never red - the cards are stale, not broken); the tile holds a flat-lining pulse whose dotted tail drifts slowly, so the
   feed reads as "trying" and not "frozen"; the chip's dot breathes. Motion only for people who have not asked for less. */
.ph-conn { background: var(--ph-surface); }
@supports (background: color-mix(in srgb, red 10%, blue)) {
    .ph-conn { background: linear-gradient(100deg, color-mix(in srgb, var(--ph-status-warning-text) 10%, var(--ph-surface)) 0%, var(--ph-surface) 46%); }
}
.ph-conn-badge { display: grid; place-items: center; width: 2.75rem; height: 2.75rem; flex: none; border-radius: .6rem; color: var(--ph-status-warning-text);
    border: 1px solid var(--ph-status-warning-text); background: var(--ph-surface); }
@supports (border-color: color-mix(in srgb, red 10%, blue)) {
    .ph-conn-badge { border-color: color-mix(in srgb, var(--ph-status-warning-text) 45%, transparent); background: color-mix(in srgb, var(--ph-status-warning-text) 12%, var(--ph-surface)); }
}
.ph-conn-chip { display: inline-flex; align-items: center; gap: .4rem; border-radius: 999px; padding: .05rem .6rem; font-size: .8125rem; font-weight: 600; line-height: 1.5;
    letter-spacing: .02em; color: var(--ph-status-warning-text); border: 1px solid var(--ph-status-warning-text); }
@supports (border-color: color-mix(in srgb, red 10%, blue)) {
    .ph-conn-chip { border-color: color-mix(in srgb, var(--ph-status-warning-text) 45%, transparent); }
}
.ph-conn-dot { width: .45rem; height: .45rem; border-radius: 50%; background: currentColor; }
.ph-conn-age { display: flex; flex-direction: column; justify-content: center; padding-left: 1rem; border-left: 1px solid var(--ph-border); min-width: 7.25rem; }
.ph-conn-age-label { font-size: .8125rem; line-height: 1.2; letter-spacing: .06em; text-transform: uppercase; font-weight: 600; color: var(--ph-text-muted); }
.ph-conn-age-value { font-family: 'Atkinson Hyperlegible Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; font-size: 1.125rem; line-height: 1.35; font-weight: 600; color: var(--ph-text); white-space: nowrap; }
@media (max-width: 639px) {
    .ph-conn-age { padding-left: 0; border-left: 0; min-width: 0; }
}
/* The top bar on a phone (the logo lives in the phone menu now, so the icon row has the whole bar): the six 44px boxes (search, filter, Notes, Paused, Alerts,
   avatar) are spread EVENLY over the bar's width - the same gap between every neighbour, the avatar at the far right edge - instead of being packed together
   at the right. The gap is what is left of the bar after the six boxes, over the five gaps: 4px at 320px (all there is room for: 6 x 44 + 5 x 4 = 284 of the 288 the bar has),
   ~13px at 360px, ~18px at 390px; never more than 20px, so a page with fewer icons does not look stretched. */
/* The "Sort, view & filter" menu on a phone uses the screen's whole width (8px from each edge) instead of a 256px box hung off the right: its position
   comes from phDropdownPos() as inline left / top, so the width rules here win with !important. */
@media (max-width: 639px) {
    .ph-hdr .ph-filter-menu { left: .5rem !important; right: .5rem; width: auto; }
}
@media (max-width: 639px) and (pointer: coarse) {
    .ph-hdr { --ph-hdr-gap: clamp(.25rem, calc((100vw - 2rem - 264px) / 5), 1.25rem); --ph-hdr-group-gap: 0px; }
}
@media (prefers-reduced-motion: no-preference) {
    .ph-flatline-gap { animation: ph-flatline-drift 2.4s linear infinite; }
    .ph-conn-dot { animation: ph-conn-breathe 1.8s ease-in-out infinite; }
}
@keyframes ph-flatline-drift { to { stroke-dashoffset: -9; } }
@keyframes ph-conn-breathe { 0%, 100% { opacity: 1; } 50% { opacity: .3; } }

/* The banner stays pinned while the page scrolls (sticky, z-30: under the header's menus at 48) and gets a soft shadow so cards passing beneath it read as beneath. */
.ph-conn { box-shadow: 0 10px 20px -12px rgb(0 0 0 / .55); }

/* A number that is a way to the hosts behind it (Quick View, Dashboard tiles): looks like what it was, shows it can be clicked (the label gets a chevron, the row a
   hover wash) and takes the global keyboard focus ring. */
.ph-stat-link { border-radius: .5rem; text-decoration: none; color: inherit; transition: background-color .12s ease; }
/* A bare number block: the hover wash reaches a little past its text, without moving anything (a tile that already has its own padding does not use this). */
.ph-stat-bleed { margin: -.25rem; padding: .25rem; }
.ph-stat-link:hover { background: var(--ph-surface-alt); }
.ph-stat-link .ph-stat-label::after { content: " \203A"; color: var(--ph-accent-text); font-weight: 700; }
.ph-stat-link:hover .ph-stat-label { text-decoration: underline; text-underline-offset: 3px; }
@media (pointer: coarse) { .ph-stat-link { min-height: 44px; } .ph-stat-link.flex { align-items: center; } }       /* a finger: the problem numbers on the Dashboard are links, so they are 44px targets */

/* ---- The Save / Cancel action bar (templates/_form_actions.html, static/form_state.js) ------------------------------------------------------------
   Every form, Settings section, dialog and inline editor ends with it: an optional status line, then [Cancel] [Save] at the bottom RIGHT - Save the
   primary and rightmost, Cancel a quiet outline immediately to its left. A disabled Save (nothing changed yet) is drawn in readable muted text on the
   quiet surface - never a faded accent (most readers here have poor eyesight). 44px tall on a touch screen; on a phone the two buttons share the
   full width (Cancel left, Save right). The status line ("Saved", "Changes discarded", an error) wraps above the buttons when there is no room. */
.ph-form-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; column-gap: .75rem; row-gap: .5rem;
    margin-top: .25rem; padding-top: .75rem; border-top: 1px solid var(--ph-border); }
/* The last thing in a stretched column (a Settings tile in a row of taller tiles): the bar sits at the very bottom, not floating above empty space. */
.flex-col > .ph-form-actions:last-child { margin-top: auto; }
.ph-form-status { flex: 1 1 10rem; min-width: 0; margin: 0; font-size: .875rem; line-height: 1.35; color: var(--ph-text-secondary); overflow-wrap: anywhere; }
.ph-form-status:empty { display: none; }
.ph-form-status--error { color: var(--ph-status-critical-text); font-weight: 600; }
.ph-form-buttons { display: flex; align-items: center; justify-content: flex-end; gap: .5rem; margin-left: auto; flex: 0 0 auto; max-width: 100%; }
.ph-btn-save, .ph-btn-cancel, .ph-btn-quiet { display: inline-flex; align-items: center; justify-content: center; min-height: 36px; min-width: 5rem;
    padding: .375rem 1rem; border-radius: .5rem; border: 1px solid transparent; font-size: .875rem; line-height: 1.25; font-weight: 600;
    white-space: nowrap; cursor: pointer; transition: background-color .12s ease, border-color .12s ease, color .12s ease; }
.ph-btn-save { background: var(--ph-accent); border-color: var(--ph-accent); color: #fff; }
.ph-btn-save:hover:not(:disabled) { background: var(--ph-accent-hover); border-color: var(--ph-accent-hover); }
.ph-btn-save:disabled { background: var(--ph-surface-alt); border-color: var(--ph-border); color: var(--ph-text-muted); cursor: not-allowed; }
.ph-btn-cancel, .ph-btn-quiet { background: transparent; border-color: var(--ph-border-input); color: var(--ph-text-secondary); }
.ph-btn-cancel:hover, .ph-btn-quiet:hover { background: var(--ph-surface-alt); color: var(--ph-text); }
.ph-btn-save:focus-visible, .ph-btn-cancel:focus-visible, .ph-btn-quiet:focus-visible { outline: 2px solid var(--ph-accent-text); outline-offset: 2px; }
/* A button at the very start of the bar (extra_left: a note's "Clear", a link end's "Back to auto-polling") stays at the far LEFT, also when the
   buttons wrap under it on a narrow card / phone. */
.ph-form-actions > button:first-child { margin-right: auto; }
/* A destructive extra (e.g. "Clear" a note) in the caller() slot at the left of Cancel. */
.ph-btn-quiet.ph-btn-danger { color: var(--ph-status-critical-text); }
/* A bar that shares a footer row with something else (the Host dialog's Power Reference switch): the row draws the rule, not the bar. */
.ph-form-actions--flush { margin-top: 0; padding-top: 0; border-top: 0; margin-left: auto; }
/* Small inline editors (a note in a dropdown, a card's note). */
.ph-form-actions--compact { padding-top: .5rem; column-gap: .5rem; }
.ph-form-actions--compact .ph-btn-save, .ph-form-actions--compact .ph-btn-cancel, .ph-form-actions--compact .ph-btn-quiet { min-height: 32px; min-width: 4.25rem; padding: .25rem .75rem; font-size: .8125rem; }
.ph-form-actions--compact .ph-form-status { font-size: .8125rem; }
/* On a coloured status card (white text on the status fill, >= 7:1): white outline Cancel, white Save with dark text, a disabled Save as a dashed
   white outline - all white-on-fill or dark-on-white, never a faded colour. */
.ph-form-actions--on-color { border-top-color: rgb(255 255 255 / .45); }
.ph-form-actions--on-color .ph-form-status { color: #fff; }
.ph-form-actions--on-color .ph-form-status--error { color: #fff; text-decoration: underline; text-underline-offset: 3px; }
.ph-form-actions--on-color .ph-btn-cancel, .ph-form-actions--on-color .ph-btn-quiet { background: rgb(0 0 0 / .25); border-color: rgb(255 255 255 / .8); color: #fff; }
.ph-form-actions--on-color .ph-btn-cancel:hover, .ph-form-actions--on-color .ph-btn-quiet:hover { background: rgb(0 0 0 / .4); color: #fff; }
.ph-form-actions--on-color .ph-btn-save { background: #fff; border-color: #fff; color: #0f172a; }
.ph-form-actions--on-color .ph-btn-save:hover:not(:disabled) { background: #e2e8f0; border-color: #e2e8f0; }
.ph-form-actions--on-color .ph-btn-save:disabled { background: rgb(0 0 0 / .25); border: 1px dashed rgb(255 255 255 / .8); color: #fff; }
.ph-form-actions--on-color .ph-btn-save:focus-visible, .ph-form-actions--on-color .ph-btn-cancel:focus-visible, .ph-form-actions--on-color .ph-btn-quiet:focus-visible { outline-color: #fff; }
@media (pointer: coarse) {
    .ph-btn-save, .ph-btn-cancel, .ph-btn-quiet,
    .ph-form-actions--compact .ph-btn-save, .ph-form-actions--compact .ph-btn-cancel, .ph-form-actions--compact .ph-btn-quiet { min-height: 44px; font-size: .875rem; }
}
@media (max-width: 639.98px) {
    .ph-form-actions--flush { flex: 1 1 100%; }
    .ph-form-buttons { flex: 1 1 100%; }
    .ph-form-buttons > .ph-btn-save, .ph-form-buttons > .ph-btn-cancel { flex: 1 1 0; }
}

/* === fixer-ui (review-ui 2026-10-13): dialogs that keep the focus, and finger-sized targets on the new screens ===
   A dialog marked data-ph-trap takes the keyboard focus when it opens (static/form_state.js) - the dialog box itself, or its panel marked
   data-ph-trap-focus. Neither is a control, so neither draws a focus ring of its own. */
[data-ph-trap]:focus, [data-ph-trap-focus]:focus { outline: none; }
/* The Solar calculator's field that stopped Calculate (static/solar.js _solFocusCalcField marks it aria-invalid until the next Calculate): it takes
   the focus and shows a red edge, so the field the message beside the Calculate button names is plain to see. */
[data-solar-page] [data-testid="sol-calc"] [aria-invalid="true"] { border-color: var(--ph-bad); box-shadow: 0 0 0 2px var(--ph-bad); }
@media (pointer: coarse) {
    /* The phone menu sheet's grab bar (templates/admin.html, _admin_nav.html: tap = close, drag = pull down) was 22px tall. 44px now; its lower
       half lies over the top of the "Menu" title row under it (non-interactive text), so the sheet is no taller - every section and Sign Out
       still fit a 320x568 screen (tests_e2e/mobile_menu_sheet.spec.js). */
    .ph-sheet-grip { min-height: 44px; margin-bottom: -22px; position: relative; z-index: 1; }
    /* A Settings section's chevron (templates/_settings_ui.html settings_section): a real 44x44 button. Its target was the whole title row
       already, but when a long title wraps, the "?" at its end sits 8px from the chevron and its own 44px hit area took the taps just left of
       the chevron. The chevron has no background, so only the icon's place moves (10px to the left). */
    .ph-settings .ph-sub-head > button[aria-label$="show or hide"] { min-width: 44px; min-height: 44px; }
}

/* === History: the graph grid (templates/_history.html, static/history.js; tests_e2e/history_layout.spec.js) ===
   Fluid from a 320 px phone to an ultrawide, and ALIGNED: every row of the page (the controls, the headline tiles, every section of graphs) is
   laid out by the room it has, never by a screen size, and what shares a row shares a top edge, a height and - for the graph cards - the same
   title row, explanation, plot and footer line.
   - The graph grid (.ph-hv-grid) is the same in every section (Reachability ... the Netonix, MikroTik and Solar sections included): as many columns
     as fit at 24rem or more, never more than 5 (an ultrawide window gets wider graphs, not a dozen thin ones); each column is a share of the row, so
     the columns of one section line up with the next section's. history.js (_hvBalance) then narrows that to the cards a section SHOWS: never more
     columns than cards (two graphs are two halves), and for more cards than columns the count that leaves the least of the last row empty (4 cards:
     2 x 2, not 3 + 1). A list card (.ph-hv-wide) takes a whole row.
   - Each graph card is four rows - head (title + Table button), explanation, plot, footer (footnote / table) - and with subgrid the cards that share a
     grid row share those rows too, so a two-line explanation next to a three-line one no longer pushes its plot (and legend) lower than its
     neighbour's. Without subgrid (an old browser) the cards still stretch to one height and stack their four parts as before.
   - The headline tiles (.ph-hv-tiles) fill the row 6 / 3 / 2 / 1 across by the room the row has (a size container, not the window: the side menu and
     the page padding take some of it), so there is never an empty third of the row beside them.
   - Gaps between rows of cards are each card's own bottom margin (not a grid row-gap), so a subgrid's four rows never inherit a gap of their own. */
.ph-hv-grid { --hv-gap: 1rem; display: grid; column-gap: var(--hv-gap); row-gap: 0; margin-bottom: calc(-1 * var(--hv-gap));
    grid-template-columns: repeat(auto-fill, minmax(min(100%, max(24rem, calc((100% - 4 * var(--hv-gap)) / 5))), 1fr)); }
.ph-hv-grid[data-hv-cols] { grid-template-columns: repeat(var(--hv-cols), minmax(0, 1fr)); }       /* set by history.js _hvBalance from the number of cards shown */
.ph-hv-grid > .ph-hv-card { margin-bottom: var(--hv-gap); min-width: 0; }
.ph-hv-grid > .ph-hv-wide { grid-column: 1 / -1; }
.ph-hv-chart { display: flex; flex-direction: column; gap: .5rem; }
.ph-hv-tail { display: flex; flex-direction: column; gap: .5rem; min-width: 0; }
@supports (grid-template-rows: subgrid) {
    .ph-hv-grid > .ph-hv-card:not(.ph-hv-wide) { grid-row: span 4; }
    .ph-hv-chart { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: subgrid; gap: 0; }
    .ph-hv-chart > .ph-hv-explain, .ph-hv-chart > .ph-hv-area { margin-top: .5rem; }
    .ph-hv-tail { display: block; }
    .ph-hv-tail > :not(template) { margin-top: .5rem; }
}
.ph-hv-area { --ph-hv-plot-h: 16rem; min-height: var(--ph-hv-plot-h); display: flex; flex-direction: column; min-width: 0; }
.ph-hv-area > .ph-hv-msg { flex: 1 1 auto; }
.ph-hv-area.ph-hv-compact { min-height: 0; }
.ph-hv-area.ph-hv-compact > .ph-hv-msg { flex: 0 0 auto; }
.ph-hv-plot { position: relative; height: var(--ph-hv-plot-h); min-width: 0; }
/* Fills its holder from the first frame (Chart.js then sets the same size inline): a canvas shown a frame before its chart is drawn would
   otherwise be the browser's default 300 x 150 for that frame. */
.ph-hv-plot canvas { display: block; width: 100%; height: 100%; transition: opacity .2s; }
.ph-hv-plot.ph-hv-dim canvas { opacity: .4; }
.ph-hv-kpi { container: hvkpi / inline-size; }
.ph-hv-tiles { display: grid; gap: .75rem; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ph-hv-tile { display: flex; flex-direction: column; }
@container hvkpi (max-width: 18.99rem) { .ph-hv-tiles { grid-template-columns: minmax(0, 1fr); } }
@container hvkpi (min-width: 35rem) { .ph-hv-tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@container hvkpi (min-width: 70rem) { .ph-hv-tiles { grid-template-columns: repeat(6, minmax(0, 1fr)); } }
/* The controls row: Show, Time range, then the summary (it takes the room that is left) and Refresh at the right end, all on the same bottom line;
   on a narrow screen the summary and Refresh wrap under the two switches. */
.ph-hv-bar { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .75rem 1.5rem; }
.ph-hv-bar-info { flex: 1 1 16rem; min-width: 0; }
.ph-hv-bar-refresh { flex: none; margin-left: auto; }
.ph-hv-seg { display: flex; flex-wrap: wrap; gap: .25rem; padding: .25rem; border-radius: .6rem; border: 1px solid var(--ph-border-input); background: var(--ph-bg); width: fit-content; max-width: 100%; }
@media (max-width: 639.98px) {
    .ph-hv-area { --ph-hv-plot-h: 14rem; }
    .ph-hv-seg { width: 100%; }
    .ph-hv-seg > button { flex: 1 1 auto; }
}
@media (prefers-reduced-motion: reduce) { .ph-hv-plot canvas { transition: none; } }
/* Compare: the graph grid below draws every metric for the picked hosts, so the Compare component's own one-metric chart (_multiping.html)
   would only repeat the Latency card with its own, separate time window. Its picker and live table stay. */
[data-testid="history"] div:has(> .ph-compare-holder) { display: none !important; }
/* Compare: one legend row per host on a narrow card - the plot grows with them instead of squeezing its lines. */
[data-hv-compare="true"] .ph-hv-area { --ph-hv-plot-h: calc(16rem + max(0, var(--ph-hv-rows, 0) - 2) * 1.5rem); }

/* The default WISPHero logo (static/wisphero-logo.png, shown when no organisation logo is uploaded): slate lettering on a transparent background, so it
   reads on the light theme as it is and is brightened on the dark one (the app is dark unless data-theme="light"). */
.ph-logo-default { filter: brightness(1.55); }
[data-theme="light"] .ph-logo-default { filter: none; }
