Files
FabledSteward/steward/static/steward.css
T
bvandeusenandClaude Opus 5.5 e8b75143f1
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m23s
CI / unit (push) Successful in 1m37s
CI / publish (push) Successful in 1m8s
feat(backup): check a backup against what is in place, changing nothing (#5823)
#5823 "Check a backup: compare what it would restore with what is in
place, without restoring".

- steward/backup/check.py: pg_restore writes the backup's rows as COPY
  text with no database involved. The rows in place go out through COPY in
  the same form. Tables are compared on their primary keys, over the columns
  both sides have, and grouped by area.
- Sealed values are opened with each side's own key and compared as
  plaintext digests, so a secret sealed twice still matches. They are never
  shown. A backup whose key can't open its own secrets fails the check.
- What Steward records as it runs (last-seen times, a schedule's last run,
  backup history, scraped UniFi fields) is left out of the comparison:
  contents.ACTIVITY, STATE_SETTINGS and HAND_SET_ONLY.
- Settings → Backups:
  - a Check button on each sent backup, which fetches it from the bucket
    (new s3.get_file);
  - "Check a file" to upload one, with the size limit raised before the
    CSRF check reads the form;
  - a results page.
- Each backup Steward sends checks itself once written, and its history
  row says how that went.
- New CLI command `steward check-backup FILE`. CI's image smoke step checks a
  real backup against its source and expects a match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:48:48 -04:00

639 lines
42 KiB
CSS

/* steward/static/steward.css — Steward's base stylesheet.
*
* Values come from theme.css, the stylesheet design system 6 ("Steward",
* inheriting FabledSword) generates. The short names below are Steward's
* aliases onto those tokens, kept so templates written against them still
* render; new work writes the --fs-* token. Contrast is a CI guard
* (tests/test_contrast.py): text 4.5:1 and control outlines 3:1 on page,
* card and hover, and status text on its own tint over each of them.
*/
:root {
--bg: var(--fs-surface-page);
--bg-card: var(--fs-surface-raised);
--bg-elevated: var(--fs-surface-hover);
--border: color-mix(in srgb, var(--fs-border-color) 70%, var(--fs-surface-raised));
--border-mid: var(--fs-border-color);
/* The outline that marks a control (input, select, ghost button): 3:1 on
page, card and hover (WCAG 1.4.11), where --border-mid is ~1.4-1.9:1. */
--border-control: color-mix(in srgb, var(--fs-text-secondary) 45%, var(--fs-border-color));
--text: var(--fs-text-primary);
--text-muted: var(--fs-text-secondary);
--text-dim: var(--fs-text-tertiary);
--gold: var(--fs-accent);
--gold-hover: color-mix(in srgb, var(--fs-accent) 85%, var(--fs-text-primary));
--gold-dim: var(--fs-accent-soft);
--accent: var(--fs-accent);
--accent-hover: var(--gold-hover);
/* Status: the -fg colour is the text and indicator; the -dim tint sits behind it.
Warning, error and their mix are lifted toward the primary text: the house
-fg falls to ~4.0:1 on its own tint over a hovered row (#5096). */
--green: var(--fs-success-fg);
--green-dim: color-mix(in srgb, var(--fs-success) 20%, transparent);
--yellow: color-mix(in srgb, var(--fs-warning-fg) 85%, var(--fs-text-primary));
--yellow-dim: color-mix(in srgb, var(--fs-warning) 20%, transparent);
/* Between warning and error: the worse-than-warning band (slow, degraded). */
--orange: color-mix(in srgb, color-mix(in srgb, var(--fs-warning-fg) 50%, var(--fs-error-fg)) 82%, var(--fs-text-primary));
--orange-dim: color-mix(in srgb, var(--fs-warning) 20%, transparent);
--red: color-mix(in srgb, var(--fs-error-fg) 85%, var(--fs-text-primary));
--red-dim: color-mix(in srgb, var(--fs-error) 20%, transparent);
/* Info (acknowledged): lighter than the house --fs-info-fg (50%), which falls
to 4.18:1 on its tint over a hovered row; 40% holds 5.09:1 there. */
--blue: color-mix(in srgb, var(--fs-info) 40%, var(--fs-text-primary));
--blue-dim: color-mix(in srgb, var(--fs-info) 20%, transparent);
--font-serif: var(--fs-font-display);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--fs-font-body); background: var(--bg); color: var(--text); line-height: var(--fs-leading-body); }
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
code { font-family: var(--fs-font-mono); font-size: 0.875em; background: var(--bg-elevated); padding: 0.1em 0.35em; border-radius: 3px; }
#candle-glow {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background:
radial-gradient(ellipse 80% 60% at 50% 40%, color-mix(in srgb, var(--fs-accent) 4.5%, transparent) 0%, transparent 70%),
radial-gradient(ellipse 50% 40% at 20% 80%, color-mix(in srgb, var(--fs-accent) 2.5%, transparent) 0%, transparent 65%);
}
/* ── App shell: persistent left sidebar + content column ───────────────────── */
.app-shell { display: flex; min-height: 100vh; position: relative; z-index: 1; }
.sidebar {
width: 220px; flex-shrink: 0; background: var(--bg-card);
border-right: 1px solid var(--border-mid);
display: flex; flex-direction: column;
position: sticky; top: 0; height: 100vh; z-index: 20;
}
.sidebar .brand {
font-family: var(--font-serif); font-weight: 500; font-size: 1.15rem;
letter-spacing: 0.01em; color: var(--gold); text-decoration: none;
display: flex; align-items: center; gap: 0.5rem;
padding: 0.85rem 1.25rem; border-bottom: 1px solid var(--border);
}
.sidebar .brand img { flex-shrink: 0; }
/* Only the nav list scrolls when long — the brand and user/logout stay pinned. */
.side-nav { flex: 1; min-height: 0; overflow-y: auto; padding: 0.75rem 0; }
.nav-group { margin-bottom: 0.9rem; }
.nav-group-label {
font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.09em;
color: var(--text-dim); font-weight: 500; padding: 0 1.25rem; margin-bottom: 0.3rem;
}
.side-nav a {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.4rem 1.25rem; font-size: 0.9rem; color: var(--text-muted);
border-left: 2px solid transparent; text-decoration: none;
transition: color .12s, background .12s, border-color .12s;
}
.side-nav a:hover { color: var(--text); background: var(--bg-elevated); }
.side-nav a.active {
color: var(--gold); border-left-color: var(--gold);
background: color-mix(in srgb, var(--gold) 8%, transparent);
}
/* A small count that needs attention: alerts beside the Alerts link, UniFi alarms. */
.count-badge {
margin-left: auto; padding: 0 0.45em; border-radius: var(--fs-radius-sm);
background: var(--red-dim); color: var(--red); font-size: 0.75rem; font-weight: 500;
font-variant-numeric: tabular-nums;
}
.side-user { border-top: 1px solid var(--border); padding: 0.7rem 1.25rem;
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.side-user form { margin: 0; }
.side-user a { color: var(--text-muted); font-size: 0.84rem; text-decoration: none;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.side-user a:hover, .side-user a[aria-current="page"] { color: var(--text); }
.side-user button {
color: var(--text-muted); font-size: 0.84rem; text-decoration: none;
display: flex; align-items: center; gap: 0.4rem;
background: none; border: 0; padding: 0; font-family: inherit; cursor: pointer;
}
.side-user button:hover { color: var(--text); }
/* Keyboard focus: a clear inset ring on every interactive sidebar element. */
.sidebar .brand:focus-visible,
.side-nav a:focus-visible,
.side-user button:focus-visible,
.side-user a:focus-visible {
outline: 2px solid var(--accent); outline-offset: -2px; color: var(--text);
}
.app-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar { display: none; } /* slim mobile bar; shown only under 900px */
.nav-scrim { display: none; }
/* Layout */
main { padding: 1.5rem 2rem; max-width: 1400px; margin: 0 auto; width: 100%; min-width: 0; box-sizing: border-box; position: relative; z-index: 1; }
/* The dashboard keeps a cap too, a wider one: past 12 columns' worth it adds
grid columns rather than stretching widgets (static/dash-layout.js). */
main.main-wide { max-width: 2400px; }
/* Responsive: sidebar slides off-canvas under 900px, toggled by ☰ */
@media (max-width: 900px) {
.sidebar {
position: fixed; left: 0; top: 0; bottom: 0; height: 100%;
transform: translateX(-100%); transition: transform .2s ease;
box-shadow: var(--fs-shadow-3);
}
body.nav-open .sidebar { transform: translateX(0); }
.topbar {
display: flex; align-items: center; gap: 0.75rem; height: 48px; padding: 0 1rem;
background: var(--bg-card); border-bottom: 1px solid var(--border-mid);
position: sticky; top: 0; z-index: 15;
}
.sidebar-toggle {
background: none; border: none; color: var(--text-muted); font-size: 1.3rem;
cursor: pointer; line-height: 1; padding: 0.25rem; border-radius: 4px;
}
.sidebar-toggle:hover { color: var(--text); }
.sidebar-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.topbar .brand-sm {
font-family: var(--font-serif); color: var(--gold); font-weight: 500;
font-size: 1.15rem; text-decoration: none;
}
body.nav-open .nav-scrim {
display: block; position: fixed; inset: 0; background: var(--fs-overlay); z-index: 18;
}
}
@media (prefers-reduced-motion: reduce) {
.sidebar { transition: none; }
.side-nav a { transition: none; }
}
/* Alerts */
.alert { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
.alert-error { background: var(--red-dim); border: 1px solid color-mix(in srgb, var(--fs-error) 45%, transparent); color: var(--red); }
.alert-success { background: var(--green-dim); border: 1px solid color-mix(in srgb, var(--fs-success) 60%, transparent); color: var(--green); }
/* Cards */
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; margin-bottom: 1rem; }
.card-flush { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; overflow-x: auto; overflow-y: hidden; margin-bottom: 1rem; }
/* A table wider than the screen scrolls sideways inside its card instead of
widening the page at phone width. Any card holding a table does this, so no
page has to remember a wrapper; .table-wrap is for a table outside a card.
(Only cards with a table: a plain card can hold a popover that must not clip.) */
.card:has(table.table) { overflow-x: auto; }
.table-wrap { overflow-x: auto; }
/* Typography */
.page-title { font-family: var(--font-serif); font-size: 1.6rem; font-weight: 500; color: var(--text); line-height: var(--fs-leading-heading); margin-bottom: 1.5rem; }
/* The tiny micro-label: body face (display face only at 18px and up), caps, tracked. */
.section-title { font-family: var(--fs-font-body); font-size: var(--fs-size-tiny); font-weight: 500; color: var(--text-muted); text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny); }
h1, h2 { font-family: var(--font-serif); font-weight: 500; }
h3, h4 { font-family: var(--fs-font-body); font-weight: 500; }
strong, b, th { font-weight: 500; }
/* Tables */
.table { width: 100%; border-collapse: collapse; }
.table th { text-align: left; padding: 0.6rem 1rem; color: var(--text-muted); font-weight: 500; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid var(--border-mid); }
.table td { padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); font-size: 0.9rem; }
/* The table's last row has no rule under it; a table may have several tbodys
(the Alerts list: one per rule, its hosts in another, maybe hidden). */
.table tbody:last-child tr:last-child td,
.table tbody:has(+ tbody[hidden]:last-child) tr:last-child td { border-bottom: none; }
.table tbody tr:hover td { background: var(--bg-elevated); }
.table .td-actions { text-align: right; white-space: nowrap; }
/* A header that sorts its column (sort_th in _macros.html): reads as the
header it is, with the arrow on the current sort. */
.sort-link { color: inherit; text-decoration: none; }
.sort-link:hover, .sort-active { color: var(--text); }
.sort-arrow { display: inline-block; min-width: 0.9em; text-align: center; }
/* Search and filters above a list; the search box takes the spare width. */
.filter-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 0.75rem; }
.filter-bar input[type=search] { flex: 1 1 16rem; width: auto; }
.filter-bar select { width: auto; flex: 0 0 auto; }
.page-actions .filter-bar { margin: 0; }
/* A small label that links somewhere, such as a group that filters the list. */
.chip { display: inline-block; margin: 0.1rem 0.3rem 0.1rem 0; padding: 0.05rem 0.45rem; font-size: 0.75rem;
color: var(--text-muted); border: 1px solid var(--border); border-radius: var(--fs-radius-sm); white-space: nowrap; }
.chip:hover { color: var(--text); border-color: var(--border-mid); }
/* A table's own heading inside its .card-flush: the name, a count and a link. */
.table-caption { display: flex; align-items: baseline; gap: 0.6rem; flex-wrap: wrap; margin: 0; padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-mid); font-family: var(--fs-font-body); font-size: 0.95rem; font-weight: 500; }
.table-caption .field-hint { margin-left: auto; }
/* A name cell cut short with "…", whole on hover (its title); a second line
under it; an image reference, long by nature, cut the same way. */
.cell-name { max-width: 18rem; }
.cell-name > a { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cell-sub { font-size: 0.75rem; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.table td.cell-image { max-width: 14rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; color: var(--text-muted); }
/* The Docker page's one-line answer, above its tiles (#5298). */
.page-verdict { margin: 0 0 0.75rem; font-size: 0.95rem; }
.table tr.row-off > td { opacity: 0.55; }
/* A covering alert rule's per-host rules (#5464), indented on a sunken band
under it, opened by its "N hosts" toggle (#5483). */
.table tr.row-child:not(:hover) > td { background: var(--bg); }
.table tr.row-child .cell-name { padding-left: 2rem; }
.row-toggle { background: none; border: 0; padding: 0; font: inherit; color: var(--text-muted);
cursor: pointer; text-decoration: underline dotted; text-underline-offset: 2px; }
.row-toggle::before { content: "▸"; display: inline-block; margin-right: 0.25rem;
transition: transform var(--fs-dur-fast) var(--fs-ease); }
.row-toggle[aria-expanded="true"]::before { transform: rotate(90deg); }
.row-toggle:hover { color: var(--text); }
.row-toggle:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); border-radius: var(--fs-radius-sm); }
/* The rule form's metric preview (#5475): now, then the last day beside its sparkline. */
.metric-preview { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem 1rem;
margin-top: 0.4rem; font-size: 0.85rem; }
.metric-preview-day { display: inline-flex; align-items: center; gap: 0.4rem; }
.metric-preview-spark { color: var(--text-muted); line-height: 0; }
/* A row that opens as a whole: its one .row-target link stretches over the
row, and the actions cell sits above it so its buttons keep their own
clicks. One real link per row, so keyboard and screen readers meet it once. */
.table tr.row-link { position: relative; cursor: pointer; }
.table tr.row-link .row-target::after { content: ""; position: absolute; inset: 0; }
.table tr.row-link .td-actions { position: relative; z-index: 1; cursor: auto; }
/* A second link inside a row that opens as a whole: it sits above the row's target. */
.table tr.row-link .row-inner { position: relative; z-index: 1; }
.table td.nowrap { white-space: nowrap; }
/* An audit entry's details, one fact a line (milestone 490). */
.audit-facts { font-size: 0.8rem; color: var(--text-muted); }
/* A divider row naming the group the rows under it fall in (Today, Older). */
/* A day group's divider reads above its rows: the primary text on a raised
band, where column headers are muted on the card (#5214). Not the accent:
that stays off chrome, and Steward's amber sits near its warning. */
.table tr.table-divider th { padding: 0.55rem 1rem; color: var(--text); background: var(--bg-elevated); border-top: 1px solid var(--border-mid); border-bottom: 1px solid var(--border-mid); }
.table tbody tr.table-divider:hover th { background: var(--bg-elevated); }
.cell-note { font-size: 0.78rem; color: var(--text-muted); margin-top: 0.2rem; }
/* An alert episode's own transitions, folded under it (#5316). */
.episode-steps { margin-top: 0.3rem; font-size: 0.8rem; }
.episode-steps summary { cursor: pointer; color: var(--text-muted); }
.episode-steps ol { margin: 0.35rem 0 0; padding-left: 1.1rem; display: grid; gap: 0.25rem; }
/* A problem section's heading carries its own count, so no tile repeats it (#5214). */
.section-count { margin-bottom: 0.6rem; }
.section-count > span:first-child { margin-left: 0.25rem; }
/* A service's resource history: CPU and memory side by side, each a figure
over a line that spans its half (docker/_usage_history.html). */
.history-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 1.25rem; }
.history-pair .spark { color: var(--blue); margin-top: 0.4rem; }
/* The Swarm page (#5303): one card per node, two or three a row on a wide
screen; each card's own services table. */
.swarm-group { margin-bottom: 2rem; }
.node-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 22rem), 1fr)); gap: 1rem; align-items: start; }
.node-card { margin-bottom: 0; display: flex; flex-direction: column; gap: 0.6rem; }
.node-card-head { display: flex; align-items: center; flex-wrap: wrap; gap: 0.4rem; margin: 0; font-family: var(--fs-font-body); font-size: 1rem; font-weight: 500; }
.node-card-head .chip { margin: 0; }
.node-card-figures { margin: 0; font-size: 0.85rem; }
.node-tasks th, .node-tasks td { padding: 0.35rem 0.5rem; font-size: 0.82rem; }
.node-tasks .cell-name { max-width: 12rem; }
.node-tasks .cell-name > a { display: inline-block; max-width: 100%; vertical-align: bottom; }
/* The Docker overview's one line per host of disk (#5306). */
.docker-disk-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.35rem 1.25rem; margin: 0 0 1rem; font-size: 0.85rem; }
.docker-disk-host { white-space: nowrap; }
/* The Disk page (#5304, #5305): a section per host; images by repository,
each repository's versions folded under it. */
.disk-host { margin: 2rem 0; }
.disk-host-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.6rem; margin: 0 0 0.75rem; font-family: var(--fs-font-body); font-size: 1.05rem; font-weight: 500; }
.disk-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-bottom: 1rem; }
.disk-actions form { margin: 0; }
.image-repos .cell-name { max-width: 28rem; }
.image-repos summary { cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-repos details[open] summary { margin-bottom: 0.5rem; }
.image-versions th, .image-versions td { padding: 0.3rem 0.5rem; font-size: 0.8rem; }
/* Forms */
.form-group { margin-bottom: 1rem; }
label { display: block; margin-bottom: 0.3rem; font-size: 0.85rem; color: var(--text-muted); font-family: var(--fs-font-body); }
input[type=text], input[type=search], input[type=email], input[type=password], input[type=number], input[type=url], select, textarea {
width: 100%; padding: 0.5rem 0.75rem; background: var(--bg); border: 1px solid var(--border-control);
border-radius: 5px; color: var(--text); font-size: 0.9rem; font-family: inherit;
transition: border-color .15s;
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
/* Keyboard focus is always visible: the design system's ring on anything
that takes focus. */
a:focus-visible, button:focus-visible, summary:focus-visible, [tabindex]:focus-visible,
input:focus-visible, select:focus-visible, textarea:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
select { cursor: pointer; }
textarea { resize: vertical; }
/* Form fields: .field wraps a .field-label, the .input and an optional .field-hint. */
.field { display: flex; flex-direction: column; gap: 0.2rem; margin-bottom: 0.9rem; }
.field[hidden] { display: none; }
.field-label { display: block; margin-bottom: 0; font-size: 0.75rem; color: var(--text-muted); }
.field-hint { font-size: 0.75rem; color: var(--text-dim); }
.field-error { font-size: 0.85rem; color: var(--red); margin-bottom: 0.75rem; }
.input { width: 100%; padding: 0.4rem 0.65rem; background: var(--bg); border: 1px solid var(--border-control); border-radius: var(--fs-radius-sm); color: var(--text); font-size: 0.88rem; font-family: inherit; }
.input:focus { outline: none; border-color: var(--accent); }
.input:disabled { opacity: var(--fs-disabled-opacity); }
/* Buttons */
.btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.45rem 1rem; background: var(--accent); color: var(--fs-surface-page); border: none; border-radius: 5px; cursor: pointer; font-size: 0.875rem; font-family: inherit; font-weight: 500; transition: background .15s; text-decoration: none; }
.btn:hover { background: var(--accent-hover); color: var(--fs-surface-page); }
.btn-sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; }
.btn-icon { padding: 0.3rem 0.5rem; line-height: 1; }
.btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border-control); }
.btn-ghost:hover { background: var(--bg-elevated); color: var(--text); }
.btn-danger { background: var(--fs-action-destructive); color: var(--fs-text-on-action); }
.btn-danger:hover { background: var(--fs-action-destructive-hover); color: var(--fs-text-on-action); }
.btn-warn { background: var(--yellow-dim); color: var(--yellow); border: 1px solid color-mix(in srgb, var(--fs-warning) 60%, transparent); }
.btn-warn:hover { background: color-mix(in srgb, var(--fs-warning) 32%, transparent); }
/* Badges / Status */
.badge { display: inline-block; padding: 0.2em 0.55em; border-radius: var(--fs-radius-sm); font-size: 0.75rem; font-weight: 500; letter-spacing: 0.02em; }
.badge-green { background: var(--green-dim); color: var(--green); }
.badge-red { background: var(--red-dim); color: var(--red); }
.badge-yellow { background: var(--yellow-dim); color: var(--yellow); }
.badge-orange { background: var(--orange-dim); color: var(--orange); }
.badge-gold { background: var(--gold-dim); color: var(--fs-accent-fg); }
.badge-dim { background: var(--bg-elevated); color: var(--text-muted); }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dot-up { background: var(--green); box-shadow: 0 0 4px var(--green); }
.dot-down { background: var(--red); }
.dot-warn { background: var(--yellow); }
.dot-dim { background: var(--text-dim); }
/* Tones the status_badge / status_dot macros draw (formatting.STATES). */
.badge-ok { background: var(--green-dim); color: var(--green); }
.badge-warn { background: var(--yellow-dim); color: var(--yellow); }
.badge-crit { background: var(--red-dim); color: var(--red); }
.badge-info { background: var(--blue-dim); color: var(--blue); }
.dot-ok { background: var(--green); }
.dot-crit { background: var(--red); }
.dot-info { background: var(--blue); }
.dot-label { margin-left: 0.35rem; font-size: 0.85rem; }
/* Text in a tone: status_class() results ('ok' / 'warn' / 'crit' / 'unknown')
and the STATES tones. */
.tone-ok { color: var(--green); }
.tone-warn { color: var(--yellow); }
.tone-crit { color: var(--red); }
.tone-info { color: var(--blue); }
.tone-dim, .tone-unknown { color: var(--text-dim); }
.tone-muted { color: var(--text-muted); }
/* A figure column in a table: right-aligned, digits of equal width so the
numbers line up down the column without a monospace face (#5279). */
.table th.num, .table td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
/* Empty state */
.empty { color: var(--text-muted); font-size: 0.9rem; padding: 2rem; text-align: center; }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; color: var(--text-muted); font-size: 0.9rem; padding: 2rem 1rem; text-align: center; }
/* Page header: title on the left, its actions on the right (page_header macro). */
.page-header { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem 1rem; margin-bottom: 1.5rem; }
.page-header .page-title { margin-bottom: 0; }
.page-subtitle { margin-top: 0.3rem; color: var(--text-muted); font-size: 0.9rem; }
.page-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
/* Stat tile (stat_tile macro): a label over one headline number. */
.stat-tile { display: flex; flex-direction: column; gap: 0.25rem; padding: 0.9rem 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--fs-radius-md); color: var(--text); min-width: 8rem; }
a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevated); color: var(--text); }
.stat-tile-label { font-size: var(--fs-size-tiny); font-weight: 500; color: var(--text-muted); text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny); }
.stat-tile-value { font-size: 1.6rem; font-weight: 500; line-height: 1.1; font-variant-numeric: tabular-nums; }
.stat-tile-hint { font-size: 0.78rem; color: var(--text-dim); }
.stat-ok .stat-tile-value { color: var(--green); }
.stat-warn .stat-tile-value { color: var(--yellow); }
.stat-crit .stat-tile-value { color: var(--red); }
.stat-info .stat-tile-value { color: var(--blue); }
.stat-dim .stat-tile-value { color: var(--text-dim); }
.stat-strip { display: grid; grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); gap: 0.75rem; margin-bottom: 1rem; }
/* "Needs attention": what is wrong, first on a page. */
.problem-card { border-color: color-mix(in srgb, var(--fs-error) 45%, var(--border)); }
.problem-list { list-style: none; display: flex; flex-direction: column; gap: 0.45rem; }
.problem-list li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.6rem; overflow-wrap: anywhere; }
/* A section's pages as tabs (section_tabs in _macros.html, #5328). It
scrolls inside itself on a phone rather than widening the page. */
.section-tabs { display: flex; gap: 0.25rem; margin: 0 0 1.25rem; border-bottom: 1px solid var(--border); overflow-x: auto; }
.section-tabs a { padding: 0.5rem 0.9rem; margin-bottom: -1px; border-bottom: 2px solid transparent;
color: var(--text-muted); font-size: 0.9rem; white-space: nowrap; }
.section-tabs a:hover { color: var(--text); }
.section-tabs a[aria-current="page"] { color: var(--text); border-bottom-color: var(--accent); }
/* A label/value list: <dl class="kv"><dt>…</dt><dd>…</dd></dl>. */
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 0.35rem 1.25rem; font-size: 0.88rem; }
.kv dt { color: var(--text-muted); }
/* What a backup check found, one line per row (settings/backup_check.html). */
.check-list { margin: 0 0 0 1.1rem; padding: 0; font-size: 0.88rem; line-height: 1.6; }
/* An Ansible run just started from the run form (ansible/run_started.html). */
.run-started-head { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; font-size: 0.9rem; }
.run-started-link { margin-left: auto; font-size: 0.82rem; }
.run-output {
margin: 0 0 0.5rem; padding: 0.75rem; background: var(--bg); border-radius: var(--fs-radius-sm);
font-family: var(--fs-font-mono); font-size: 0.78rem; color: var(--text-muted);
max-height: 400px; overflow-y: auto; white-space: pre-wrap;
}
/* A run's log on its page (ansible/run_detail.html), and what failed. */
.run-log { margin: 0; padding: 1rem; background: var(--bg); font-family: var(--fs-font-mono); font-size: 0.78rem;
color: var(--text-muted); max-height: 600px; overflow-y: auto; white-space: pre-wrap; }
.run-failures { padding: 0.75rem; border-radius: var(--fs-radius-sm); color: var(--red); max-height: 240px; }
.run-log-status { margin: 0; padding: 0.5rem 1rem; }
/* A playbook's run form (ansible/_run_form.html): options folded away, the
dry-run choice beside the button. */
.run-more { margin: 0 0 0.75rem; }
.run-more > summary { cursor: pointer; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 0.6rem; }
.field-check { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; font-weight: normal; color: var(--text-muted); }
.form-actions .field-check { margin: 0; }
.playbook-cell { max-width: 30rem; }
.inline-form { display: inline; }
/* The playbook itself, folded under its page. */
.playbook-source > summary { cursor: pointer; margin: 0; }
.playbook-source[open] > summary { margin-bottom: 0.75rem; }
.card-flush-note { margin: 0; padding: 0.75rem 1rem; }
/* A playbook's own words above its fields (_playbook_vars.html): what it does,
the destructive gate, then its variables. */
.pb-about { background: var(--bg); border-left: 3px solid var(--accent); border-radius: var(--fs-radius-sm);
padding: 0.5rem 0.7rem; margin: 0.25rem 0 0.6rem; font-size: 0.82rem; color: var(--text-muted); }
.pb-category { display: inline-block; margin-right: 0.4rem; padding: 0.05em 0.45em; border-radius: var(--fs-radius-sm);
background: var(--bg-elevated); color: var(--text-dim); font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; }
.pb-confirm { display: flex; align-items: flex-start; gap: 0.5rem; margin: 0 0 0.6rem; padding: 0.6rem 0.8rem; cursor: pointer;
background: color-mix(in srgb, var(--red) 10%, var(--bg-elevated)); border: 1px solid color-mix(in srgb, var(--red) 35%, var(--border));
border-radius: 6px; font-size: 0.82rem; font-weight: normal; }
.pb-confirm input { margin-top: 0.15rem; }
.pb-confirm strong { color: var(--red); }
.pb-vars-title { margin: 0.5rem 0 0.25rem; font-size: 0.82rem; color: var(--text-muted); font-weight: 500; }
.pb-vars-hint { margin: 0 0 0.6rem; }
.pb-var-name { color: var(--text-muted); font-weight: normal; font-size: 0.76rem; }
/* Monospaced data: ids, limits, tags, paths. */
.mono { font-family: var(--fs-font-mono); font-size: 0.85em; }
/* The host page's health header (_host_vitals.html). */
.host-health-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.35rem 0.75rem; margin-bottom: 0.75rem; }
.host-health-summary { font-size: 0.92rem; }
/* Its four tiles share the card's width (two a row on a phone); each sparkline
stretches across its tile. */
.host-health-tiles { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.host-health-tiles .stat-tile { min-width: 0; }
.host-health-tiles .stat-tile-hint { margin-top: 0.25rem; }
@media (max-width: 640px) { .host-health-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
/* Monitors and Agent side by side: equal height, and the row keeps the same
1rem gap below it as every other card (the panel's own card margin would add
a second). */
.host-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr)); gap: 1rem; margin-bottom: 1rem; }
.host-pair > * { min-width: 0; }
.host-pair .card { margin-bottom: 0; height: 100%; }
/* A small form over the page (native <dialog>; Esc closes it). Centred: the
reset at the top (* { margin: 0 }) takes away the browser's margin: auto. */
.modal { margin: auto; width: min(560px, calc(100vw - 2rem)); padding: 1.25rem; color: var(--text); background: var(--bg-card);
border: 1px solid var(--border); border-radius: var(--fs-radius-md); box-shadow: var(--fs-shadow-3); }
.modal::backdrop { background: var(--fs-overlay); }
.modal-title { font-family: var(--font-serif); font-size: 1.25rem; font-weight: 500; margin: 0 0 1rem; }
/* The confirm dialog (#5314): a question, what follows from it, then Cancel and the act. */
.modal-confirm { width: min(440px, calc(100vw - 2rem)); }
.modal-confirm .modal-title { margin-bottom: 0.5rem; overflow-wrap: anywhere; }
.confirm-detail { color: var(--text-muted); font-size: 0.9rem; margin: 0 0 1.25rem; overflow-wrap: anywhere; }
.modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
/* A form page in sections (#5317): capped, left-aligned under its crumbs. */
.form-page { max-width: 760px; }
.form-section > .section-title { margin-bottom: 0.9rem; }
.form-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr)); gap: 0 1rem; }
.form-pair[hidden] { display: none; }
/* The invite, forgot-password and reset pages (milestones 496, 497): the login
page's centred card, with labelled fields. */
.invite-page { max-width: 420px; margin: 4rem auto; }
.invite-title { font-family: var(--font-serif); font-size: 1.5rem; font-weight: 500; margin: 0 0 0.5rem; }
.invite-lede { color: var(--text-muted); margin: 0 0 1.25rem; }
.invite-submit { width: 100%; margin-top: 0.25rem; }
.login-forgot { text-align: center; margin: 1rem 0 0; font-size: 0.85rem; }
/* A link shown once to copy (an invite without mail): the value and its Copy button. */
.copy-once { display: flex; gap: 0.5rem; align-items: center; }
.copy-once .input { flex: 1; font-family: ui-monospace, monospace; font-size: 0.8rem; }
/* A column of checkboxes: a target's groups, a group's members. */
.check-list { display: grid; gap: 0.4rem; }
.ansible-fields { padding: 0.9rem 1rem 0.25rem; margin-bottom: 0.75rem; border: 1px solid var(--border); border-radius: var(--fs-radius-md); background: var(--bg-elevated); }
.form-actions { display: flex; gap: 0.75rem; margin: 0.5rem 0 1.5rem; }
.danger-zone { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
border-color: color-mix(in srgb, var(--fs-error) 45%, var(--border)); }
/* A filled bar for a percentage; the fill takes its grade from status_class. */
.meter { height: 8px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 4px; overflow: hidden; }
.meter-fill { height: 100%; }
.meter-ok { background: var(--green); }
.meter-warn { background: var(--yellow); }
.meter-crit { background: var(--red); }
.meter-unknown { background: var(--text-dim); }
.problem-detail { color: var(--text-muted); font-size: 0.82rem; }
/* Breadcrumbs */
/* Breadcrumb renders as a kicker line tucked directly above the page header
(.page-title), so nested and top-level views share one consistent header. */
.breadcrumb { display:flex; align-items:center; flex-wrap:wrap; gap:0.35rem; font-size:0.78rem; color:var(--text-dim); margin:0 0 0.45rem; }
.breadcrumb a { color:var(--text-muted); }
.breadcrumb a:hover { color:var(--text); }
.breadcrumb-sep { color:var(--text-dim); }
.breadcrumb-cur { color:var(--text-muted); }
/* Stat cards (dashboard) */
.stat-val { font-size: 2rem; font-weight: 500; line-height: 1; font-variant-numeric: tabular-nums; }
.stat-label { font-size: 0.8rem; color: var(--text-muted); margin-left: 0.3rem; }
/* Ping pills */
.ping-card { padding: 0.75rem 1.25rem; }
.ping-row { display:flex; align-items:center; gap:1rem; padding:0.45rem 0; border-bottom:1px solid var(--border); }
.ping-row:last-child { border-bottom:none; }
.ping-row-paused { opacity:0.7; }
.row-actions { display:flex; gap:0.35rem; flex-shrink:0; }
.ping-meta { min-width:120px; max-width:180px; flex-shrink:1; overflow:hidden; }
/* Monitors list columns (#5270): fixed widths under one header row. */
.mon-head { font-size:var(--fs-size-tiny); color:var(--text-dim); text-transform:uppercase; letter-spacing:var(--fs-tracking-tiny); padding-top:0; }
.mon-head .ping-pills { display:block; }
.ping-meta.mon-col-name { width:14rem; min-width:0; max-width:none; flex-shrink:0; }
.mon-latest { width:7.5rem; flex-shrink:0; text-align:right; font-size:0.8rem; font-variant-numeric:tabular-nums; white-space:nowrap; }
.mon-spark { color:var(--text-dim); margin-right:0.35rem; }
.mon-pills .pill { flex:1 1 0; min-width:4px; max-width:14px; } /* the strip fills its column */
.mon-up { width:4.2rem; flex-shrink:0; text-align:right; font-size:0.8rem; font-variant-numeric:tabular-nums; }
.mon-tls { width:3.5rem; flex-shrink:0; text-align:right; font-size:0.78rem; font-variant-numeric:tabular-nums; }
.mon-act { width:6.6rem; justify-content:flex-end; }
.ping-name { font-size:0.875rem; font-weight:500; display:block; overflow-wrap:anywhere; }
a.ping-name:hover { text-decoration:underline; }
/* Dashboard widget grid — Gridstack-rendered. Static (positioned but inert) in
view mode; the dashboard itself becomes the edit surface when Edit is toggled,
so drag-resize happens over live widget data. The bottom drawer is a fixed
overlay, so the dashboard width is identical entering/leaving edit mode. */
.grid-stack-item-content {
background:var(--bg-card); border:1px solid var(--border-mid); border-radius:8px;
padding:0.8rem 1rem; display:flex; flex-direction:column; overflow:hidden;
}
.grid-stack-item-content .dash-cell-body { flex:1; min-height:0; overflow:auto; }
/* The widget body is a query container, so fragments can restyle to their OWN
panel width (not the viewport) — widgets adapt as you resize them. */
.dash-cell-body { container-type:inline-size; }
/* Host-list widgets flow into columns as the panel widens: use the width, drop
the vertical deadspace; stay single-column (and untruncated) when narrow. */
.host-blocks { display:grid; grid-template-columns:1fr; gap:0 1.5rem; }
@container (min-width:520px) { .host-blocks { grid-template-columns:1fr 1fr; } }
@container (min-width:900px) { .host-blocks { grid-template-columns:1fr 1fr 1fr; } }
.panel-chrome { display:none; }
.panel-drag { cursor:grab; color:var(--text-dim); user-select:none; }
.panel-drag:active { cursor:grabbing; }
body.dash-editing .panel-chrome { display:inline-flex; align-items:center; }
body.dash-editing .grid-stack-item-content { outline:1px dashed var(--border-mid); outline-offset:-1px; }
body.dash-editing main { padding-bottom:46vh; }
.widget-drawer {
position:fixed; left:0; right:0; bottom:0; transform:translateY(100%);
transition:transform .25s ease; max-height:44vh; overflow-y:auto;
background:var(--bg-card); border-top:1px solid var(--border-mid);
box-shadow:var(--fs-shadow-3); z-index:60; padding:1rem 1.5rem 1.5rem;
}
body.dash-editing .widget-drawer { transform:translateY(0); }
.ping-addr { display:block; color:var(--text-muted); font-size:0.75rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ping-pills { display:flex; gap:2px; flex:1; min-width:0; overflow:hidden; align-items:center; }
.pill { display:inline-block; width:7px; height:22px; border-radius:2px; cursor:default; transition:opacity .1s; }
.pill:hover { opacity:.75; }
.pill-empty { background:var(--bg-elevated); }
.pill-up { background:var(--green); }
.pill-down { background:var(--red); }
.ping-cur { min-width:72px; text-align:right; font-size:0.85rem; font-variant-numeric:tabular-nums; }
.ping-cur-good { color:var(--green); }
.ping-cur-warn { color:var(--yellow); }
.ping-cur-bad { color:var(--orange); }
.ping-cur-down { color:var(--red); font-weight:500; }
.ping-cur-nd { color:var(--text-dim); }
/* Widget headers */
.widget-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem; }
.widget-title { font-family:var(--fs-font-body); font-size:var(--fs-size-tiny); font-weight:500; color:var(--text-muted); text-transform:uppercase; letter-spacing:var(--fs-tracking-tiny); }
.widget-link { font-size:0.8rem; color:var(--text-muted); }
/* A widget cell's own state (#5208): loading, couldn't load, and how fresh
the shown data is. Set by static/dash-cells.js. */
.widget-asof { font-size:var(--fs-size-tiny); color:var(--text-dim); white-space:nowrap; }
.widget-asof-failed { color:var(--yellow); }
.cell-state { font-size:0.85rem; color:var(--text-muted); padding:0.25rem 0; }
.cell-state-failed { display:flex; flex-direction:column; align-items:flex-start; gap:0.5rem; color:var(--yellow); }
.widget-link:hover { color:var(--text); }
/* Widget content, in the order of the widget-shape ruling (UI & Design):
a verdict line, the exceptions, then the healthy rest in a compact table
whose column labels appear once. Names cut off with … and show whole on
hover; the table never scrolls sideways. */
.w-verdict { font-size:0.85rem; margin-bottom:0.5rem; }
.w-table { width:100%; border-collapse:collapse; table-layout:fixed; font-size:0.82rem; font-variant-numeric:tabular-nums; }
.w-table th { text-align:right; font-weight:500; font-size:var(--fs-size-tiny); color:var(--text-dim); text-transform:uppercase; letter-spacing:var(--fs-tracking-tiny); padding:0 0 0.25rem 0.5rem; white-space:nowrap; }
.w-table td { text-align:right; padding:0.2rem 0 0.2rem 0.5rem; border-top:1px solid var(--border); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.w-table th:first-child, .w-table td:first-child { text-align:left; padding-left:0; }
.w-table .w-text { text-align:left; } /* a text column after the name: reads from the left, like its header */
.w-table td.w-name { overflow:hidden; text-overflow:ellipsis; }
.w-table td.w-name a { color:inherit; text-decoration:none; }
.w-table td.w-name a:hover { text-decoration:underline; }
.w-table .dot { margin-right:0.4rem; vertical-align:middle; }
.w-sub { display:block; font-size:0.72rem; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.w-more { font-size:0.75rem; color:var(--text-dim); padding-top:0.3rem; }
.w-section { font-size:var(--fs-size-tiny); color:var(--text-dim); text-transform:uppercase; letter-spacing:var(--fs-tracking-tiny); margin:0.5rem 0 0.2rem; }
/* The healthy rest: one line per item, as many columns as the widget's width
allows (it is a size container, so this follows the widget, not the window). */
.w-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(15rem, 1fr)); gap:0 1.25rem; }
.w-item { display:flex; align-items:center; gap:0.4rem; min-width:0; font-size:0.82rem; padding:0.12rem 0; }
.w-item .w-label { flex:1; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.w-item .w-label a { color:inherit; text-decoration:none; }
.w-item .w-meta { flex-shrink:0; font-size:0.72rem; color:var(--text-muted); font-variant-numeric:tabular-nums; }
.w-note { font-size:0.7rem; color:var(--text-dim); margin-top:0.3rem; }
.w-table col.w-barcol { width:10.2rem; }
/* Bars of a figure's recent past, inline before it (Busiest Containers, #5214):
the row's tone, faint, with the newest bar solid. A gap is a slice with no
samples. Heights are set per bar as a % of the row's roof. */
.w-bars { display:inline-flex; align-items:flex-end; gap:1px; height:0.95rem; width:4.3rem; margin-right:0.45rem; vertical-align:-0.15rem; }
.w-bars i { flex:1; min-height:1px; background:currentColor; opacity:0.4; border-radius:1px 1px 0 0; }
.w-bars i:last-child { opacity:1; }
.w-bars i.gap { background:none; }
/* The figure after a row of bars takes a fixed width, so the bars line up down the column whatever the figure's length. */
.w-fig { display:inline-block; min-width:4.9rem; text-align:right; }
@container (max-width: 26rem) { .w-wide-only { display:none; } .w-table col.w-barcol { width:5.5rem; } }
/* Time range selector */
.range-btn { background:none; border:1px solid var(--border-control); border-radius:4px; color:var(--text-muted); padding:0.18rem 0.5rem; cursor:pointer; font-size:0.75rem; font-family:inherit; transition:background .1s,color .1s,border-color .1s; }
.range-btn:hover { background:var(--bg-elevated); color:var(--text); border-color:var(--accent); }
.range-btn.active { background:var(--accent); border-color:var(--accent); color:var(--fs-surface-page); }
/* The Prometheus widget template editor (#5692): the YAML beside its live
preview, which is drawn at about a dashboard cell's width; stacked on a
narrow screen. */
.tpl-editor { display:grid; grid-template-columns:minmax(0,1fr) 24rem; gap:1rem; align-items:start; margin-bottom:1rem; }
@media (max-width: 900px) { .tpl-editor { grid-template-columns:1fr; } }
.tpl-text { width:100%; min-height:28rem; font-size:0.8rem; line-height:1.45; tab-size:2; white-space:pre; resize:vertical; }
.tpl-problems { margin:0.3rem 0 0; padding-left:1.1rem; font-size:0.82rem; }
.tpl-preview-head { display:flex; align-items:center; gap:0.75rem; margin-bottom:0.5rem; }
.tpl-preview-head .section-title { margin:0; }
.tpl-preview-head select { margin-left:auto; max-width:12rem; }