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
+58
View File
@@ -411,6 +411,64 @@ h1, h2, h3 { line-height: 1.25; }
}
.widget-list-status[data-status="up"] { background: rgba(42,168,118,0.18); color: var(--accent-dark); }
.widget-list-status[data-status="down"] { background: rgba(217,83,79,0.18); color: var(--red); }
/* v2.7 typed semantic statuses (mirrors ScarfDesign palette + Swift ListItemStatus enum). */
.widget-list-status.status-success { background: rgba(42,168,118,0.18); color: var(--accent-dark); }
.widget-list-status.status-warning { background: rgba(240,173,78,0.20); color: var(--orange); }
.widget-list-status.status-danger { background: rgba(217,83,79,0.18); color: var(--red); }
.widget-list-status.status-info { background: rgba(91,143,225,0.20); color: var(--blue); }
.widget-list-status.status-pending { background: rgba(0,0,0,0.06); color: var(--fg-muted); }
.widget-list-status.status-done { background: rgba(42,168,118,0.14); color: var(--accent-dark); }
.widget-list-status.status-neutral { background: rgba(0,0,0,0.06); color: var(--fg-muted); }
.widget-list-status.status-unknown { background: rgba(0,0,0,0.06); color: var(--fg-muted); font-style: italic; }
.widget-list-item-done .widget-list-text { text-decoration: line-through; color: var(--fg-muted); }
/* v2.7 structured widget error card (mirrors Swift WidgetErrorCard). */
.widget-error {
background: rgba(240,173,78,0.08);
border: 1px solid rgba(240,173,78,0.30);
}
.widget-error-head { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.widget-error-icon { color: var(--orange); font-size: 14px; }
.widget-error-reason { font-size: 14px; color: var(--fg); }
.widget-error-hint { margin-top: 4px; font-size: 11px; color: var(--fg-muted); }
/* v2.7 cron_status / log_tail / markdown_file (catalog preview placeholders). */
.widget-cron-status, .widget-log-tail, .widget-markdown-file { padding: 12px; }
.widget-cron-head { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.widget-cron-icon { font-size: 14px; color: var(--fg-muted); }
.widget-cron-meta { font-size: 13px; color: var(--fg); font-family: monospace; }
.widget-cron-hint { margin-top: 6px; font-size: 11px; color: var(--fg-muted); font-style: italic; }
/* v2.7 image widget. */
.widget-image { padding: 12px; }
.widget-image-img { display: block; max-width: 100%; height: auto; margin-top: 8px; border-radius: 4px; }
/* v2.7 status_grid widget — compact NxM grid with semantic-status swatches. */
.widget-status-grid { padding: 12px; }
.widget-status-grid-grid {
display: grid;
grid-template-columns: repeat(var(--cols, 6), 1fr);
gap: 4px;
margin-top: 6px;
}
.widget-status-grid-cell { display: flex; flex-direction: column; gap: 2px; align-items: center; }
.widget-status-grid-swatch { width: 100%; height: 18px; border-radius: 3px; }
.widget-status-grid-label { font-size: 9px; color: var(--fg-muted); text-align: center; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.widget-status-grid-swatch.status-success { background: rgba(42,168,118,0.85); }
.widget-status-grid-swatch.status-warning { background: rgba(240,173,78,0.85); }
.widget-status-grid-swatch.status-danger { background: rgba(217,83,79,0.85); }
.widget-status-grid-swatch.status-info { background: rgba(91,143,225,0.85); }
.widget-status-grid-swatch.status-pending { background: rgba(120,120,120,0.40); }
.widget-status-grid-swatch.status-done { background: rgba(42,168,118,0.55); }
.widget-status-grid-swatch.status-neutral { background: rgba(120,120,120,0.30); }
/* v2.7 sparkline under stat value. Inherits color from widget-stat data-color. */
.widget-stat-sparkline { display: block; margin-top: 4px; width: 100%; }
.widget-stat[data-color="accent"] .widget-stat-sparkline,
.widget-stat[data-color="green"] .widget-stat-sparkline { color: var(--accent-dark); }
.widget-stat[data-color="red"] .widget-stat-sparkline { color: var(--red); }
.widget-stat[data-color="blue"] .widget-stat-sparkline { color: var(--blue); }
.widget-stat[data-color="orange"] .widget-stat-sparkline { color: var(--orange); }
/* chart */
.widget-chart-svg { width: 100%; height: auto; margin-top: 8px; }
+214 -15
View File
@@ -6,16 +6,13 @@
// shows a live preview of exactly what the user's project dashboard
// will look like after install.
//
// Widget types mirrored from the Swift dispatcher:
// stat — big number + label + icon + color
// progress — label + 0..1 bar
// text — markdown (tiny subset renderer)
// table — plain HTML table
// list — bulleted list with optional status badge
// chart — SVG line/bar by series
// webview — sandboxed <iframe>
// CANONICAL VOCABULARY: tools/widget-schema.json. The catalog validator
// (tools/build-catalog.py) and the agent-authoring SKILL.md both read
// from there. When you add a renderer below, mirror the Swift view in
// scarf/scarf/Features/Projects/Views/Widgets/ AND add an entry to
// widget-schema.json.
//
// Vanilla JS, no build step, no external deps. ~300 lines.
// Vanilla JS, no build step, no external deps.
(function (global) {
"use strict";
@@ -79,6 +76,11 @@
case "list": return renderList(widget);
case "chart": return renderChart(widget);
case "webview": return renderWebview(widget);
case "cron_status": return renderCronStatus(widget);
case "log_tail": return renderLogTail(widget);
case "markdown_file": return renderMarkdownFile(widget);
case "image": return renderImage(widget);
case "status_grid": return renderStatusGrid(widget);
default: return renderUnknown(widget);
}
} catch (e) {
@@ -103,9 +105,41 @@
if (widget.subtitle) {
card.appendChild(elt("div", "widget-stat-subtitle", widget.subtitle));
}
if (Array.isArray(widget.sparkline) && widget.sparkline.length >= 2) {
card.appendChild(renderSparkline(widget.sparkline));
}
return card;
}
/** v2.7 — inline trend line under a stat value. SVG, no Chart.js. */
function renderSparkline(values) {
const w = 120;
const h = 18;
const min = Math.min(...values);
const max = Math.max(...values);
const span = Math.max(0.0001, max - min);
const stepX = values.length > 1 ? w / (values.length - 1) : 0;
let path = "";
values.forEach((v, i) => {
const x = (i * stepX).toFixed(2);
const y = (h - ((v - min) / span) * h).toFixed(2);
path += (i === 0 ? "M" : "L") + x + "," + y + " ";
});
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("class", "widget-stat-sparkline");
svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
svg.setAttribute("width", String(w));
svg.setAttribute("height", String(h));
svg.setAttribute("preserveAspectRatio", "none");
const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
p.setAttribute("d", path.trim());
p.setAttribute("fill", "none");
p.setAttribute("stroke", "currentColor");
p.setAttribute("stroke-width", "1.2");
svg.appendChild(p);
return svg;
}
function displayValue(v) {
if (v === null || v === undefined) return "—";
if (typeof v === "number") {
@@ -263,16 +297,40 @@
// List
// ---------------------------------------------------------------------
// Maps a `ListItem.status` string (free-form on the wire) to a canonical
// semantic status. Mirrors the Swift `ListItemStatus(raw:)` lenient parse
// — accepts canonical names + common synonyms (`ok`/`up` → success,
// `down`/`error` → danger, `active` → info). Returns null for unrecognized
// values so the renderer can fall back to a neutral text badge.
const STATUS_SYNONYMS = {
success: "success", ok: "success", up: "success", green: "success", passing: "success",
warning: "warning", warn: "warning", yellow: "warning", degraded: "warning",
danger: "danger", down: "danger", error: "danger", failed: "danger", failure: "danger", red: "danger", critical: "danger",
info: "info", active: "info", blue: "info",
pending: "pending", queued: "pending", waiting: "pending", scheduled: "pending",
done: "done", complete: "done", completed: "done", finished: "done",
neutral: "neutral", muted: "neutral", gray: "neutral",
};
function canonicalStatus(raw) {
if (typeof raw !== "string") return null;
const key = raw.trim().toLowerCase();
return STATUS_SYNONYMS[key] || null;
}
function renderList(widget) {
const card = elt("div", "widget widget-list");
card.appendChild(elt("div", "widget-title", widget.title || ""));
const ul = elt("ul", "widget-list-items");
for (const item of widget.items || []) {
const li = elt("li", "widget-list-item");
const canon = canonicalStatus(item.status);
if (canon === "done") li.classList.add("widget-list-item-done");
li.appendChild(elt("span", "widget-list-text", item.text || ""));
if (item.status) {
const badge = elt("span", "widget-list-status", item.status);
badge.dataset.status = item.status;
const cls = canon ? `widget-list-status status-${canon}` : "widget-list-status status-unknown";
const badge = elt("span", cls, canon || item.status);
badge.dataset.status = canon || item.status;
if (!canon) badge.title = `unknown status: ${item.status}`;
li.appendChild(badge);
}
ul.appendChild(li);
@@ -376,15 +434,156 @@
return card;
}
// ---------------------------------------------------------------------
// log_tail / markdown_file / image / status_grid — v2.7
// The first three are file-reading widgets; the catalog has no project
// filesystem to read from, so we render an annotated placeholder. The
// template-author skill (SKILL.md Widget Catalog) tells users this is
// expected on the catalog and that real data appears in-app.
// ---------------------------------------------------------------------
function renderLogTail(widget) {
const card = elt("div", "widget widget-log-tail");
const head = elt("div", "widget-cron-head");
head.appendChild(elt("span", "widget-cron-icon", "⌙"));
head.appendChild(elt("span", "widget-title", widget.title || ""));
card.appendChild(head);
if (!widget.path) {
card.appendChild(renderWidgetError(
"", "Missing required `path` field.",
"Set `path` to a file relative to the project root."
));
return card;
}
const lines = Math.max(1, Math.min(200, widget.lines || 20));
card.appendChild(elt("div", "widget-cron-meta",
`Tails last ${lines} line${lines === 1 ? "" : "s"} of ${widget.path}`));
card.appendChild(elt("div", "widget-cron-hint",
"Live tail appears in Scarf after install."));
return card;
}
function renderMarkdownFile(widget) {
const card = elt("div", "widget widget-markdown-file");
const head = elt("div", "widget-cron-head");
head.appendChild(elt("span", "widget-cron-icon", "📄"));
head.appendChild(elt("span", "widget-title", widget.title || ""));
card.appendChild(head);
if (!widget.path) {
card.appendChild(renderWidgetError(
"", "Missing required `path` field.",
"Set `path` to a markdown file relative to the project root."
));
return card;
}
card.appendChild(elt("div", "widget-cron-meta",
`Renders markdown from: ${widget.path}`));
card.appendChild(elt("div", "widget-cron-hint",
"File contents appear in Scarf after install."));
return card;
}
function renderImage(widget) {
const card = elt("div", "widget widget-image");
card.appendChild(elt("div", "widget-title", widget.title || ""));
if (widget.url) {
const img = document.createElement("img");
img.className = "widget-image-img";
img.src = widget.url;
img.alt = widget.title || "";
if (widget.height) img.style.maxHeight = `${widget.height}px`;
card.appendChild(img);
} else if (widget.path) {
card.appendChild(elt("div", "widget-cron-meta",
`Local image: ${widget.path}`));
card.appendChild(elt("div", "widget-cron-hint",
"Local files render in Scarf after install."));
} else {
card.appendChild(renderWidgetError(
"", "Image widget needs either `path` (local) or `url` (remote)."
));
}
return card;
}
function renderStatusGrid(widget) {
const card = elt("div", "widget widget-status-grid");
card.appendChild(elt("div", "widget-title", widget.title || ""));
const cells = Array.isArray(widget.cells) ? widget.cells : [];
if (cells.length === 0) {
card.appendChild(elt("div", "widget-cron-meta", "No cells."));
return card;
}
let cols = widget.gridColumns;
if (typeof cols !== "number" || cols <= 0) {
if (cells.length <= 4) cols = Math.max(1, cells.length);
else if (cells.length <= 12) cols = 6;
else if (cells.length <= 24) cols = 8;
else cols = 12;
}
const grid = elt("div", "widget-status-grid-grid");
grid.style.setProperty("--cols", String(cols));
for (const cell of cells) {
const square = elt("div", "widget-status-grid-cell");
const canon = canonicalStatus(cell.status) || "neutral";
const swatch = elt("div", `widget-status-grid-swatch status-${canon}`);
square.title = cell.tooltip || (cell.label + (cell.status ? `${cell.status}` : ""));
square.appendChild(swatch);
square.appendChild(elt("div", "widget-status-grid-label", cell.label || ""));
grid.appendChild(square);
}
card.appendChild(grid);
return card;
}
// ---------------------------------------------------------------------
// Cron status (catalog preview — no live cron data)
// ---------------------------------------------------------------------
function renderCronStatus(widget) {
const card = elt("div", "widget widget-cron-status");
const head = elt("div", "widget-cron-head");
const icon = elt("span", "widget-cron-icon", "↻");
head.appendChild(icon);
head.appendChild(elt("span", "widget-title", widget.title || ""));
card.appendChild(head);
if (!widget.jobId) {
card.appendChild(elt("div", "widget-cron-meta",
"Missing required `jobId` field."));
} else {
card.appendChild(elt("div", "widget-cron-meta",
`Tracks Hermes cron job: ${widget.jobId}`));
card.appendChild(elt("div", "widget-cron-hint",
"Live status (last run, next run, output tail) appears in Scarf after install."));
}
return card;
}
// ---------------------------------------------------------------------
// Unknown / placeholder
// ---------------------------------------------------------------------
function renderUnknown(widget) {
const card = elt("div", "widget widget-unknown");
card.appendChild(elt("div", "widget-title", widget.title || ""));
card.appendChild(elt("div", "widget-unknown-body",
`Unknown widget type: ${widget.type}`));
return renderWidgetError(
widget.title,
`Unknown widget type: "${widget.type}"`,
"This catalog renderer doesn't know about this widget type. The Scarf app may render it correctly if it's been added in a newer release."
);
}
/**
* Structured error card. Mirrors `WidgetErrorCard` on the Swift side and
* is also used by file-reading widgets (markdown_file, log_tail, image)
* when their underlying data can't be loaded.
*/
function renderWidgetError(title, reason, hint) {
const card = elt("div", "widget widget-unknown widget-error");
const head = elt("div", "widget-error-head");
head.appendChild(elt("span", "widget-error-icon", "⚠"));
head.appendChild(elt("span", "widget-title", title || "Widget error"));
card.appendChild(head);
card.appendChild(elt("div", "widget-error-reason", reason));
if (hint) card.appendChild(elt("div", "widget-error-hint", hint));
return card;
}