feat(ui): base stylesheet on the Steward design-system tokens; readable status colours (#5092)
#5092 "Tokens". base.html's inline palette was its own: white text on the gold button (1.81:1), a red at 3.26:1 on a card, a warning yellow at hue 42 beside the gold accent at 46, a lavender page title, serif labels at 13px and weights up to 700. - static/theme.css is design system 6's generated sheet (FabledSword + the Steward accent). static/steward.css is base.html's stylesheet moved out, with :root now aliases onto those tokens (--bg, --text-muted, --green…) so every template keeps rendering; it states no colour of its own. - Status text colours are Steward overrides recorded in the design system (tokens 182-184): success #5FB87A, warning #E08A3C, error #EC7A6C. The house -fg mixes read "up" as grey hint text (dE 8.2) and warning as the accent (dE 11.5); these are >= 4.5:1 on page, card, hover and their own 15-25% tints. --orange is now the midpoint of warning and error. - Primary button and active range button: dark text on gold (7.82:1). Danger button on the house destructive fill. Badges and alerts take the -fg colour on a tint; the gold badge uses --fs-accent-fg. - Type: Inter body, Fraunces only for page titles and h1/h2, JetBrains Mono for code; section and widget titles become the tiny caps label in the body face; weights capped at 500. - Removed: the lavender title and share-view gradient, #606080, and the undefined --card-bg, --accent-faint, --accent-bg, --bg-alt, --border-low and --row-alt (now real tokens); the dead settings/index.html. - Guards (tests/test_style_tokens.py): every var(--x) used is defined; steward.css states no colour or weight over 500; hex colours in templates may only go down (ceiling 75, from 81 before this change), each with a self-test that it can fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
</p>
|
||||
|
||||
{% if new_token %}
|
||||
<div class="card" style="background:var(--accent-bg);">
|
||||
<div class="card" style="background:var(--fs-accent-faint);">
|
||||
<h3>New token — copy this install command now</h3>
|
||||
<p style="font-size:0.82rem;color:var(--text-muted);">
|
||||
This is the only time the raw token is shown. Rotate if you lose it.
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
{% for oid_cfg in device.oids %}
|
||||
{% set label = oid_cfg.label if oid_cfg.label else oid_cfg.oid %}
|
||||
{% set val = device.readings.get(label) %}
|
||||
<div style="background:var(--bg-alt);border-radius:6px;padding:0.6rem 0.9rem;">
|
||||
<div style="background:var(--bg-elevated);border-radius:6px;padding:0.6rem 0.9rem;">
|
||||
<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.2rem;font-family:monospace;">{{ label }}</div>
|
||||
{% if val is not none %}
|
||||
<div style="font-size:1.1rem;font-weight:600;font-variant-numeric:tabular-nums;">
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not loop.last %}
|
||||
<div style="height:1px;background:var(--border-low);margin:0.1rem 0;"></div>
|
||||
<div style="height:1px;background:var(--border);margin:0.1rem 0;"></div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if total > devices|length %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{# ── Global stats bar ──────────────────────────────────────────────────────── #}
|
||||
{% if global_stat %}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;margin-bottom:1.25rem;padding:0.75rem 1rem;background:var(--card-bg);border:1px solid var(--border);border-radius:6px;font-size:0.85rem;font-variant-numeric:tabular-nums;">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;margin-bottom:1.25rem;padding:0.75rem 1rem;background:var(--bg-card);border:1px solid var(--border);border-radius:6px;font-size:0.85rem;font-variant-numeric:tabular-nums;">
|
||||
|
||||
{% if global_stat.open_conns_total is not none %}
|
||||
<span>
|
||||
@@ -122,6 +122,6 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<p style="color:#606080;">No Traefik metrics yet. Configure <code>metrics_url</code> in <code>config.yaml</code> under <code>plugins.traefik</code>.</p>
|
||||
<p style="color:var(--text-dim);">No Traefik metrics yet. Configure <code>metrics_url</code> in <code>config.yaml</code> under <code>plugins.traefik</code>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% if summary %}
|
||||
|
||||
{# ── Overview bar ─────────────────────────────────────────────────────────── #}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;margin-bottom:1.25rem;padding:0.75rem 1rem;background:var(--card-bg);border:1px solid var(--border);border-radius:6px;font-size:0.85rem;font-variant-numeric:tabular-nums;align-items:center;">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;margin-bottom:1.25rem;padding:0.75rem 1rem;background:var(--bg-card);border:1px solid var(--border);border-radius:6px;font-size:0.85rem;font-variant-numeric:tabular-nums;align-items:center;">
|
||||
<span>
|
||||
<span style="color:var(--text-muted);">Total</span>
|
||||
<span style="margin-left:0.4rem;font-weight:600;">{{ summary.total }}</span>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<th style="text-align:left;font-weight:normal;padding:0 0 0.4rem;">Message</th>
|
||||
</tr>
|
||||
{% for ev in events %}
|
||||
<tr style="{% if loop.index is divisibleby 2 %}background:var(--row-alt,rgba(255,255,255,0.02));{% endif %}">
|
||||
<tr style="{% if loop.index is divisibleby 2 %}background:var(--fs-table-stripe);{% endif %}">
|
||||
<td style="padding:0.25rem 0.5rem 0.25rem 0;color:var(--text-dim);font-size:0.77rem;white-space:nowrap;">
|
||||
{{ ev.event_time.strftime("%Y-%m-%d %H:%M:%S") }}
|
||||
</td>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{# ── WAN health bar ───────────────────────────────────────────────────────── #}
|
||||
{% if latest_wan %}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;margin-bottom:1.25rem;padding:0.75rem 1rem;background:var(--card-bg);border:1px solid var(--border);border-radius:6px;font-size:0.85rem;font-variant-numeric:tabular-nums;align-items:center;">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;margin-bottom:1.25rem;padding:0.75rem 1rem;background:var(--bg-card);border:1px solid var(--border);border-radius:6px;font-size:0.85rem;font-variant-numeric:tabular-nums;align-items:center;">
|
||||
<span>
|
||||
<span style="color:var(--text-muted);">WAN</span>
|
||||
<span style="margin-left:0.4rem;font-weight:600;color:{% if latest_wan.is_up %}var(--green){% else %}var(--red){% endif %};">
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/* 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 measured 2026-10-06
|
||||
* (#5092): every text colour here is at least 4.5:1 on page, card, hover
|
||||
* and its own 15-25% tint.
|
||||
*/
|
||||
: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);
|
||||
--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. */
|
||||
--green: var(--fs-success-fg);
|
||||
--green-dim: color-mix(in srgb, var(--fs-success) 20%, transparent);
|
||||
--yellow: var(--fs-warning-fg);
|
||||
--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, var(--fs-warning-fg) 50%, var(--fs-error-fg));
|
||||
--orange-dim: color-mix(in srgb, var(--fs-warning) 20%, transparent);
|
||||
--red: var(--fs-error-fg);
|
||||
--red-dim: color-mix(in srgb, var(--fs-error) 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 svg { 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);
|
||||
}
|
||||
.side-user { border-top: 1px solid var(--border); padding: 0.7rem 1.25rem; }
|
||||
.side-user form { margin: 0; }
|
||||
.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 {
|
||||
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%; box-sizing: border-box; position: relative; z-index: 1; }
|
||||
|
||||
/* 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: hidden; margin-bottom: 1rem; }
|
||||
|
||||
/* 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; }
|
||||
.table tbody 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; }
|
||||
|
||||
/* 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=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-mid);
|
||||
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); }
|
||||
select { cursor: pointer; }
|
||||
textarea { resize: vertical; }
|
||||
|
||||
/* 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-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border-mid); }
|
||||
.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); }
|
||||
|
||||
/* Empty state */
|
||||
.empty { color: var(--text-muted); font-size: 0.9rem; padding: 2rem; text-align: center; }
|
||||
|
||||
/* 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(--border-mid); }
|
||||
.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-meta { min-width:120px; max-width:180px; flex-shrink:1; overflow:hidden; }
|
||||
.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); }
|
||||
.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); }
|
||||
.widget-link:hover { color:var(--text); }
|
||||
|
||||
/* Time range selector */
|
||||
.range-btn { background:none; border:1px solid var(--border-mid); 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); }
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Steward — generated stylesheet
|
||||
* Source: design system 6.
|
||||
*
|
||||
* Purpose tokens only. This sheet declares what values MEAN; it styles
|
||||
* no elements. Buttons, tables and input schemes live as snippets that
|
||||
* reference these names, so each value is stated once and reused rather
|
||||
* than restated per element.
|
||||
*
|
||||
* Generated — edit the design system, not this file.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* accent */
|
||||
--fs-accent: #C8A840; /* Steward's single signature colour */
|
||||
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent); /* Pill, tag and badge backgrounds */
|
||||
--fs-accent-faint: color-mix(in srgb, var(--fs-accent) 8%, transparent); /* The faintest accent wash */
|
||||
--fs-accent-deep: color-mix(in srgb, var(--fs-accent) 70%, black); /* The accent, darkened — the far end of a CTA gradient */
|
||||
--fs-accent-wash: color-mix(in srgb, var(--fs-accent) 22%, transparent); /* The heaviest accent tint — selection and emphasis washes */
|
||||
--fs-gradient-cta: linear-gradient(135deg, var(--fs-accent), var(--fs-accent-deep)); /* Gradient for a deliberate brand moment */
|
||||
--fs-glow-cta: 0 2px 10px color-mix(in srgb, var(--fs-accent) 35%, transparent); /* Glow under a brand-moment CTA */
|
||||
--fs-glow-cta-hover: 0 4px 24px color-mix(in srgb, var(--fs-accent) 65%, transparent); /* Glow under a hovered brand-moment CTA */
|
||||
--fs-accent-fg: color-mix(in srgb, var(--fs-accent) 45%, var(--fs-text-primary)); /* Accent-coloured TEXT sitting on an accent tint — the readable partner of --fs-accent-soft / -faint / -wash */
|
||||
|
||||
/* action */
|
||||
--fs-action-primary: #4A5D3F; /* Save, Submit, Confirm */
|
||||
--fs-action-secondary: #8B7355; /* Non-destructive alternates */
|
||||
--fs-action-tertiary: var(--fs-border-color); /* Ghost / outline actions */
|
||||
--fs-action-destructive: var(--fs-destructive); /* Destructive actions — delete, purge, revoke */
|
||||
--fs-action-primary-hover: color-mix(in srgb, var(--fs-action-primary) 88%, white); /* Primary action, hovered */
|
||||
--fs-action-secondary-hover: color-mix(in srgb, var(--fs-action-secondary) 88%, white); /* Secondary action, hovered */
|
||||
--fs-action-destructive-hover: color-mix(in srgb, var(--fs-action-destructive) 88%, white); /* Destructive action, hovered */
|
||||
|
||||
/* border */
|
||||
--fs-border-color: #3F4651; /* Borders, dividers and ghost outlines */
|
||||
--fs-border: 0.5px solid var(--fs-border-color); /* The default structural border */
|
||||
--fs-border-hover: 0.5px solid color-mix(in srgb, var(--fs-text-secondary) 30%, transparent); /* Border on hover or emphasis */
|
||||
--fs-border-active: 2px solid var(--fs-accent); /* Selected card or active tab only */
|
||||
|
||||
/* elevation */
|
||||
--fs-shadow-1: 0 1px 0 rgba(0,0,0,0.4); /* Hairline lift */
|
||||
--fs-shadow-2: 0 4px 12px rgba(0,0,0,0.35); /* Dropdowns and popovers */
|
||||
--fs-shadow-3: 0 16px 40px rgba(0,0,0,0.5); /* Modals */
|
||||
|
||||
/* focus */
|
||||
--fs-focus-ring: 0 0 0 2px var(--fs-accent); /* Focus indication, as a box-shadow */
|
||||
|
||||
/* font */
|
||||
--fs-font-display: 'Fraunces', Georgia, serif; /* Display, headings and wordmarks */
|
||||
--fs-font-body: 'Inter', system-ui, sans-serif; /* Body text, UI and labels */
|
||||
--fs-font-mono: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; /* Code, terminal output and monospaced data */
|
||||
|
||||
/* icon */
|
||||
--fs-icon-stroke: 1.5px; /* Icon stroke width at 24px */
|
||||
--fs-icon-stroke-sm: 1px; /* Icon stroke width at 16px */
|
||||
|
||||
/* layout */
|
||||
--fs-layout-page-max: 1200px; /* Maximum page width */
|
||||
--fs-layout-page-pad: 1rem; /* Horizontal page padding */
|
||||
--fs-layout-sidebar: 260px; /* Sidebar / nav rail width */
|
||||
--fs-layout-header: 52px; /* App header height */
|
||||
|
||||
/* motion */
|
||||
--fs-ease: cubic-bezier(0.2, 0.6, 0.2, 1); /* The one easing curve, used by every transition */
|
||||
--fs-dur-fast: 120ms; /* Hovers, colour and border changes */
|
||||
--fs-dur-base: 180ms; /* Most state changes */
|
||||
--fs-dur-slow: 280ms; /* Larger surface or layout shifts */
|
||||
|
||||
/* radius */
|
||||
--fs-radius-sm: 4px; /* Pills, tags and code spans */
|
||||
--fs-radius-md: 8px; /* Buttons, inputs and small cards */
|
||||
--fs-radius-lg: 12px; /* Cards, panels and modals */
|
||||
--fs-radius-xl: 16px; /* Hero containers and major surfaces */
|
||||
--fs-radius-pill: 9999px; /* Fully rounded — avatars, toggles */
|
||||
|
||||
/* semantic */
|
||||
--fs-success: var(--fs-action-primary); /* Success states */
|
||||
--fs-warning: #8B6F1E; /* Warnings */
|
||||
--fs-error: #C04A1F; /* Errors — something went wrong */
|
||||
--fs-info: #3D5A6E; /* Informational states */
|
||||
--fs-destructive: #6B2118; /* Irreversible actions */
|
||||
--fs-error-fg: #EC7A6C; /* Error / down / firing as TEXT or a status indicator */
|
||||
--fs-warning-fg: #E08A3C; /* Warning / degraded / pending as TEXT or a status indicator */
|
||||
--fs-info-fg: color-mix(in srgb, var(--fs-info) 50%, var(--fs-text-primary)); /* Info colour when it is TEXT: a status pill's label, an informational line. Not for fills or borders. */
|
||||
--fs-success-fg: #5FB87A; /* Success / up / ok as TEXT or a status indicator: badge labels, status dots, "up" counts */
|
||||
|
||||
/* space */
|
||||
--fs-space-1: 4px; /* Spacing step 1 */
|
||||
--fs-space-2: 8px; /* Spacing step 2 */
|
||||
--fs-space-3: 12px; /* Spacing step 3 */
|
||||
--fs-space-4: 16px; /* Spacing step 4 */
|
||||
--fs-space-5: 20px; /* Spacing step 5 */
|
||||
--fs-space-6: 24px; /* Spacing step 6 */
|
||||
--fs-space-7: 32px; /* Spacing step 7 */
|
||||
--fs-space-8: 48px; /* Spacing step 8 */
|
||||
--fs-space-9: 64px; /* Spacing step 9 */
|
||||
--fs-space-10: 96px; /* Spacing step 10 */
|
||||
|
||||
/* state */
|
||||
--fs-disabled-opacity: 0.5; /* Opacity for disabled controls */
|
||||
--fs-overlay: rgba(0, 0, 0, 0.65); /* Scrim behind modals and dialogs */
|
||||
|
||||
/* surface */
|
||||
--fs-surface-page: #14171A; /* Page background, the deepest surface */
|
||||
--fs-surface-raised: #1E2228; /* Cards and raised elements */
|
||||
--fs-surface-hover: #2C313A; /* Hovered surfaces, secondary elevation */
|
||||
--fs-surface-code: var(--fs-surface-page); /* Code block background */
|
||||
--fs-surface-code-inline: var(--fs-surface-raised); /* Inline code span background */
|
||||
--fs-table-stripe: color-mix(in srgb, var(--fs-text-primary) 3%, transparent); /* Alternating table row tint */
|
||||
|
||||
/* text */
|
||||
--fs-text-primary: #E8E4D8; /* Primary text on a page or raised surface — body, headings, labels */
|
||||
--fs-text-secondary: #C2BFB4; /* Secondary text and captions */
|
||||
--fs-text-tertiary: #9C9A92; /* Hints and metadata */
|
||||
--fs-text-on-action: #E8E4D8; /* Text on a filled coloured surface — action buttons, semantic badges, the accent */
|
||||
--fs-text-tertiary-fg: color-mix(in srgb, var(--fs-text-tertiary) 55%, var(--fs-text-primary)); /* Tertiary text on a tertiary tint — a muted chip that stays readable */
|
||||
--fs-text-secondary-fg: color-mix(in srgb, var(--fs-text-secondary) 90%, var(--fs-text-primary)); /* Secondary text on a secondary tint — completes the pattern; the value barely moves */
|
||||
|
||||
/* type */
|
||||
--fs-size-display: 40px; /* Display — display face, medium weight */
|
||||
--fs-size-h1: 32px; /* H1 — display face, medium weight */
|
||||
--fs-size-h2: 24px; /* H2 — display face, medium weight */
|
||||
--fs-size-h3: 18px; /* H3 — body face, medium weight */
|
||||
--fs-size-body: 15px; /* Body — body face, regular weight */
|
||||
--fs-size-body-sm: 13px; /* Small body — body face, regular weight */
|
||||
--fs-size-label: 12px; /* Label — body face, medium weight */
|
||||
--fs-size-code: 13px; /* Code — mono face, regular weight */
|
||||
--fs-size-tiny: 11px; /* Tiny micro-label — body face, medium weight */
|
||||
--fs-weight-regular: 400; /* Regular weight */
|
||||
--fs-weight-medium: 500; /* Medium weight — the heaviest the system goes */
|
||||
--fs-leading-heading: 1.3; /* Line height for headings */
|
||||
--fs-leading-body: 1.5; /* Line height for body text */
|
||||
--fs-leading-longform: 1.7; /* Line height for long-form reading surfaces */
|
||||
--fs-tracking-tiny: 0.08em; /* Letter-spacing on the tiny uppercase label */
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
/* accent */
|
||||
--fs-accent-wash: color-mix(in srgb, var(--fs-accent) 20%, transparent);
|
||||
--fs-glow-cta-hover: 0 4px 20px color-mix(in srgb, var(--fs-accent) 55%, transparent);
|
||||
|
||||
/* border */
|
||||
--fs-border-color: #D9D6CE;
|
||||
|
||||
/* state */
|
||||
--fs-overlay: rgba(0, 0, 0, 0.45);
|
||||
|
||||
/* surface */
|
||||
--fs-surface-page: #F5F1E8;
|
||||
--fs-surface-raised: #FBF8F0;
|
||||
--fs-surface-hover: #EFEAE0;
|
||||
--fs-surface-code: #EBEDF0;
|
||||
--fs-surface-code-inline: #EBEDF0;
|
||||
|
||||
/* text */
|
||||
--fs-text-primary: #14171A;
|
||||
--fs-text-secondary: #5A5852;
|
||||
--fs-text-tertiary: #9A9890;
|
||||
}
|
||||
|
||||
/* SUPERSEDES — write the token, not the literal.
|
||||
* #c84048 -> --fs-error-fg
|
||||
* #ff8090 -> --fs-error-fg
|
||||
* #ffb0b8 -> --fs-error-fg
|
||||
* #ffb0b0 -> --fs-error-fg
|
||||
* #d4a840 -> --fs-warning-fg
|
||||
* #c87840 -> --fs-warning-fg
|
||||
* var(--fs-info) -> --fs-info-fg
|
||||
* #4aa86a -> --fs-success-fg
|
||||
* #90f0b0 -> --fs-success-fg
|
||||
* #fff -> --fs-text-on-action
|
||||
* #ffffff -> --fs-text-on-action
|
||||
* white -> --fs-text-on-action
|
||||
* bold -> --fs-weight-medium
|
||||
* bolder -> --fs-weight-medium
|
||||
*/
|
||||
@@ -56,7 +56,7 @@
|
||||
</div>
|
||||
|
||||
{% if not sd.playbooks %}
|
||||
<p style="color:#606080;font-size:0.9rem;">No playbooks found at this path.</p>
|
||||
<p style="color:var(--text-dim);font-size:0.9rem;">No playbooks found at this path.</p>
|
||||
{% else %}
|
||||
<table class="table" style="margin-bottom:1rem;">
|
||||
<thead>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<pre id="sse-output-{{ run.id }}"
|
||||
style="background:#080810;padding:0.75rem;border-radius:4px;font-family:monospace;font-size:0.78rem;color:#a0c0a0;max-height:400px;overflow-y:auto;white-space:pre-wrap;margin:0 0 0.5rem 0;">
|
||||
</pre>
|
||||
<div id="sse-status-{{ run.id }}" style="font-size:0.85rem;color:#606080;">
|
||||
<div id="sse-status-{{ run.id }}" style="font-size:0.85rem;color:var(--text-dim);">
|
||||
Streaming output…
|
||||
</div>
|
||||
<script>
|
||||
|
||||
+2
-264
@@ -16,270 +16,8 @@
|
||||
<script src="{{ url_for('static', filename='vendor/htmx-2.0.4.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='vendor/chart-4.4.4.umd.min.js') }}"></script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='fonts/fonts.css') }}">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #131315;
|
||||
--bg-card: #1d1d20;
|
||||
--bg-elevated: #24242a;
|
||||
--border: #30303a;
|
||||
--border-mid: #3a3a44;
|
||||
--text: #d4d0c0;
|
||||
--text-muted: #b8b8b0;
|
||||
--text-dim: #8a8a92;
|
||||
--gold: #c8a840;
|
||||
--gold-hover: #d8b850;
|
||||
--gold-dim: #2a2410;
|
||||
--pewter: #8a8a92;
|
||||
--accent: var(--gold);
|
||||
--accent-hover: var(--gold-hover);
|
||||
--green: #4aa86a;
|
||||
--green-dim: #162a1c;
|
||||
--yellow: #d4a840;
|
||||
--yellow-dim: #2a2410;
|
||||
--orange: #c87840;
|
||||
--orange-dim: #2a1a10;
|
||||
--red: #c84048;
|
||||
--red-dim: #2a1014;
|
||||
--font-serif: 'Fraunces', Georgia, serif;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { color: var(--accent-hover); }
|
||||
code { font-family: ui-monospace, monospace; 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%, rgba(200, 168, 64, 0.045) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 50% 40% at 20% 80%, rgba(200, 168, 64, 0.025) 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: 700; 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 svg { 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: 600; 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);
|
||||
}
|
||||
.side-user { border-top: 1px solid var(--border); padding: 0.7rem 1.25rem; }
|
||||
.side-user form { margin: 0; }
|
||||
.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 {
|
||||
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%; box-sizing: border-box; position: relative; z-index: 1; }
|
||||
|
||||
/* 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: 0 0 30px rgba(0,0,0,0.5);
|
||||
}
|
||||
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: 700;
|
||||
font-size: 1.05rem; text-decoration: none;
|
||||
}
|
||||
body.nav-open .nav-scrim {
|
||||
display: block; position: fixed; inset: 0; background: rgba(0,0,0,0.5); 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 #6a1820; color: #ffb0b8; }
|
||||
.alert-success { background: var(--green-dim); border: 1px solid #1a5a28; color: #90f0b0; }
|
||||
|
||||
/* 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: hidden; margin-bottom: 1rem; }
|
||||
|
||||
/* Typography */
|
||||
.page-title { font-family: var(--font-serif); font-size: 1.6rem; font-weight: 600; color: #c8b4f8; margin-bottom: 1.5rem; letter-spacing: 0.01em; }
|
||||
.section-title { font-family: var(--font-serif); font-size: 0.82rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
h1, h2, h3 { font-family: var(--font-serif); }
|
||||
|
||||
/* 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; }
|
||||
.table tbody 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; }
|
||||
|
||||
/* Forms */
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
label { display: block; margin-bottom: 0.3rem; font-size: 0.85rem; color: var(--text-muted); font-family: var(--font-serif); }
|
||||
input[type=text], 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-mid);
|
||||
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); }
|
||||
select { cursor: pointer; }
|
||||
textarea { resize: vertical; }
|
||||
|
||||
/* Buttons */
|
||||
.btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.45rem 1rem; background: var(--accent); color: #fff; 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: #fff; }
|
||||
.btn-sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; }
|
||||
.btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border-mid); }
|
||||
.btn-ghost:hover { background: var(--bg-elevated); color: var(--text); }
|
||||
.btn-danger { background: #7a1820; color: #ffb0b0; }
|
||||
.btn-danger:hover { background: #9a2030; }
|
||||
.btn-warn { background: var(--yellow-dim); color: var(--yellow); border: 1px solid #6a5000; }
|
||||
.btn-warn:hover { background: #4a4000; }
|
||||
|
||||
/* Badges / Status */
|
||||
.badge { display: inline-block; padding: 0.2em 0.55em; border-radius: 4px; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.03em; }
|
||||
.badge-green { background: var(--green-dim); color: var(--green); }
|
||||
.badge-red { background: var(--red-dim); color: #ff8090; }
|
||||
.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(--gold); }
|
||||
.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); }
|
||||
|
||||
/* Empty state */
|
||||
.empty { color: var(--text-muted); font-size: 0.9rem; padding: 2rem; text-align: center; }
|
||||
|
||||
/* 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(--border-mid); }
|
||||
.breadcrumb-cur { color:var(--text-muted); }
|
||||
|
||||
/* Stat cards (dashboard) */
|
||||
.stat-val { font-size: 2rem; font-weight: 700; line-height: 1; }
|
||||
.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-meta { min-width:120px; max-width:180px; flex-shrink:1; overflow:hidden; }
|
||||
.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:0 -10px 30px rgba(0,0,0,0.45); 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); }
|
||||
.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:bold; }
|
||||
.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(--font-serif); font-size:0.8rem; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.06em; }
|
||||
.widget-link { font-size:0.8rem; color:var(--text-muted); }
|
||||
.widget-link:hover { color:var(--text); }
|
||||
|
||||
/* Time range selector */
|
||||
.range-btn { background:none; border:1px solid var(--border-mid); 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:#fff; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='theme.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='steward.css') }}">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<span style="font-weight:700;font-size:1rem;background:linear-gradient(110deg,#c8b4f8,#c9a84c);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;">
|
||||
<span style="font-weight:500;font-size:1.15rem;color:var(--gold);">
|
||||
{{ dashboard.name }}
|
||||
</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);">Steward · read-only view</span>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<span style="flex-shrink:0;font-size:0.7rem;font-weight:600;text-transform:uppercase;
|
||||
letter-spacing:0.06em;padding:0.15em 0.5em;border-radius:3px;
|
||||
background:{% if src.type == 'git' %}var(--accent-faint){% else %}var(--bg-elevated){% endif %};
|
||||
background:{% if src.type == 'git' %}var(--fs-accent-faint){% else %}var(--bg-elevated){% endif %};
|
||||
color:{% if src.type == 'git' %}var(--accent){% else %}var(--text-muted){% endif %};">
|
||||
{{ src.type }}
|
||||
</span>
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
{# steward/templates/settings/index.html #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Settings — Steward{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="page-title">Settings</h1>
|
||||
<form method="post" action="/settings/">{{ csrf_field() }}
|
||||
<div style="display:grid;gap:1.5rem;">
|
||||
|
||||
{# ── General ──────────────────────────────────────────────────────────── #}
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="margin-bottom:1rem;">General</h2>
|
||||
<div class="form-group">
|
||||
<label>Session lifetime (hours)</label>
|
||||
<input type="number" name="session.lifetime_hours" min="1" max="720"
|
||||
value="{{ settings['session.lifetime_hours'] }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Data retention (days)</label>
|
||||
<input type="number" name="data.retention_days" min="1"
|
||||
value="{{ settings['data.retention_days'] }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Monitor poll interval (seconds)</label>
|
||||
<input type="number" name="monitors.poll_interval_seconds" min="10"
|
||||
value="{{ settings['monitors.poll_interval_seconds'] }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── SMTP ─────────────────────────────────────────────────────────────── #}
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="margin-bottom:1rem;">Email (SMTP)</h2>
|
||||
<div class="form-group">
|
||||
<label>SMTP host</label>
|
||||
<input type="text" name="smtp.host" value="{{ settings['smtp.host'] }}" placeholder="mail.example.com">
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<div class="form-group">
|
||||
<label>Port</label>
|
||||
<input type="number" name="smtp.port" value="{{ settings['smtp.port'] }}">
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:0.5rem;padding-top:1.5rem;">
|
||||
<input type="checkbox" name="smtp.tls" id="smtp-tls"
|
||||
{% if settings['smtp.tls'] %}checked{% endif %}>
|
||||
<label for="smtp-tls" style="margin:0;">Use TLS</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" name="smtp.username" value="{{ settings['smtp.username'] }}" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password <span style="color:#606080;font-size:0.8rem;">(leave blank to keep current)</span></label>
|
||||
<input type="password" name="smtp.password" placeholder="••••••••" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Recipients <span style="color:#606080;font-size:0.8rem;">(comma-separated)</span></label>
|
||||
<input type="text" name="smtp.recipients"
|
||||
value="{{ settings['smtp.recipients'] | join(', ') }}"
|
||||
placeholder="admin@example.com, ops@example.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Webhook ──────────────────────────────────────────────────────────── #}
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="margin-bottom:1rem;">Webhook</h2>
|
||||
<div class="form-group">
|
||||
<label>Webhook URL <span style="color:#606080;font-size:0.8rem;">(leave blank to disable)</span></label>
|
||||
<input type="text" name="webhook.url" value="{{ settings['webhook.url'] }}"
|
||||
placeholder="https://discord.com/api/webhooks/...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Payload template <span style="color:#606080;font-size:0.8rem;">(Jinja2, must produce valid JSON)</span></label>
|
||||
<textarea name="webhook.template" rows="3" style="font-family:ui-monospace,monospace;font-size:0.85rem;">{{ settings['webhook.template'] }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Ansible Sources ──────────────────────────────────────────────────── #}
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="margin-bottom:1rem;">Ansible Sources</h2>
|
||||
<p style="color:#606080;font-size:0.85rem;margin-bottom:0.75rem;">JSON array of source objects. Each requires <code>name</code>, <code>type</code> (git or local), and either <code>url</code> or <code>path</code>.</p>
|
||||
<textarea name="ansible.sources" rows="8" style="font-family:ui-monospace,monospace;font-size:0.85rem;">{{ settings['ansible.sources'] | tojson(indent=2) }}</textarea>
|
||||
</div>
|
||||
|
||||
{# ── Plugins ──────────────────────────────────────────────────────────── #}
|
||||
{% if discovered_plugins %}
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="margin-bottom:1rem;">Plugins</h2>
|
||||
{% for plugin in discovered_plugins %}
|
||||
<div style="border:1px solid #2a2a4a;border-radius:4px;padding:1rem;margin-bottom:0.75rem;">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.75rem;">
|
||||
<input type="checkbox" name="plugin.{{ plugin._dir }}.enabled"
|
||||
id="plugin-{{ plugin._dir }}-enabled"
|
||||
{% if plugin._enabled %}checked{% endif %}>
|
||||
<label for="plugin-{{ plugin._dir }}-enabled"
|
||||
style="margin:0;font-weight:bold;color:#c0c0e0;">
|
||||
{{ plugin.get('name', plugin._dir) }}
|
||||
<span style="color:#606080;font-size:0.8rem;font-weight:normal;">
|
||||
v{{ plugin.get('version', '?') }} — {{ plugin.get('description', '') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{% for cfg_key, cfg_default in plugin.get('config', {}).items() %}
|
||||
{% if cfg_default is mapping %}
|
||||
{# Nested dict — render each sub-key as its own field #}
|
||||
<div style="margin-left:1.5rem;margin-bottom:0.6rem;">
|
||||
<div style="font-size:0.82rem;color:var(--text-muted);margin-bottom:0.35rem;text-transform:uppercase;letter-spacing:0.04em;">{{ cfg_key }}</div>
|
||||
{% set sub_cfg = plugin._config.get(cfg_key, cfg_default) if plugin._config.get(cfg_key, cfg_default) is mapping else cfg_default %}
|
||||
{% for sub_key, sub_default in cfg_default.items() %}
|
||||
<div class="form-group" style="margin-left:1rem;">
|
||||
{% if sub_default is sameas false or sub_default is sameas true %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<input type="checkbox"
|
||||
name="plugin.{{ plugin._dir }}.{{ cfg_key }}.{{ sub_key }}"
|
||||
id="plugin-{{ plugin._dir }}-{{ cfg_key }}-{{ sub_key }}"
|
||||
{% if sub_cfg.get(sub_key, sub_default) %}checked{% endif %}>
|
||||
<label for="plugin-{{ plugin._dir }}-{{ cfg_key }}-{{ sub_key }}"
|
||||
style="margin:0;font-size:0.85rem;">{{ sub_key }}</label>
|
||||
</div>
|
||||
{% else %}
|
||||
<label style="font-size:0.85rem;">{{ sub_key }}</label>
|
||||
<input type="text"
|
||||
name="plugin.{{ plugin._dir }}.{{ cfg_key }}.{{ sub_key }}"
|
||||
value="{{ sub_cfg.get(sub_key, sub_default) }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif cfg_default is sameas false or cfg_default is sameas true %}
|
||||
<div class="form-group" style="margin-left:1.5rem;display:flex;align-items:center;gap:0.5rem;">
|
||||
<input type="checkbox"
|
||||
name="plugin.{{ plugin._dir }}.{{ cfg_key }}"
|
||||
id="plugin-{{ plugin._dir }}-{{ cfg_key }}"
|
||||
{% if plugin._config.get(cfg_key, cfg_default) %}checked{% endif %}>
|
||||
<label for="plugin-{{ plugin._dir }}-{{ cfg_key }}" style="margin:0;font-size:0.85rem;">{{ cfg_key }}</label>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-group" style="margin-left:1.5rem;">
|
||||
<label style="font-size:0.85rem;">{{ cfg_key }}</label>
|
||||
<input type="text" name="plugin.{{ plugin._dir }}.{{ cfg_key }}"
|
||||
value="{{ plugin._config.get(cfg_key, cfg_default) }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
<div style="margin-top:1.5rem;">
|
||||
<button type="submit" class="btn">Save Settings</button>
|
||||
<span style="color:#606080;font-size:0.85rem;margin-left:1rem;">
|
||||
Changes take effect immediately. Monitor interval, plugin enable/disable require a restart.
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Every colour a page draws comes from the design-system tokens (#5092).
|
||||
|
||||
- Every var(--x) a template or stylesheet uses is defined somewhere Steward
|
||||
serves, so nothing silently falls back to the browser default.
|
||||
- The base stylesheet states no colour or heavy weight of its own: values
|
||||
are theme.css tokens (generated from design system 6) or aliases of them.
|
||||
- Hex colours written into templates can only go down: each page moved onto
|
||||
the tokens lowers HEX_CEILING (milestone 477 retires the rest).
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
import steward
|
||||
|
||||
_REPO = pathlib.Path(steward.__file__).parent.parent
|
||||
_STATIC = _REPO / "steward" / "static"
|
||||
_COMMENTS = re.compile(r"\{#.*?#\}|<!--.*?-->|/\*.*?\*/", re.S)
|
||||
_DEFINED = re.compile(r"(--[\w-]+)\s*:")
|
||||
_USED = re.compile(r"var\(\s*(--[\w-]+)")
|
||||
# A hex colour where a colour goes: after a CSS ':', '(' or ',', or as a quoted JS
|
||||
# string -- but not an HTML attribute's value (href="#abc" is a link to an id).
|
||||
_HEX = re.compile(r"""(?:[:(,]\s*|(?<![\w-]=)['"])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})(?![0-9a-zA-Z_-])""")
|
||||
|
||||
HEX_CEILING = 75 # measured 2026-10-06 (#5092); lower it as pages move onto tokens, never raise it
|
||||
|
||||
|
||||
def _templates():
|
||||
for root in [_REPO / "steward" / "templates", *(_REPO / "plugins").glob("*/templates")]:
|
||||
yield from root.rglob("*.html")
|
||||
|
||||
|
||||
def _sheets():
|
||||
return [p for p in _STATIC.rglob("*.css") if "vendor" not in p.parts]
|
||||
|
||||
|
||||
def _text(p):
|
||||
return _COMMENTS.sub("", p.read_text())
|
||||
|
||||
|
||||
def undefined_vars(texts) -> set[str]:
|
||||
defined, used = set(), set()
|
||||
for t in texts:
|
||||
defined |= set(_DEFINED.findall(t))
|
||||
used |= set(_USED.findall(t))
|
||||
return used - defined
|
||||
|
||||
|
||||
def hex_colours(text: str) -> list[str]:
|
||||
return _HEX.findall(_COMMENTS.sub("", text))
|
||||
|
||||
|
||||
def test_the_guards_can_fail():
|
||||
assert undefined_vars([":root { --a: 1; } .x { color: var(--a); background: var(--b); }"]) == {"--b"}
|
||||
page = '<div style="color:#fff;background: rgb(0 0 0)" hx-target="#add-form"></div><script>c = "#4aa86a"</script>'
|
||||
assert len(hex_colours(page)) == 2
|
||||
assert hex_colours("{# color:#fff #}<a href=\"#abc\">") == []
|
||||
|
||||
|
||||
def test_every_css_variable_used_is_defined():
|
||||
texts = [_text(p) for p in [*_templates(), *_sheets()]]
|
||||
assert not undefined_vars(texts)
|
||||
|
||||
|
||||
def test_the_base_stylesheet_states_no_colour_or_heavy_weight_of_its_own():
|
||||
css = _text(_STATIC / "steward.css")
|
||||
assert not re.findall(r"#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(", css)
|
||||
assert not re.findall(r"font-weight\s*:\s*(?:[6-9]00|bold)", css)
|
||||
|
||||
|
||||
def test_hex_colours_in_templates_only_go_down():
|
||||
count = sum(len(hex_colours(p.read_text())) for p in _templates())
|
||||
assert count <= HEX_CEILING, (
|
||||
f"{count} hex colours in templates, ceiling {HEX_CEILING}: use a token (theme.css / steward.css)")
|
||||
Reference in New Issue
Block a user