feat(ui): status_badge, status_dot, stat_tile, empty_state, page_header, icon_button and form classes (#5094)

#5094 "Status and layout macros, and form classes".

- formatting.STATES: one state -> (tone, label) map for up, down, stale,
  firing, pending, acknowledged, no_data, ok, warn, crit, resolved and
  inactive; status_info() reads it (an unmapped state is drawn dim under its
  own name). required() lets a macro refuse a missing argument.
- _macros.html: status_badge and status_dot (a dot always carries its label,
  sr-only unless shown), stat_tile, empty_state, page_header (actions via a
  call block) and icon_button, which raises without a label.
- steward.css: tone classes (badge-/dot-/stat- ok, warn, crit, info, dim),
  .sr-only, .page-header, .stat-tile, .empty-state, .btn-icon and the form
  classes .field, .field-label, .field-hint and .input. Info text is a 40%
  mix (--blue): the house --fs-info-fg falls to 4.18:1 on its tint over a
  hovered row, 40% holds 5.09:1.
- First users: the Alerts list, history and maintenance pages (headers,
  state badges, empty states) and the alert forms' hints; the inline inp/lbl
  style sets on the monitor and host forms become .input / .field-label.
- tests/core/test_macros.py renders each macro through the app environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 14:34:21 -04:00
co-authored by Claude Opus 5.5
parent f47fc23265
commit 477b05b9d0
16 changed files with 296 additions and 117 deletions
+80
View File
@@ -0,0 +1,80 @@
"""The status and layout macros in _macros.html (#5094), rendered through the
app's own Jinja environment so its globals (status_info, required) are the
real ones."""
import enum
import pytest
from steward.core.formatting import STATES, status_info
async def _render(app, source: str, **ctx) -> str:
async with app.app_context():
tpl = app.jinja_env.from_string('{% from "_macros.html" import status_badge, status_dot, '
'stat_tile, empty_state, page_header, icon_button %}' + source)
out = await tpl.render_async(**ctx) if app.jinja_env.is_async else tpl.render(**ctx)
return " ".join(out.split())
def test_every_state_a_page_shows_has_a_tone_and_a_label():
named = ["up", "down", "stale", "firing", "pending", "acknowledged", "no_data", "ok", "warn", "crit"]
assert set(named) <= set(STATES)
assert {tone for tone, _ in STATES.values()} == {"ok", "warn", "crit", "info", "dim"}
assert status_info("down")[0] == status_info("firing")[0] == "crit"
class _State(enum.Enum):
firing = "firing"
@pytest.mark.parametrize("state, tone, label", [
("firing", "crit", "Firing"), (_State.firing, "crit", "Firing"), ("UP", "ok", "Up"),
("no_data", "warn", "No data"), ("acknowledged", "info", "Acknowledged"),
(None, "dim", "Unknown"), ("half_open", "dim", "Half open"), # unmapped: dim, its own name
])
def test_status_info(state, tone, label):
assert status_info(state) == (tone, label)
async def test_status_badge(app):
assert await _render(app, "{{ status_badge('firing') }}") == '<span class="badge badge-crit">Firing</span>'
out = await _render(app, "{{ status_badge(s, 'Muted', title=t) }}", s=_State.firing, t='a "b"')
assert out == '<span class="badge badge-crit" title="a &#34;b&#34;">Muted</span>'
async def test_a_status_dot_always_carries_its_label(app):
out = await _render(app, "{{ status_dot('down') }}")
assert 'class="dot dot-crit" aria-hidden="true"' in out and '<span class="sr-only">Down</span>' in out
out = await _render(app, "{{ status_dot('up', show_label=true) }}")
assert '<span class="dot-label">Up</span>' in out and "sr-only" not in out
async def test_stat_tile(app):
out = await _render(app, "{{ stat_tile('Firing', 3, state='crit', href='/alerts/?a=1&b=2') }}")
assert out.startswith('<a href="/alerts/?a=1&amp;b=2" class="stat-tile stat-crit">')
assert '<span class="stat-tile-value">3</span>' in out and out.endswith("</a>")
out = await _render(app, "{{ stat_tile('<Hosts>', 12, hint='2 stale') }}")
assert out.startswith('<div class="stat-tile">') and "&lt;Hosts&gt;" in out and "2 stale" in out
async def test_empty_state(app):
out = await _render(app, "{{ empty_state('No rules yet.', 'New rule', '/alerts/rules/new') }}")
assert "<p>No rules yet.</p>" in out and 'href="/alerts/rules/new">New rule</a>' in out
assert "<a" not in await _render(app, "{{ empty_state('Nothing.') }}")
async def test_page_header_with_and_without_actions(app):
out = await _render(app, "{% call page_header('Alerts', 'All rules') %}<a class=\"btn\">New</a>{% endcall %}")
assert '<h1 class="page-title">Alerts</h1>' in out and '<p class="page-subtitle">All rules</p>' in out
assert '<div class="page-actions"><a class="btn">New</a></div>' in out
assert "page-actions" not in await _render(app, "{{ page_header('Alerts') }}")
async def test_icon_button_names_itself_and_refuses_to_render_without_a_label(app):
out = await _render(app, "{{ icon_button('×', 'Delete rule web01', type='submit', cls='btn-danger', confirm='Delete?') }}")
assert 'type="submit"' in out and 'aria-label="Delete rule web01"' in out and 'data-confirm="Delete?"' in out
out = await _render(app, "{{ icon_button('✎', 'Edit', href='/x') }}")
assert out.startswith('<a class="btn btn-sm btn-icon btn-ghost" href="/x" aria-label="Edit"')
for source in ("{{ icon_button('×') }}", "{{ icon_button('×', '') }}", "{{ icon_button('×', ' ') }}"):
with pytest.raises(ValueError, match="icon_button label is required"):
await _render(app, source)