feat(ui): base stylesheet on the Steward design-system tokens; readable status colours (#5092)
CI / lint (push) Successful in 5s
CI / unit (push) Successful in 1m20s
CI / integration (push) Successful in 1m12s
CI / publish (push) Successful in 1m43s

#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:
2026-10-06 14:18:49 -04:00
co-authored by Claude Opus 5.5
parent bd28d3b0e7
commit 65411d3ce8
16 changed files with 538 additions and 433 deletions
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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>
+1 -1
View File
@@ -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>
-157
View File
@@ -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 %}