diff --git a/steward/templates/dashboard/share_view.html b/steward/templates/dashboard/share_view.html
index 855eb98..d23e38e 100644
--- a/steward/templates/dashboard/share_view.html
+++ b/steward/templates/dashboard/share_view.html
@@ -7,81 +7,34 @@
+
+
+ {# The palette, type and widget classes come from steward.css like every
+ other page; only the read-only page's own frame and its grid live here. #}
-
-
- {{ dashboard.name }}
-
+
+ {{ dashboard.name }}Steward · read-only view
-
+
{% if widgets %}
{% for item in widgets %}
@@ -101,9 +54,7 @@
{% endfor %}
{% else %}
-
- This dashboard has no widgets.
-
+
This dashboard has no widgets.
{% endif %}
diff --git a/steward/templates/settings/general.html b/steward/templates/settings/general.html
index b5363fa..2ff61a3 100644
--- a/steward/templates/settings/general.html
+++ b/steward/templates/settings/general.html
@@ -1,6 +1,8 @@
{# steward/templates/settings/general.html #}
{% extends "base.html" %}
+{% from "_macros.html" import crumbs %}
{% block title %}Settings — General — Steward{% endblock %}
+{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("General", "")]) }}{% endblock %}
{% block content %}
{% set active_tab = "general" %}
{% include "settings/_tabs.html" %}
diff --git a/steward/templates/settings/thresholds.html b/steward/templates/settings/thresholds.html
index 5b8efc2..221ee0d 100644
--- a/steward/templates/settings/thresholds.html
+++ b/steward/templates/settings/thresholds.html
@@ -1,6 +1,8 @@
{# steward/templates/settings/thresholds.html — tunable degraded/critical cutoffs #}
{% extends "base.html" %}
+{% from "_macros.html" import crumbs %}
{% block title %}Settings — Thresholds & Retention — Steward{% endblock %}
+{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Thresholds & retention", "")]) }}{% endblock %}
{% block content %}
{% set active_tab = "thresholds" %}
{% include "settings/_tabs.html" %}
diff --git a/tests/test_breadcrumbs.py b/tests/test_breadcrumbs.py
new file mode 100644
index 0000000..b56fca5
--- /dev/null
+++ b/tests/test_breadcrumbs.py
@@ -0,0 +1,56 @@
+"""Every page below a top-level section shows its trail back up (rule 134).
+
+The breadcrumb block is empty by default, so a nested page that forgets it
+ships with no way back up and nothing says so. Every full page (one that
+extends base.html) must fill the block, unless it is a section's own top
+page, a logged-out page or an error page: those are named below, so adding
+one is a decision someone makes in this file.
+"""
+import re
+
+from tests._templates import REPO, template_files
+
+TOP_LEVEL = {
+ "steward/templates/dashboard/index.html",
+ "steward/templates/status/index.html",
+ "steward/templates/hosts/list.html",
+ "steward/templates/monitors/index.html",
+ "steward/templates/alerts/list.html",
+ "steward/templates/ansible/index.html",
+ "steward/templates/audit/list.html",
+ "steward/templates/ai_access/index.html",
+ "plugins/docker/templates/docker/index.html",
+ "plugins/snmp/templates/snmp/index.html",
+ "plugins/traefik/templates/traefik/index.html",
+ "plugins/unifi/templates/unifi/index.html",
+ # Logged out, or no page to go back up from.
+ "steward/templates/auth/login.html",
+ "steward/templates/auth/setup.html",
+ "steward/templates/errors/404.html",
+ "steward/templates/errors/500.html",
+ "steward/templates/errors/database_unavailable.html",
+}
+
+_EXTENDS_BASE = re.compile(r"""\{%-?\s*extends\s+["']base\.html["']""")
+_BLOCK = re.compile(r"\{%-?\s*block\s+breadcrumb\s*-?%\}(.*?)\{%-?\s*endblock", re.S)
+
+
+def needs_crumbs_and_lacks_them(text: str) -> bool:
+ block = _BLOCK.search(text)
+ return bool(_EXTENDS_BASE.search(text)) and not (block and "crumbs(" in block.group(1))
+
+
+def test_the_guard_can_fail():
+ assert needs_crumbs_and_lacks_them('{% extends "base.html" %}{% block content %}x{% endblock %}')
+ assert needs_crumbs_and_lacks_them('{% extends "base.html" %}{% block breadcrumb %}{% endblock %}')
+ assert not needs_crumbs_and_lacks_them(
+ '{% extends "base.html" %}{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), ("x", "")]) }}{% endblock %}')
+ assert not needs_crumbs_and_lacks_them("
a fragment
")
+
+
+def test_every_nested_page_has_a_trail():
+ pages = {str(p.relative_to(REPO)): p for p in template_files()}
+ assert TOP_LEVEL <= set(pages), sorted(TOP_LEVEL - set(pages)) # a renamed page leaves no stale entry
+ missing = sorted(name for name, p in pages.items()
+ if name not in TOP_LEVEL and needs_crumbs_and_lacks_them(p.read_text()))
+ assert not missing, f"add {{% block breadcrumb %}}{{{{ crumbs([...]) }}}} (rule 134): {missing}"
diff --git a/tests/test_share_view.py b/tests/test_share_view.py
new file mode 100644
index 0000000..519bd5f
--- /dev/null
+++ b/tests/test_share_view.py
@@ -0,0 +1,30 @@
+"""The public share view draws with the same stylesheet as every other page
+(#5095): no palette of its own, so it can never drift from the tokens again."""
+from types import SimpleNamespace
+
+from quart import render_template
+
+from tests.test_style_tokens import hex_colours
+
+
+async def _share_view(app, widgets) -> str:
+ async with app.test_request_context("/share/t0ken"):
+ return await render_template(
+ "dashboard/share_view.html", dashboard=SimpleNamespace(name="Ops "),
+ widgets=widgets, raw_token="t0ken", poll_interval=30)
+
+
+async def test_the_share_view_uses_the_shared_stylesheet(app):
+ page = await _share_view(app, [])
+ assert "/static/theme.css" in page and "/static/steward.css" in page
+ assert not hex_colours(page), hex_colours(page)
+ assert "Ops <wall>" in page and "Ops <wall> — Steward" in page
+ assert '
This dashboard has no widgets.
' in page
+
+
+async def test_each_widget_loads_through_the_share_token(app):
+ gw = SimpleNamespace(id=7, grid_x=0, grid_y=2, grid_w=6, grid_h=3)
+ item = SimpleNamespace(db=gw, title="Hosts", hx_url="/w/hosts?id=7", defn=SimpleNamespace(poll=True))
+ page = await _share_view(app, [item])
+ assert 'hx-get="/w/hosts?id=7&s=t0ken"' in page
+ assert "grid-column:1 / span 6;grid-row:3 / span 3;" in page and "every 30s" in page
diff --git a/tests/test_style_tokens.py b/tests/test_style_tokens.py
index 05865e8..d72b4e7 100644
--- a/tests/test_style_tokens.py
+++ b/tests/test_style_tokens.py
@@ -22,7 +22,7 @@ _USED = re.compile(r"var\(\s*(--[\w-]+)")
# string -- but not an HTML attribute's value (href="#abc" is a link to an id).
_HEX = re.compile(r"""(?:[:(,]\s*|(?