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
@@ -102,17 +102,31 @@ struct WidgetView: View {
}
private var unsupportedView: some View {
VStack(alignment: .leading, spacing: 4) {
Label(widget.title, systemImage: "questionmark.app.dashed")
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
Text("Widget type \"\(widget.type)\" isn't supported in this version of Scarf yet.")
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(ScarfColor.warning)
Text(widget.title.isEmpty ? "Widget error" : widget.title)
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
Text("Unknown widget type: \"\(widget.type)\"")
.font(.callout)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
Text("This Scarf build doesn't render this widget type. Update Scarf or change the widget type in dashboard.json.")
.font(.caption2)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.background(ScarfColor.warning.opacity(0.08))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(ScarfColor.warning.opacity(0.3), lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -19,15 +19,7 @@ struct ListWidgetView: View {
}
if let items = widget.items {
ForEach(items) { item in
HStack(spacing: 6) {
Image(systemName: statusIcon(item.status))
.font(.caption2)
.foregroundStyle(statusColor(item.status))
Text(item.text)
.font(.callout)
.strikethrough(item.status == "done")
.foregroundStyle(item.status == "done" ? .secondary : .primary)
}
ListItemRow(item: item)
}
}
}
@@ -36,21 +28,52 @@ struct ListWidgetView: View {
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
private func statusIcon(_ status: String?) -> String {
switch status {
case "done": return "checkmark.circle.fill"
case "active": return "circle.inset.filled"
case "pending": return "circle"
default: return "circle"
private struct ListItemRow: View {
let item: ListItem
private var typedStatus: ListItemStatus? { ListItemStatus(raw: item.status) }
var body: some View {
HStack(spacing: 6) {
Image(systemName: iconName)
.font(.caption2)
.foregroundStyle(tint)
Text(item.text)
.font(.callout)
.strikethrough(typedStatus == .done)
.foregroundStyle(typedStatus == .done ? .secondary : .primary)
if typedStatus == nil, let raw = item.status, !raw.isEmpty {
Text(raw)
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.quaternary.opacity(0.5))
.clipShape(Capsule())
}
}
}
private func statusColor(_ status: String?) -> Color {
switch status {
case "done": return .green
case "active": return .blue
default: return .secondary
private var iconName: String {
switch typedStatus {
case .success, .done: return "checkmark.circle.fill"
case .warning: return "exclamationmark.triangle.fill"
case .danger: return "xmark.octagon.fill"
case .info: return "info.circle.fill"
case .pending: return "circle.dashed"
case .neutral, nil: return "circle"
}
}
private var tint: Color {
switch typedStatus {
case .success, .done: return ScarfColor.success
case .warning: return ScarfColor.warning
case .danger: return ScarfColor.danger
case .info: return ScarfColor.info
case .pending, .neutral, nil: return .secondary
}
}
}