Files
FabledSteward/steward/templates/dashboard/index.html
T
bvandeusenandClaude Opus 5.5 bf339ed28c
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m8s
CI / unit (push) Failing after 1m34s
CI / publish (push) Skipped
fix(ui): status text readable on its tint over a hovered row, a 3:1 outline on controls, contrast as a CI guard (#5096)
An audit of 1,980 text/surface pairs (every steward.css rule and every inline
template style, tints composited over page, card and hovered row) found:

- Red, yellow and orange status text on its own 20% tint over a hovered row
  at 3.97-4.06:1 (badges, count badge, alert-error, btn-warn). --red and
  --yellow are lifted 15% toward the primary text, --orange 18%, as --blue
  already was: worst case now 4.54:1.
- Inputs, selects, ghost and range buttons outlined in --border-mid at
  1.4-1.9:1, on surfaces 1.1:1 apart, so the outline is what marks them.
  New --border-control (45% toward secondary text): 3.13:1 worst. The
  dashboard switcher and widget-drawer fields drop their inline styles for
  the shared .input class.
- The breadcrumb separator painted in a border colour (1.4-1.9:1): now
  --text-dim (4.64:1) and aria-hidden, since it is decoration.

tests/test_contrast.py resolves the tokens (color-mix and tints included)
and holds every drawn pair to 4.5:1, status text on its tint to 4.5:1 and
control outlines to 3:1, with a test that the guard catches the old pairs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:30:37 -04:00

139 lines
6.0 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ dashboard.name }} — Steward{% endblock %}
{% block head %}
{# Gridstack renders the grid for everyone (static for viewers); editors also get
the in-place drag-resize + bottom drawer. #}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/gridstack-12.6.0.min.css') }}">
{% endblock %}
{% block content %}
{# ── Header ────────────────────────────────────────────────────────────────── #}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap;">
<div style="display:flex;align-items:center;gap:0.75rem;">
<h1 class="page-title" style="margin-bottom:0;">{{ dashboard.name }}</h1>
{% if dashboard.is_default %}
<span class="badge badge-gold" style="font-size:0.7rem;">Default</span>
{% endif %}
</div>
<div style="display:flex;align-items:center;gap:0.75rem;">
{# Dashboard switcher — shown when more than one dashboard exists #}
{% if all_dashboards | length > 1 %}
<select onchange="window.location='/d/'+this.value" class="input" style="width:auto;" aria-label="Dashboard">
{% for d in all_dashboards %}
<option value="{{ d.id }}" {% if d.id == dashboard.id %}selected{% endif %}>{{ d.name }}</option>
{% endfor %}
</select>
{% endif %}
{% if can_edit %}
<button type="button" id="dash-edit-toggle" class="btn btn-ghost btn-sm">Edit</button>
<a href="/d/{{ dashboard.id }}/share" class="btn btn-ghost btn-sm">Share</a>
{% endif %}
<a href="/dashboards/" class="btn btn-ghost btn-sm">Dashboards</a>
</div>
</div>
{# The old fixed summary strip (Hosts/Ping/DNS/Alerts) was merged into the single
'Overview' widget (status_overview) so there's one canonical summary, not two. #}
{# ── Widget grid (Gridstack; static until Edit is toggled) ──────────────────── #}
{% if not widgets %}
<div id="dash-empty" class="card" style="text-align:center;padding:3rem;">
<div style="font-size:1.1rem;color:var(--text-muted);margin-bottom:1rem;">No widgets on this dashboard yet.</div>
{% if can_edit %}
<button type="button" class="btn" onclick="document.getElementById('dash-edit-toggle').click()">Add Widgets</button>
{% endif %}
</div>
{% endif %}
<div class="grid-stack" data-dash-id="{{ dashboard.id }}" data-can-edit="{{ '1' if can_edit else '0' }}">
{% for item in widgets %}
{% include "dashboard/_grid_item.html" %}
{% endfor %}
</div>
{% if can_edit %}{% include "dashboard/_widget_drawer.html" %}{% endif %}
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', filename='vendor/gridstack-12.6.0-all.min.js') }}"></script>
<script>
(function () {
var gridEl = document.querySelector('.grid-stack');
if (!gridEl || typeof GridStack === 'undefined') return;
var dashId = gridEl.dataset.dashId;
var canEdit = gridEl.dataset.canEdit === '1';
var editing = false;
var grid = GridStack.init({
column: 12, cellHeight: 70, margin: 8, float: false,
staticGrid: true, // view mode: positioned but inert
handle: '.panel-drag',
resizable: { handles: 'e, se, s' },
columnOpts: { breakpointForWindow: true, breakpoints: [{ w: 700, c: 1 }] },
}, gridEl);
if (!canEdit) return; // viewers get the static grid only
var csrf = document.querySelector('meta[name="csrf-token"]').content;
function saveLayout() {
var items = grid.save(false).map(function (n) {
return { id: parseInt(n.id, 10), x: n.x, y: n.y, w: n.w, h: n.h };
});
fetch('/d/' + dashId + '/edit/layout', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ items: items }),
});
}
grid.on('change', function () { if (editing) saveLayout(); });
function setEditing(on) {
editing = on;
grid.setStatic(!on);
document.body.classList.toggle('dash-editing', on);
var btn = document.getElementById('dash-edit-toggle');
if (btn) btn.textContent = on ? 'Done' : 'Edit';
}
var toggle = document.getElementById('dash-edit-toggle');
if (toggle) toggle.addEventListener('click', function () { setEditing(!editing); });
var done = document.getElementById('dash-edit-done');
if (done) done.addEventListener('click', function () { setEditing(false); });
// Add widget — POST the form, insert the returned grid item in place.
document.querySelectorAll('.add-widget-form').forEach(function (form) {
form.addEventListener('submit', function (ev) {
ev.preventDefault();
fetch(form.action, { method: 'POST', body: new FormData(form), headers: { 'X-CSRF-Token': csrf } })
.then(function (r) { return r.status === 204 ? '' : r.text(); })
.then(function (html) {
if (!html) return;
var tmp = document.createElement('div');
tmp.innerHTML = html.trim();
var el = tmp.firstElementChild;
if (!el) return;
gridEl.appendChild(el);
grid.makeWidget(el);
if (window.htmx) window.htmx.process(el); // load its live data
var empty = document.getElementById('dash-empty');
if (empty) empty.style.display = 'none';
var det = form.closest('details');
if (det) det.open = false;
});
});
});
// Remove widget — delegated; POST then drop the grid item.
gridEl.addEventListener('click', function (ev) {
var rm = ev.target.closest('[data-remove]');
if (!rm) return;
ev.preventDefault();
var item = rm.closest('.grid-stack-item');
fetch('/d/' + dashId + '/edit/remove/' + rm.dataset.remove,
{ method: 'POST', headers: { 'X-CSRF-Token': csrf } })
.then(function () { if (item) grid.removeWidget(item); });
});
// Deep link: /d/<id>?edit=1 opens straight into edit mode (new dashboards do this).
if (new URLSearchParams(window.location.search).get('edit') === '1') setEditing(true);
})();
</script>
{% endblock %}