feat(dashboards): v2.7 widget catalog — file-reading widgets, sparkline, typed status, project-wide watch

Major project-dashboard release. Five new widget types (markdown_file, log_tail,
cron_status, image, status_grid), inline sparkline on stat, typed status enum
shared by list + status_grid, structured WidgetErrorCard, and a project-wide
.scarf/ directory watch that picks up files cron jobs write next to dashboard.json.

- ProjectDashboard: extend DashboardWidget with path/lines/jobId/cells/gridColumns/sparkline; add StatusGridCell + ListItemStatus (lenient parse with synonyms)
- HermesFileWatcher: watch each project's .scarf/ dir alongside dashboard.json (local FSEvents + remote SSH mtime poll); updateProjectWatches signature now takes dashboardPaths + scarfDirs
- New widget views: CronStatus, Image, LogTail, MarkdownFile, StatusGrid, plus WidgetErrorCard for structured failure messaging; legacy "Unknown" placeholder replaced everywhere
- WidgetPathResolver: project-root-anchored path resolution that rejects absolute paths + ".." escapes pre and post canonicalization
- Stat widget gains optional inline sparkline (pure SwiftUI Path, no Charts dep); list widget rows route through typed status with semantic icons + ScarfColor tints
- iOS list widget + unsupported card adopt typed status + warning-toned error card (parity with Mac error styling); new widget types remain Mac-only
- Site mirror: widgets.js renders all five new types (file-reading widgets show annotated catalog placeholders), sparkline SVG, status-grid grid; styles.css adds typed-status palette + error-card + sparkline + grid styles
- Catalog validator: tools/widget-schema.json is the single source of truth; build-catalog.py loads it and enforces per-type required fields. 8 new test cases in test_build_catalog.py covering schema load, v2.7 additions, and missing-required rejection
- Template-author skill (SKILL.md) gains v2.7 Widget Catalog section + canonical status guidance; CONTRIBUTING.md points authors at widget-schema.json; template-author bundle rebuilt
- Localizable.xcstrings picks up auto-extracted strings for the previously-shipped OAuth keepalive feature
- Release notes drafted at releases/v2.7.0/RELEASE_NOTES.md

Backwards compatible — existing dashboard.json renders byte-identically, status synonyms (ok/up/down/active/etc.) keep working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-04 21:16:29 +02:00
parent 9d945150e0
commit c7bcfd8655
28 changed files with 1846 additions and 123 deletions
+168
View File
@@ -266,6 +266,174 @@ class ValidationTests(unittest.TestCase):
_, errors = self._validate_all()
self.assertTrue(any("unknown type" in str(e) for e in errors), errors)
def test_rejects_widget_missing_required_field(self):
# 'progress' requires both title + value; omit value.
bad_dashboard = {
"version": 1,
"title": "Bad",
"sections": [{"title": "x", "columns": 1, "widgets": [
{"type": "progress", "title": "Loading"},
]}],
}
manifest = {
"schemaVersion": 1,
"id": "tester/missing-required",
"name": "Missing",
"version": "1.0.0",
"description": "missing required field",
"contents": {"dashboard": True, "agentsMd": True},
}
make_template_dir(
self.repo, "tester", "missing-required",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# readme",
"AGENTS.md": b"# agents",
"dashboard.json": json.dumps(bad_dashboard).encode("utf-8"),
},
)
_, errors = self._validate_all()
self.assertTrue(
any("missing required field 'value'" in str(e) for e in errors),
errors,
)
def test_widget_schema_loads_and_lists_known_types(self):
# Sanity check: schema includes the v2.2 originals so old templates
# keep validating.
for t in ("stat", "progress", "text", "table", "chart", "list", "webview"):
self.assertIn(t, build_catalog.SUPPORTED_WIDGET_TYPES)
def test_widget_schema_includes_v2_7_additions(self):
# v2.7 added markdown_file, log_tail, cron_status, image, status_grid.
for t in ("markdown_file", "log_tail", "cron_status", "image", "status_grid"):
self.assertIn(t, build_catalog.SUPPORTED_WIDGET_TYPES)
def test_v2_7_widgets_accept_canonical_minimum_fields(self):
# Build one bundle whose dashboard exercises every v2.7 addition with
# its canonical required fields populated. If any per-type rule
# over-tightens, this test catches it before catalog publishing.
ok_dashboard = {
"version": 1,
"title": "v2.7 sampler",
"sections": [{
"title": "Sample",
"columns": 2,
"widgets": [
{"type": "stat", "title": "Sites", "value": 4, "sparkline": [1, 2, 3, 2, 4]},
{"type": "list", "title": "Status",
"items": [{"text": "auth.example.com", "status": "ok"},
{"text": "api.example.com", "status": "down"}]},
{"type": "markdown_file", "title": "Weekly", "path": "reports/weekly.md"},
{"type": "log_tail", "title": "Tail", "path": "reports/run.log", "lines": 30},
{"type": "cron_status", "title": "Job", "jobId": "uptime-sweep"},
{"type": "image", "title": "Pic", "path": "reports/chart.png"},
{"type": "status_grid", "title": "Fleet", "cells": [
{"label": "us-east-1", "status": "success"},
{"label": "us-west-2", "status": "warning"},
{"label": "eu-central-1", "status": "danger"},
]},
],
}],
}
manifest = {
"schemaVersion": 1,
"id": "tester/v2_7",
"name": "v2.7 sampler",
"version": "1.0.0",
"description": "exercises every v2.7 widget",
"contents": {"dashboard": True, "agentsMd": True},
}
make_template_dir(
self.repo, "tester", "v2_7",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# readme",
"AGENTS.md": b"# agents",
"dashboard.json": json.dumps(ok_dashboard).encode("utf-8"),
},
)
templates, errors = self._validate_all()
self.assertEqual(errors, [], f"unexpected errors: {errors}")
def test_v2_7_widgets_reject_missing_required(self):
# Each v2.7 file/cron/grid widget has a required field. A bundle that
# omits any of them should be rejected.
bad_dashboard = {
"version": 1,
"title": "Bad sampler",
"sections": [{
"title": "Bad",
"columns": 1,
"widgets": [
{"type": "markdown_file", "title": "no path"},
{"type": "log_tail", "title": "no path"},
{"type": "cron_status", "title": "no jobId"},
{"type": "status_grid", "title": "no cells"},
],
}],
}
manifest = {
"schemaVersion": 1,
"id": "tester/v2_7_bad",
"name": "Bad",
"version": "1.0.0",
"description": "missing required fields",
"contents": {"dashboard": True, "agentsMd": True},
}
make_template_dir(
self.repo, "tester", "v2_7_bad",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# readme",
"AGENTS.md": b"# agents",
"dashboard.json": json.dumps(bad_dashboard).encode("utf-8"),
},
)
_, errors = self._validate_all()
# Expect at least one missing-required error per offending widget.
for required, label in [("path", "markdown_file"), ("path", "log_tail"),
("jobId", "cron_status"), ("cells", "status_grid")]:
self.assertTrue(
any(f"missing required field '{required}'" in str(e) and label in str(e) for e in errors),
f"expected missing `{required}` error for {label}; got: {errors}",
)
def test_cron_status_requires_jobId(self):
bad_dashboard = {
"version": 1,
"title": "Bad",
"sections": [{"title": "x", "columns": 1, "widgets": [
{"type": "cron_status", "title": "Without jobId"},
]}],
}
manifest = {
"schemaVersion": 1,
"id": "tester/cron-no-id",
"name": "Cron",
"version": "1.0.0",
"description": "missing jobId",
"contents": {"dashboard": True, "agentsMd": True},
}
make_template_dir(
self.repo, "tester", "cron-no-id",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# readme",
"AGENTS.md": b"# agents",
"dashboard.json": json.dumps(bad_dashboard).encode("utf-8"),
},
)
_, errors = self._validate_all()
self.assertTrue(
any("missing required field 'jobId'" in str(e) for e in errors),
errors,
)
def test_rejects_secret_in_bundle(self):
leaky = b"config:\n github_token: ghp_" + b"A" * 40 + b"\n"
manifest = {