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
+61
View File
@@ -0,0 +1,61 @@
## What's in 2.7.0
A focused release on **project dashboards** — the most "live" surface for users running cron-driven workflows. This release does three things at once:
1. **Auto-refresh now covers the entire project, not just `dashboard.json`.** A widget that points at `<project>/.scarf/reports/uptime.md` refreshes the moment the cron job rewrites it.
2. **Five new widget types** make cron-driven monitoring dashboards much more expressive — render markdown reports from disk, tail log files, surface Hermes cron-job state, embed images, and pack many services into a compact status grid.
3. **`stat` widgets gain inline sparklines.** `list` items get a typed status enum with semantic colors. Unknown widget types render as a structured error card (not a generic "Unknown" placeholder).
**Backwards compatible — no schema bump.** Every existing `dashboard.json` renders byte-identically on v2.7. The catalog manifest format is unchanged. v1, v2, v3 bundles install identically as before. Templates that adopt new widget types still validate against the existing manifest schema — only the catalog validator's vocabulary list was extended.
### Project-wide auto-refresh
[`HermesFileWatcher`](../../scarf/scarf/Core/Services/HermesFileWatcher.swift) used to watch each project's `dashboard.json` file specifically. v2.7 promotes that to a watch on the entire `<project>/.scarf/` directory:
- **Local** — adds a `DispatchSourceFileSystemObject` per project's `.scarf/` dir alongside the existing per-file watch on `dashboard.json`.
- **Remote (SSH)** — folds project `.scarf/` directories into the existing 3-second mtime poll. Closes the explicit "Phase 4 polish item" deferral that landed in v2.3.
Effect: a `markdown_file` or `log_tail` widget pointing at `<project>/.scarf/reports/foo.md` refreshes automatically when a cron job rewrites the file. **By convention, place files the dashboard reads inside `.scarf/`** so the watch picks them up. Files outside `.scarf/` work too but only refresh when `dashboard.json` itself changes.
_Limitation:_ in-place appends to an existing file (`>> file.log`) don't tick the watcher — the cron job should write atomically (write-temp + rename), or `touch dashboard.json` after each run to force a refresh. Per-widget data-source watching (the granular alternative) is deferred to a future release; this project-wide pattern covers the common cron-driven workflow without the extra plumbing.
### Five new widget types
All five additive — pre-v2.7 Scarf renders unknown widget types as a clearly-labeled error card now (not a crash). They share two conventions: file paths are resolved relative to the project root with a hard `..`-escape rejection at [`WidgetPathResolver`](../../scarf/scarf/Features/Projects/Views/Widgets/WidgetPathResolver.swift), and reads happen in `Task.detached` so dashboards never block the main actor.
- **`markdown_file`** — renders a markdown file from disk through the same `MarkdownContentView` pipeline used by inline `text` widgets. Pair with cron jobs that write longer-form reports.
- **`log_tail`** — last `lines` of a file (default 20, max 200), monospaced, ANSI codes stripped. Killer for "what did my cron job print last run?".
- **`cron_status`** — last run / next run / state for one Hermes cron job by `jobId`, plus a small inline log tail. Read-only — Run / Pause / Resume controls stay on the Cron tab so the dashboard isn't a place where you accidentally fire a job.
- **`image`** — local file (`path` relative to project root, via `transport.readFile`) or remote `url` (via `AsyncImage`). Optional `height` cap. Useful for matplotlib/Plotly PNGs the cron job generates.
- **`status_grid`** — compact NxM grid of colored cells, one per service / item, with hover labels. Reuses the typed status enum so colors stay consistent with `list` widgets.
### `stat` widget gains inline sparklines
`stat` widgets now accept an optional `sparkline: [Number]` field — a tiny inline trend line under the big number. SVG-only render, no Chart.js dependency, dozens per dashboard cost nothing. Old `stat` widgets without the field render exactly as before.
### Typed status badges (lenient decode)
`list` items and `status_grid` cells share a typed status enum: `success`, `warning`, `danger`, `info`, `pending`, `done`, `neutral`. Common synonyms map to the canonical case (`ok` / `up` → success, `down` / `error` / `failed` → danger, `active` → info, `complete` → done). Unknown strings render as plain text rather than crashing — the dev's machine alone has dashboards using ad-hoc statuses like `"ok"`, `"up"`, `"info"`, and they all keep working byte-identically. **For new templates, prefer the canonical names** so colors stay predictable across releases.
### Structured widget error card
The legacy "Unknown: \<type\>" placeholder is replaced with a structured error card surfacing the widget's title, the specific reason (unknown type, missing file, parse error, path escapes project root), and a hint. Used by the dispatcher's default branch and by every v2.7 file-reading widget when its underlying data can't be loaded.
### Schema mirror — single source of truth
The widget vocabulary is now defined once at [`tools/widget-schema.json`](../../tools/widget-schema.json) instead of being maintained in three places by hand. The catalog validator ([`tools/build-catalog.py`](../../tools/build-catalog.py)) reads from it and now enforces per-type required fields (e.g. `cron_status` requires `jobId`, `log_tail` requires `path`). Adding a future widget type means editing one JSON file plus implementing a Swift view + a JS renderer; the validator picks up the addition automatically.
### What's deferred
Two items from the design plan stayed deferred:
- **Per-widget data sources + per-widget refresh granularity.** The general "widget points at a typed data source (file / cron / json-path / …)" abstraction is the next-largest win in this area but materially expands the model + JS mirror + validator + authoring skill surface. The project-wide watch covers the common cron-driven workflow without it; revisit when a real-world template wants the granular control.
- **Cross-project health digest sidebar rollup.** Counting attention-needed projects across the registry was scoped for this release but ended up not pulling its weight against the rest of the work; the underlying status enum (B.1) makes a future digest cheap to add.
### Compatibility
- macOS 14+ (unchanged).
- Hermes target: still **v2026.4.30 (v0.12.0)**. No new Hermes capability gates added.
- Existing `dashboard.json` files render unchanged.
- Existing `.scarftemplate` bundles install unchanged. Catalog manifest schemaVersion stays at 1/2/3 — no bump.
- The `awizemann/template-author` bundle was rebuilt to ship the updated `SKILL.md` (Widget Catalog v2.7+ section) so Hermes can scaffold dashboards using the new widget types out of the box.
@@ -39,6 +39,13 @@ public struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
public var dashboardPath: String { path + "/.scarf/dashboard.json" }
/// Directory holding the project's Scarf-managed sidecar files
/// (dashboard.json, manifest.json, template.lock.json, config.json,
/// plus any cron-job-written reports the dashboard widgets reference).
/// Watched as a unit by `HermesFileWatcher` so any file added /
/// removed / renamed inside refreshes the dashboard automatically.
public var scarfDir: String { path + "/.scarf" }
// MARK: - Codable (custom for backward compat)
private enum CodingKeys: String, CodingKey {
@@ -152,29 +159,54 @@ public struct DashboardWidget: Codable, Sendable, Identifiable {
// List
public let items: [ListItem]?
// Webview
// Webview / Image (image reuses `url` for remote, `path` for local)
public let url: String?
public let height: Double?
// v2.7 file-reading widgets (markdown_file, log_tail, image-local).
// `path` is resolved relative to the project root (the directory that
// contains `.scarf/`). Renderers must reject `..` segments after
// normalization to prevent escape from the project boundary.
public let path: String?
public let lines: Int?
// v2.7 cron_status widget; `jobId` matches HermesCronJob.id.
public let jobId: String?
// v2.7 status_grid widget; `cells` carries label + status per square,
// `gridColumns` overrides the auto-fit column count (keep distinct
// from `columns` which is the table-widget header list).
public let cells: [StatusGridCell]?
public let gridColumns: Int?
// v2.7 optional sparkline trend on `stat` widgets.
public let sparkline: [Double]?
public init(
type: String,
title: String,
value: WidgetValue?,
icon: String?,
color: String?,
subtitle: String?,
label: String?,
content: String?,
format: String?,
columns: [String]?,
rows: [[String]]?,
chartType: String?,
xLabel: String?,
yLabel: String?,
series: [ChartSeries]?,
items: [ListItem]?,
url: String?,
height: Double?
value: WidgetValue? = nil,
icon: String? = nil,
color: String? = nil,
subtitle: String? = nil,
label: String? = nil,
content: String? = nil,
format: String? = nil,
columns: [String]? = nil,
rows: [[String]]? = nil,
chartType: String? = nil,
xLabel: String? = nil,
yLabel: String? = nil,
series: [ChartSeries]? = nil,
items: [ListItem]? = nil,
url: String? = nil,
height: Double? = nil,
path: String? = nil,
lines: Int? = nil,
jobId: String? = nil,
cells: [StatusGridCell]? = nil,
gridColumns: Int? = nil,
sparkline: [Double]? = nil
) {
self.type = type
self.title = title
@@ -194,6 +226,29 @@ public struct DashboardWidget: Codable, Sendable, Identifiable {
self.items = items
self.url = url
self.height = height
self.path = path
self.lines = lines
self.jobId = jobId
self.cells = cells
self.gridColumns = gridColumns
self.sparkline = sparkline
}
}
// MARK: - Status Grid Data (v2.7)
/// One cell of a `status_grid` widget. Status semantics match `ListItem.status`
/// parsed via `ListItemStatus(raw:)` so the same vocabulary + synonyms apply.
public struct StatusGridCell: Codable, Sendable, Identifiable, Hashable {
public var id: String { label }
public let label: String
public let status: String?
public let tooltip: String?
public init(label: String, status: String? = nil, tooltip: String? = nil) {
self.label = label
self.status = status
self.tooltip = tooltip
}
}
@@ -284,3 +339,47 @@ public struct ListItem: Codable, Sendable, Identifiable {
self.status = status
}
}
/// Typed semantic status for `ListItem` (and `status_grid` cells in v2.7+).
///
/// Wire format stays a free `String?` on `ListItem` for backwards compatibility
/// pre-existing dashboards never break. Renderers call `ListItemStatus(raw:)`
/// to map known values + synonyms to a canonical case; unknown values return
/// `nil` and render as plain neutral text.
public enum ListItemStatus: String, Sendable, Hashable, CaseIterable {
case success
case warning
case danger
case info
case pending
case done
case neutral
/// Lenient parse accepts canonical names plus common synonyms seen in
/// real-world dashboards (`ok`/`up` success, `down`/`error`/`failed`
/// danger, `active` info). Returns `nil` for unrecognized strings so
/// the renderer can fall back to plain text.
public init?(raw: String?) {
guard let raw = raw?.trimmingCharacters(in: .whitespaces).lowercased(), !raw.isEmpty else {
return nil
}
switch raw {
case "success", "ok", "up", "green", "passing":
self = .success
case "warning", "warn", "yellow", "degraded":
self = .warning
case "danger", "down", "error", "failed", "failure", "red", "critical":
self = .danger
case "info", "active", "blue":
self = .info
case "pending", "queued", "waiting", "scheduled":
self = .pending
case "done", "complete", "completed", "finished":
self = .done
case "neutral", "muted", "gray":
self = .neutral
default:
return nil
}
}
}
@@ -164,6 +164,16 @@ public final class ProjectsViewModel {
projects.map(\.dashboardPath)
}
/// Per-project `.scarf/` directories watched alongside `dashboardPaths`
/// so that file-reading widgets (markdown_file, log_tail, image) refresh
/// when their underlying files are added / removed / renamed inside the
/// directory by a cron job. In-place file appends within an existing
/// file are NOT detected here; the cron job should write atomically
/// (write-then-rename) or `touch` dashboard.json after each run.
public var projectScarfDirs: [String] {
projects.map(\.scarfDir)
}
private func loadDashboard(for project: ProjectEntry) {
dashboardError = nil
if !service.dashboardExists(for: project) {
@@ -0,0 +1,48 @@
import Testing
import Foundation
@testable import ScarfCore
/// Verifies the lenient `ListItemStatus(raw:)` parser. Real dashboards on
/// disk use a mix of canonical names + synonyms (`done`, `info`, `ok`,
/// `pending`, `up` are seen on the dev's machine today) the parser must
/// fold those onto the canonical case set without throwing or returning nil
/// for the common synonyms. Unknown strings nil so the renderer can fall
/// back to plain text without losing the original.
@Suite struct ListItemStatusTests {
@Test func canonicalNamesParse() {
for c in ListItemStatus.allCases {
#expect(ListItemStatus(raw: c.rawValue) == c)
}
}
@Test func synonymsCollapseToCanonical() {
#expect(ListItemStatus(raw: "ok") == .success)
#expect(ListItemStatus(raw: "OK") == .success) // case-insensitive
#expect(ListItemStatus(raw: " up ") == .success) // whitespace trim
#expect(ListItemStatus(raw: "down") == .danger)
#expect(ListItemStatus(raw: "error") == .danger)
#expect(ListItemStatus(raw: "failed") == .danger)
#expect(ListItemStatus(raw: "warn") == .warning)
#expect(ListItemStatus(raw: "degraded") == .warning)
#expect(ListItemStatus(raw: "active") == .info)
#expect(ListItemStatus(raw: "queued") == .pending)
#expect(ListItemStatus(raw: "complete") == .done)
}
@Test func unknownReturnsNilNotThrows() {
#expect(ListItemStatus(raw: "hologram") == nil)
#expect(ListItemStatus(raw: "") == nil)
#expect(ListItemStatus(raw: nil) == nil)
#expect(ListItemStatus(raw: " ") == nil)
}
@Test func listItemStillDecodesUnknownStatusString() throws {
// Backwards-compat invariant: `ListItem.status` stays a free String? on
// the wire. Decoding a v2.6 dashboard with a non-canonical status must
// succeed and preserve the original string (renderer falls back).
let json = #"{"text":"foo","status":"weird"}"#.data(using: .utf8)!
let item = try JSONDecoder().decode(ListItem.self, from: json)
#expect(item.status == "weird")
#expect(ListItemStatus(raw: item.status) == nil)
}
}
@@ -102,17 +102,31 @@ struct WidgetView: View {
}
private var unsupportedView: some View {
VStack(alignment: .leading, spacing: 4) {
Label(widget.title, systemImage: "questionmark.app.dashed")
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("Widget type \"\(widget.type)\" isn't supported in this version of Scarf yet.")
}
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
}
}
}
@@ -10,6 +10,10 @@ final class HermesFileWatcher {
/// Remote polling task. Non-nil only when `context.isRemote`. Cancelled
/// on `stopWatching()`.
private var remotePollTask: Task<Void, Never>?
/// Project directory paths fed to the SSH poller alongside `watchedCorePaths`.
/// Updated by `updateProjectWatches` so the remote stream restarts whenever
/// the project list changes.
private var remoteProjectPaths: [String] = []
let context: ServerContext
private let transport: any ServerTransport
@@ -52,17 +56,7 @@ final class HermesFileWatcher {
func startWatching() {
if context.isRemote {
// FSEvents doesn't reach across SSH. Drive lastChangeDate off
// the transport's AsyncStream, which polls stat mtime on a
// shared ControlMaster channel (~5ms per tick).
let stream = transport.watchPaths(watchedCorePaths)
remotePollTask = Task { [weak self] in
for await _ in stream {
await MainActor.run { [weak self] in
self?.lastChangeDate = Date()
}
}
}
startRemotePoller()
return
}
@@ -79,6 +73,21 @@ final class HermesFileWatcher {
// touches `gateway_state.json` which the watcher catches.
}
/// (Re)start the SSH polling stream over the union of `watchedCorePaths`
/// and the current `remoteProjectPaths`. Called on initial start and
/// whenever `updateProjectWatches` changes the project set.
private func startRemotePoller() {
remotePollTask?.cancel()
let stream = transport.watchPaths(watchedCorePaths + remoteProjectPaths)
remotePollTask = Task { [weak self] in
for await _ in stream {
await MainActor.run { [weak self] in
self?.lastChangeDate = Date()
}
}
}
}
func stopWatching() {
for source in coreSources + projectSources {
source.cancel()
@@ -91,11 +100,26 @@ final class HermesFileWatcher {
remotePollTask = nil
}
func updateProjectWatches(_ dashboardPaths: [String]) {
// Remote contexts don't support per-project FSEvents watches today
// the shared mtime poll covers the core set. Adding per-project
// polling is a Phase 4 polish item.
guard !context.isRemote else { return }
/// Watch each project's `dashboard.json` AND its enclosing `.scarf/`
/// directory. Watching both is what lets file-reading widgets
/// (markdown_file, log_tail, image) refresh when a cron job rewrites
/// a sidecar file: dir-level FSEvents fire on add/remove/rename inside
/// `.scarf/`, file-level FSEvents fire on dashboard.json content
/// changes. In-place writes to an existing sidecar file (e.g., `>>` log
/// append) are NOT detected by convention the cron job should write
/// atomically (write-then-rename) or `touch dashboard.json` after each
/// run.
func updateProjectWatches(dashboardPaths: [String], scarfDirs: [String]) {
if context.isRemote {
// Restart the SSH poller with the union of core + project dir
// paths. `stat -c %Y` on a directory tracks mtime, which ticks
// on add/remove/rename inside the dir same coverage as the
// local FSEvents directory watch below.
let union = Array(Set(dashboardPaths + scarfDirs))
remoteProjectPaths = union.sorted()
startRemotePoller()
return
}
for source in projectSources {
source.cancel()
}
@@ -105,6 +129,11 @@ final class HermesFileWatcher {
projectSources.append(source)
}
}
for dir in scarfDirs {
if let source = makeSource(for: dir) {
projectSources.append(source)
}
}
}
private func makeSource(for path: String) -> DispatchSourceFileSystemObject? {
@@ -94,7 +94,7 @@ struct ProjectsView: View {
let project = viewModel.projects.first(where: { $0.name == name }) {
viewModel.selectProject(project)
}
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
fileWatcher.updateProjectWatches(dashboardPaths: viewModel.dashboardPaths, scarfDirs: viewModel.projectScarfDirs)
// Cold-launch deep link or Finder double-click: the router may
// have a URL staged before this view installed the onChange
// observer below. Without this first-appearance check,
@@ -107,7 +107,7 @@ struct ProjectsView: View {
}
.onChange(of: fileWatcher.lastChangeDate) {
viewModel.load()
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
fileWatcher.updateProjectWatches(dashboardPaths: viewModel.dashboardPaths, scarfDirs: viewModel.projectScarfDirs)
}
.onChange(of: TemplateURLRouter.shared.pendingInstallURL) { _, new in
// A URL landed *while the app was already running*.
@@ -122,7 +122,7 @@ struct ProjectsView: View {
if let project = viewModel.projects.first(where: { $0.name == entry.name }) {
viewModel.selectProject(project)
}
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
fileWatcher.updateProjectWatches(dashboardPaths: viewModel.dashboardPaths, scarfDirs: viewModel.projectScarfDirs)
}
}
.sheet(item: $exportSheetProject) { project in
@@ -155,7 +155,7 @@ struct ProjectsView: View {
coordinator.selectedProjectName = nil
}
viewModel.load()
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
fileWatcher.updateProjectWatches(dashboardPaths: viewModel.dashboardPaths, scarfDirs: viewModel.projectScarfDirs)
}
}
.sheet(item: $configEditorProject) { project in
@@ -339,7 +339,7 @@ struct ProjectsView: View {
.sheet(isPresented: $showingAddSheet) {
AddProjectSheet(context: serverContext) { name, path in
viewModel.addProject(name: name, path: path)
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
fileWatcher.updateProjectWatches(dashboardPaths: viewModel.dashboardPaths, scarfDirs: viewModel.projectScarfDirs)
}
}
.sheet(item: $renameTarget) { target in
@@ -485,6 +485,9 @@ struct ProjectsView: View {
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
// v2.7: file-reading widgets (markdown_file, log_tail, image-local)
// resolve their `path` field against this root via WidgetPathResolver.
.environment(\.selectedProjectRoot, viewModel.selectedProject?.path)
}
private func siteTab(_ widget: DashboardWidget) -> some View {
@@ -600,19 +603,22 @@ struct WidgetView: View {
ListWidgetView(widget: widget)
case "webview":
WebviewWidgetView(widget: widget)
case "cron_status":
CronStatusWidgetView(widget: widget)
case "log_tail":
LogTailWidgetView(widget: widget)
case "markdown_file":
MarkdownFileWidgetView(widget: widget)
case "image":
ImageWidgetView(widget: widget)
case "status_grid":
StatusGridWidgetView(widget: widget)
default:
VStack {
Image(systemName: "questionmark.square.dashed")
.font(.title2)
.foregroundStyle(.secondary)
Text("Unknown: \(widget.type)")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, minHeight: 60)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
WidgetErrorCard(
title: widget.title,
reason: "Unknown widget type: \"\(widget.type)\"",
hint: "This Scarf build doesn't render this widget type. Update Scarf or change the widget type in dashboard.json. Known types are listed in tools/widget-schema.json."
)
}
}
}
@@ -0,0 +1,182 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Surfaces last-run / next-run / state for a single Hermes cron job by id,
/// plus a short tail of its most recent output. Read-only Run / Pause /
/// Resume controls remain on the main Cron tab to keep this widget non-
/// destructive (the dashboard shouldn't be a place where the user
/// accidentally fires a job).
///
/// Refreshes whenever `HermesFileWatcher.lastChangeDate` ticks, which
/// covers both `cron/jobs.json` mutations and the project-wide `.scarf/`
/// watch installed in v2.7. Reads happen detached never on the main
/// actor.
struct CronStatusWidgetView: View {
let widget: DashboardWidget
@Environment(\.serverContext) private var serverContext
@Environment(HermesFileWatcher.self) private var fileWatcher
@State private var job: HermesCronJob?
@State private var outputTail: String?
@State private var loadError: String?
@State private var isLoading = false
private var jobId: String? { widget.jobId }
private var lineCount: Int { max(1, min(40, widget.lines ?? 5)) }
var body: some View {
Group {
if let jobId, !jobId.isEmpty {
content(jobId: jobId)
} else {
WidgetErrorCard(
title: widget.title,
reason: "Missing required `jobId` field.",
hint: "Set `jobId` to a Hermes cron job's id (visible in the Cron tab)."
)
}
}
.task(id: fileWatcher.lastChangeDate) {
await reload()
}
}
@ViewBuilder
private func content(jobId: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "clock.arrow.circlepath")
.foregroundStyle(.secondary)
.scarfStyle(.caption)
Text(widget.title)
.scarfStyle(.caption)
.foregroundStyle(.secondary)
Spacer()
if isLoading && job == nil {
ProgressView().controlSize(.mini)
}
}
if let job {
jobRow(job)
if let tail = outputTail, !tail.isEmpty {
tailView(tail)
}
} else if let loadError {
Text(loadError)
.font(.callout)
.foregroundStyle(.secondary)
} else if !isLoading {
Text("No cron job with id `\(jobId)`.")
.font(.callout)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(ScarfColor.backgroundSecondary)
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg))
}
@ViewBuilder
private func jobRow(_ job: HermesCronJob) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text(job.name)
.font(.callout.weight(.medium))
.lineLimit(1)
Spacer()
stateBadge(for: job)
}
HStack(spacing: 12) {
if let last = job.lastRunAt, !last.isEmpty {
Label(last, systemImage: "checkmark.circle")
.font(.caption2)
.foregroundStyle(.secondary)
.labelStyle(.titleAndIcon)
}
if let next = job.nextRunAt, !next.isEmpty {
Label(next, systemImage: "calendar")
.font(.caption2)
.foregroundStyle(.secondary)
.labelStyle(.titleAndIcon)
}
}
if let lastError = job.lastError, !lastError.isEmpty {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.danger)
Text(lastError)
.lineLimit(2)
}
.font(.caption2)
.foregroundStyle(ScarfColor.danger)
}
}
}
private func stateBadge(for job: HermesCronJob) -> some View {
let (label, status): (String, ListItemStatus) = {
if !job.enabled { return ("DISABLED", .neutral) }
switch job.state.lowercased() {
case "active", "running": return (job.state.uppercased(), .info)
case "paused": return ("PAUSED", .warning)
case "error", "failed": return (job.state.uppercased(), .danger)
default: return (job.state.uppercased(), .success)
}
}()
return Text(label)
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(status.tint.opacity(0.15))
.foregroundStyle(status.tint)
.clipShape(Capsule())
}
@ViewBuilder
private func tailView(_ tail: String) -> some View {
let lines = tail.split(separator: "\n", omittingEmptySubsequences: false).suffix(lineCount)
VStack(alignment: .leading, spacing: 1) {
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
Text(String(line))
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(.quaternary.opacity(0.4))
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.sm))
}
private func reload() async {
guard let jobId, !jobId.isEmpty else { return }
let context = serverContext
let lines = lineCount
isLoading = true
defer { isLoading = false }
let result: (HermesCronJob?, String?, String?) = await Task.detached {
let fs = HermesFileService(context: context)
let jobs = fs.loadCronJobs()
guard let match = jobs.first(where: { $0.id == jobId }) else {
return (nil, nil, "No cron job with id `\(jobId)`.")
}
let outputRaw = fs.loadCronOutput(jobId: jobId)
let trimmed: String? = {
guard let outputRaw else { return nil }
let stripped = AnsiStripper.strip(outputRaw)
let allLines = stripped.split(separator: "\n", omittingEmptySubsequences: false)
let kept = allLines.suffix(lines)
return kept.joined(separator: "\n")
}()
return (match, trimmed, nil)
}.value
self.job = result.0
self.outputTail = result.1
self.loadError = result.2
}
}
@@ -0,0 +1,124 @@
import SwiftUI
import ScarfCore
import ScarfDesign
import AppKit
/// Renders a local file (`path`, resolved relative to project root) or a
/// remote `url`. `path` wins when both are set. Local files refresh via the
/// project-wide `.scarf/` directory watch (v2.7); remote URLs are loaded
/// once per appearance and cached by the SwiftUI `AsyncImage` machinery.
struct ImageWidgetView: View {
let widget: DashboardWidget
@Environment(\.serverContext) private var serverContext
@Environment(\.selectedProjectRoot) private var projectRoot
@Environment(HermesFileWatcher.self) private var fileWatcher
@State private var localImage: NSImage?
@State private var loadError: String?
private var displayHeight: CGFloat? {
widget.height.map { CGFloat($0) }
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: "photo")
.foregroundStyle(.secondary)
.scarfStyle(.caption)
Text(widget.title)
.scarfStyle(.caption)
.foregroundStyle(.secondary)
}
content
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(ScarfColor.backgroundSecondary)
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg))
}
@ViewBuilder
private var content: some View {
if let _ = widget.path {
localContent
} else if let url = widget.url, let parsed = URL(string: url) {
remoteContent(url: parsed)
} else {
WidgetErrorCard(
title: "",
reason: "Image widget needs either `path` (local file relative to project root) or `url` (remote)."
)
}
}
@ViewBuilder
private var localContent: some View {
switch WidgetPathResolver.resolve(widget.path, projectRoot: projectRoot) {
case .failure(let err):
WidgetErrorCard(title: "", reason: err.userMessage)
case .success(let resolved):
Group {
if let img = localImage {
Image(nsImage: img)
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity, maxHeight: displayHeight)
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.sm))
} else if let loadError {
Text(loadError)
.font(.caption)
.foregroundStyle(.secondary)
} else {
ProgressView().controlSize(.small)
}
}
.task(id: "\(resolved)|\(fileWatcher.lastChangeDate.timeIntervalSince1970)") {
await loadLocal(absPath: resolved)
}
}
}
private func remoteContent(url: URL) -> some View {
AsyncImage(url: url) { phase in
switch phase {
case .empty:
ProgressView().controlSize(.small)
case .success(let img):
img.resizable()
.scaledToFit()
.frame(maxWidth: .infinity, maxHeight: displayHeight)
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.sm))
case .failure(let err):
Text("Could not load image: \(err.localizedDescription)")
.font(.caption)
.foregroundStyle(.secondary)
@unknown default:
EmptyView()
}
}
}
private func loadLocal(absPath: String) async {
let context = serverContext
let outcome: WidgetIOResult<NSImage> = await Task.detached {
let transport = context.makeTransport()
do {
let data = try transport.readFile(absPath)
if let img = NSImage(data: data) { return .success(img) }
return .failure("File is not a recognized image format.")
} catch {
return .failure("Could not read file: \(error.localizedDescription)")
}
}.value
switch outcome {
case .success(let img):
self.localImage = img
self.loadError = nil
case .failure(let err):
self.localImage = nil
self.loadError = err.message
}
}
}
@@ -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,59 @@ struct ListWidgetView: View {
.background(ScarfColor.backgroundSecondary)
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg))
}
}
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"
/// One row of a list widget. Maps `item.status` through `ListItemStatus(raw:)`
/// to a typed badge (icon + color). Unknown strings render as plain text with
/// the original string preserved as a trailing badge so nothing's hidden.
struct ListItemRow: View {
let item: ListItem
private var typedStatus: ListItemStatus? { ListItemStatus(raw: item.status) }
var body: some View {
HStack(spacing: 6) {
Image(systemName: typedStatus?.iconName ?? "circle")
.font(.caption2)
.foregroundStyle(typedStatus?.tint ?? .secondary)
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
extension ListItemStatus {
var iconName: String {
switch self {
case .success: 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 .done: return "checkmark.circle.fill"
case .neutral: return "circle"
}
}
var tint: Color {
switch self {
case .success, .done: return ScarfColor.success
case .warning: return ScarfColor.warning
case .danger: return ScarfColor.danger
case .info: return ScarfColor.info
case .pending: return .secondary
case .neutral: return .secondary
}
}
}
@@ -0,0 +1,131 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Tails the last N lines of a file under the project root, monospaced.
/// Best paired with cron jobs that write atomically (write-temp + rename)
/// the project-wide `.scarf/` directory watch (v2.7) refreshes the
/// widget when a new file lands. In-place appends to an existing file
/// won't tick `lastChangeDate`; the cron job should `touch dashboard.json`
/// after each run if it appends in place.
struct LogTailWidgetView: View {
let widget: DashboardWidget
@Environment(\.serverContext) private var serverContext
@Environment(\.selectedProjectRoot) private var projectRoot
@Environment(HermesFileWatcher.self) private var fileWatcher
@State private var loadedTail: String?
@State private var loadError: WidgetPathResolver.ResolveError?
@State private var ioError: String?
@State private var isLoading = false
private var lineCount: Int { max(1, min(200, widget.lines ?? 20)) }
var body: some View {
Group {
switch WidgetPathResolver.resolve(widget.path, projectRoot: projectRoot) {
case .failure(let err):
WidgetErrorCard(
title: widget.title,
reason: err.userMessage,
hint: "Set `path` to a file relative to the project root, e.g. `reports/uptime.log`."
)
case .success(let resolved):
content(for: resolved)
.task(id: refreshKey(resolved)) {
await reload(absPath: resolved)
}
}
}
}
private func refreshKey(_ resolved: String) -> String {
// Force a reload whenever either the widget config or any project
// file changes (the latter via fileWatcher.lastChangeDate).
"\(resolved)|\(lineCount)|\(fileWatcher.lastChangeDate.timeIntervalSince1970)"
}
@ViewBuilder
private func content(for absPath: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: "doc.text.below.ecg")
.foregroundStyle(.secondary)
.scarfStyle(.caption)
Text(widget.title)
.scarfStyle(.caption)
.foregroundStyle(.secondary)
Spacer()
Text("last \(lineCount)")
.font(.caption2)
.foregroundStyle(.tertiary)
if isLoading {
ProgressView().controlSize(.mini)
}
}
if let ioError {
Text(ioError)
.font(.caption)
.foregroundStyle(.secondary)
} else if let loadedTail {
tailBody(loadedTail)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(ScarfColor.backgroundSecondary)
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg))
}
@ViewBuilder
private func tailBody(_ tail: String) -> some View {
let lines = tail.split(separator: "\n", omittingEmptySubsequences: false)
if lines.isEmpty {
Text("(empty)").font(.caption2).foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 1) {
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
Text(String(line))
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.tail)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(.quaternary.opacity(0.4))
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.sm))
}
}
private func reload(absPath: String) async {
let context = serverContext
let n = lineCount
isLoading = true
defer { isLoading = false }
let outcome: WidgetIOResult<String> = await Task.detached {
let transport = context.makeTransport()
do {
let data = try transport.readFile(absPath)
guard let text = String(data: data, encoding: .utf8) else {
return .failure("File is not UTF-8 — log_tail expects text.")
}
let stripped = AnsiStripper.strip(text)
let parts = stripped.split(separator: "\n", omittingEmptySubsequences: false)
return .success(parts.suffix(n).joined(separator: "\n"))
} catch {
return .failure("Could not read file: \(error.localizedDescription)")
}
}.value
switch outcome {
case .success(let s):
self.loadedTail = s
self.ioError = nil
case .failure(let err):
self.loadedTail = nil
self.ioError = err.message
}
}
}
@@ -0,0 +1,92 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Renders a markdown file from the project root through the same
/// `MarkdownContentView` pipeline used by the inline `text` widget. Picks
/// up edits automatically via the project-wide `.scarf/` directory watch
/// (v2.7).
struct MarkdownFileWidgetView: View {
let widget: DashboardWidget
@Environment(\.serverContext) private var serverContext
@Environment(\.selectedProjectRoot) private var projectRoot
@Environment(HermesFileWatcher.self) private var fileWatcher
@State private var loadedContent: String?
@State private var ioError: String?
@State private var isLoading = false
var body: some View {
Group {
switch WidgetPathResolver.resolve(widget.path, projectRoot: projectRoot) {
case .failure(let err):
WidgetErrorCard(
title: widget.title,
reason: err.userMessage,
hint: "Set `path` to a markdown file relative to the project root, e.g. `reports/weekly.md`."
)
case .success(let resolved):
content(for: resolved)
.task(id: "\(resolved)|\(fileWatcher.lastChangeDate.timeIntervalSince1970)") {
await reload(absPath: resolved)
}
}
}
}
@ViewBuilder
private func content(for absPath: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: "doc.text")
.foregroundStyle(.secondary)
.scarfStyle(.caption)
Text(widget.title)
.scarfStyle(.caption)
.foregroundStyle(.secondary)
Spacer()
if isLoading {
ProgressView().controlSize(.mini)
}
}
if let ioError {
Text(ioError)
.font(.caption)
.foregroundStyle(.secondary)
} else if let loadedContent {
MarkdownContentView(content: loadedContent)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(ScarfColor.backgroundSecondary)
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg))
}
private func reload(absPath: String) async {
let context = serverContext
isLoading = true
defer { isLoading = false }
let outcome: WidgetIOResult<String> = await Task.detached {
let transport = context.makeTransport()
do {
let data = try transport.readFile(absPath)
guard let text = String(data: data, encoding: .utf8) else {
return .failure("File is not UTF-8 — markdown_file expects text.")
}
return .success(text)
} catch {
return .failure("Could not read file: \(error.localizedDescription)")
}
}.value
switch outcome {
case .success(let s):
self.loadedContent = s
self.ioError = nil
case .failure(let err):
self.loadedContent = nil
self.ioError = err.message
}
}
}
@@ -2,6 +2,33 @@ import SwiftUI
import ScarfCore
import ScarfDesign
/// Tiny inline trend line drawn under a `stat` widget's value. Pure SwiftUI
/// `Path`, no Swift Charts dependency stays light enough to render
/// dozens per dashboard without measurable cost.
struct SparklineView: View {
let values: [Double]
let tint: Color
var body: some View {
GeometryReader { geo in
let minV = values.min() ?? 0
let maxV = values.max() ?? 1
let span = max(0.0001, maxV - minV)
let stepX = values.count > 1 ? geo.size.width / CGFloat(values.count - 1) : 0
Path { path in
for (i, v) in values.enumerated() {
let x = CGFloat(i) * stepX
let normalized = (v - minV) / span
let y = geo.size.height - CGFloat(normalized) * geo.size.height
if i == 0 { path.move(to: CGPoint(x: x, y: y)) }
else { path.addLine(to: CGPoint(x: x, y: y)) }
}
}
.stroke(tint.opacity(0.85), lineWidth: 1.2)
}
}
}
struct StatWidgetView: View {
let widget: DashboardWidget
@@ -30,6 +57,11 @@ struct StatWidgetView: View {
.font(.caption2)
.foregroundStyle(widgetColor)
}
if let sparkline = widget.sparkline, sparkline.count >= 2 {
SparklineView(values: sparkline, tint: widgetColor)
.frame(height: 18)
.padding(.top, 2)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
@@ -0,0 +1,78 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Compact NxM grid of colored cells, one per service / item. Denser than
/// `list` for monitoring dashboards with 12+ entities. Uses the same
/// `ListItemStatus` vocabulary as the list widget so colors stay consistent.
struct StatusGridWidgetView: View {
let widget: DashboardWidget
private var cells: [StatusGridCell] { widget.cells ?? [] }
/// Auto-fit columns when not specified: aim for ~6 cells per row, capped
/// at 12, floored at 4. Ensures both 8-cell and 36-cell grids look ok.
private var columnCount: Int {
if let n = widget.gridColumns, n > 0 { return min(20, n) }
let count = cells.count
if count <= 4 { return max(1, count) }
if count <= 12 { return 6 }
if count <= 24 { return 8 }
return 12
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "square.grid.3x3.fill")
.foregroundStyle(.secondary)
.scarfStyle(.caption)
Text(widget.title)
.scarfStyle(.caption)
.foregroundStyle(.secondary)
Spacer()
Text("\(cells.count)")
.font(.caption2)
.foregroundStyle(.tertiary)
}
if cells.isEmpty {
Text("No cells.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: columnCount),
spacing: 4
) {
ForEach(cells) { cell in
StatusGridCellView(cell: cell)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(ScarfColor.backgroundSecondary)
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg))
}
}
private struct StatusGridCellView: View {
let cell: StatusGridCell
private var typedStatus: ListItemStatus { ListItemStatus(raw: cell.status) ?? .neutral }
var body: some View {
VStack(spacing: 2) {
RoundedRectangle(cornerRadius: 3)
.fill(typedStatus.tint.opacity(0.85))
.frame(height: 18)
Text(cell.label)
.font(.system(size: 9, weight: .medium))
.lineLimit(1)
.truncationMode(.tail)
.foregroundStyle(.secondary)
}
.help(cell.tooltip ?? cell.label + (cell.status.map { "\($0)" } ?? ""))
}
}
@@ -0,0 +1,45 @@
import SwiftUI
import ScarfDesign
/// Replacement for the legacy "Unknown widget" placeholder. Surfaces the
/// widget's own title plus a structured reason so dashboard authors can see
/// at a glance what's wrong (unknown type, missing file, parse error, ).
///
/// Used by the `WidgetView` dispatcher's default branch and (in v2.7+) by
/// file-reading widgets that can't load their underlying data.
struct WidgetErrorCard: View {
let title: String
let reason: String
var hint: String? = nil
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.warning)
.font(.caption)
Text(title.isEmpty ? "Widget error" : title)
.scarfStyle(.caption)
.foregroundStyle(.secondary)
}
Text(reason)
.font(.callout)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
if let hint, !hint.isEmpty {
Text(hint)
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(ScarfColor.warning.opacity(0.08))
.overlay(
RoundedRectangle(cornerRadius: ScarfRadius.lg)
.strokeBorder(ScarfColor.warning.opacity(0.3), lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg))
}
}
@@ -1,4 +1,44 @@
import SwiftUI
import Foundation
/// Strips CSI ANSI escape sequences (`ESC [ ... letter`) so log output
/// pasted into the dashboard renders cleanly. Single regex, fast enough
/// for the small windows the log_tail / cron_status widgets work with.
/// Lightweight result type for file-reading widgets failure is just a
/// human-readable string the widget surfaces in its error card. `Result<_, String>`
/// won't compile because `String` doesn't conform to `Error`; this alias
/// uses a typed wrapper so the rest of the call sites stay readable.
typealias WidgetIOResult<T> = Result<T, WidgetIOError>
struct WidgetIOError: Error, Sendable {
let message: String
nonisolated init(_ m: String) { self.message = m }
}
extension Result where Failure == WidgetIOError {
/// Convenience constructor `.failure("")` instead of
/// `.failure(WidgetIOError(""))`. Marked nonisolated so detached
/// tasks can call it from outside the main actor.
nonisolated static func failure(_ message: String) -> Self {
.failure(WidgetIOError(message))
}
}
enum AnsiStripper {
/// Single-call regex strip. Compiles per call log windows are small,
/// the cost is negligible, and skipping a `static let` cache means
/// callers from `Task.detached` don't fight the Swift 6 actor checker.
nonisolated static func strip(_ s: String) -> String {
// ESC = \u{1B}; CSI = ESC [ ; final byte is in 0x40..0x7E.
guard let pattern = try? NSRegularExpression(
pattern: "\u{1B}\\[[0-?]*[ -/]*[@-~]", options: []
) else { return s }
let range = NSRange(s.startIndex..., in: s)
return pattern.stringByReplacingMatches(
in: s, options: [], range: range, withTemplate: ""
)
}
}
func parseColor(_ name: String?) -> Color {
switch name?.lowercased() {
@@ -0,0 +1,67 @@
import SwiftUI
/// Project root the dashboard widgets resolve relative `path` fields against.
/// Set by `ProjectsView` from the currently-selected project; nil when no
/// project is active. v2.7+ file-reading widgets (markdown_file, log_tail,
/// image-local) read this via the environment.
private struct SelectedProjectRootKey: EnvironmentKey {
static let defaultValue: String? = nil
}
extension EnvironmentValues {
var selectedProjectRoot: String? {
get { self[SelectedProjectRootKey.self] }
set { self[SelectedProjectRootKey.self] = newValue }
}
}
/// Resolves a widget's `path` field against the project root. Rejects paths
/// that escape the project boundary via `..` segments after normalization,
/// rejects absolute paths, and rejects empty / nil inputs. The returned
/// path is suitable to hand to `transport.readFile`.
///
/// Returns nil + the reason if the path is invalid; widgets surface that
/// reason via `WidgetErrorCard`.
enum WidgetPathResolver {
enum ResolveError: Error, Equatable {
case noProject
case missingPath
case absolutePath
case escapesProject
}
static func resolve(_ relativePath: String?, projectRoot: String?) -> Result<String, ResolveError> {
guard let projectRoot, !projectRoot.isEmpty else { return .failure(.noProject) }
guard let relativePath, !relativePath.isEmpty else { return .failure(.missingPath) }
if relativePath.hasPrefix("/") { return .failure(.absolutePath) }
// Strip a single leading "./" common in template-authored paths.
let trimmed = relativePath.hasPrefix("./") ? String(relativePath.dropFirst(2)) : relativePath
// Walk the segments and reject any "..": the project root is the
// trust boundary, anything reaching outside it is rejected. We do
// this BEFORE join+standardize so symlink games can't smuggle a
// ".." through path canonicalization.
let segments = trimmed.split(separator: "/", omittingEmptySubsequences: true)
for s in segments where s == ".." { return .failure(.escapesProject) }
let joined = (projectRoot as NSString).appendingPathComponent(trimmed)
let standardized = (joined as NSString).standardizingPath
// Belt and suspenders: ensure the standardized path is still
// beneath projectRoot. Standardizing resolves "./" and may follow
// symlinks; this catch checks the final string prefix.
let rootStd = (projectRoot as NSString).standardizingPath
if !standardized.hasPrefix(rootStd) {
return .failure(.escapesProject)
}
return .success(standardized)
}
}
extension WidgetPathResolver.ResolveError {
var userMessage: String {
switch self {
case .noProject: return "No project selected."
case .missingPath: return "Missing required `path` field."
case .absolutePath: return "Path must be relative to the project root, not absolute."
case .escapesProject: return "Path escapes the project root (`..` segments are not allowed)."
}
}
}
+16
View File
@@ -3087,6 +3087,10 @@
}
}
},
"Auto-refresh OAuth tokens daily" : {
"comment" : "A toggle that enables or disables automatic refresh of OAuth tokens.",
"isCommentAutoGenerated" : true
},
"Aux Models" : {
"localizations" : {
"de" : {
@@ -11217,6 +11221,10 @@
"comment" : "A description of a file that is not part of the template's installation.",
"isCommentAutoGenerated" : true
},
"Keep tokens fresh" : {
"comment" : "Title of a section that lets you enable a cron job that refreshes OAuth tokens.",
"isCommentAutoGenerated" : true
},
"Keep typing to send as a message, or press Esc." : {
"localizations" : {
"de" : {
@@ -17369,6 +17377,10 @@
"comment" : "A message that appears when the app is refreshing",
"isCommentAutoGenerated" : true
},
"Registers a `%@` cron job that runs at 4am daily. Booting a Hermes session is what triggers token refresh — without this, refresh tokens silently expire if you go ~30 days without using Scarf." : {
"comment" : "A paragraph describing the purpose of the auto-refresh toggle.",
"isCommentAutoGenerated" : true
},
"Reload" : {
"localizations" : {
"de" : {
@@ -25378,6 +25390,10 @@
"comment" : "A label for the user's name.",
"isCommentAutoGenerated" : true
},
"Your Nous subscription was last refreshed %lld days ago. Enable the toggle above to prevent the refresh token from expiring." : {
"comment" : "A warning that the refresh token is stale.",
"isCommentAutoGenerated" : true
},
"Your tools will now route through your subscription." : {
"comment" : "A description of the success state of the",
"isCommentAutoGenerated" : true
+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;
}
+1 -1
View File
@@ -67,7 +67,7 @@ Minimum required files under `staging/`:
- **`AGENTS.md`** — the cross-agent spec. Include: project layout, first-run bootstrap (if any), what each cron job expects to happen, and answers to common user prompts (`"what's the status"`, `"add a X"`, etc.).
- **`dashboard.json`** — the Scarf dashboard that renders on the catalog detail page and after install. See [awizemann/site-status-checker/staging/dashboard.json](awizemann/site-status-checker/staging/dashboard.json) for the schema in action.
- **`dashboard.json`** — the Scarf dashboard that renders on the catalog detail page and after install. See [awizemann/site-status-checker/staging/dashboard.json](awizemann/site-status-checker/staging/dashboard.json) for the schema in action. The canonical widget vocabulary lives at [`tools/widget-schema.json`](../tools/widget-schema.json) — the catalog validator reads it and every widget type must appear there. **v2.7+ adds five new widget types** (`markdown_file`, `log_tail`, `cron_status`, `image`, `status_grid`) plus a `sparkline` field on `stat` and a typed status enum on `list` items (`success` / `warning` / `danger` / `info` / `pending` / `done` / `neutral`; common synonyms like `ok` / `up` / `down` also accepted). File-reading widgets (`markdown_file`, `log_tail`, `image`-with-`path`) take a `path` field relative to the project root — by convention place the underlying files inside `.scarf/` so the project-wide directory watch refreshes them automatically.
Optional:
@@ -73,15 +73,21 @@ Map to cron expressions:
### 3. What the dashboard shows
Explain the seven widget types (see Widget Catalog below) in plain English, then ask which ones feel right. Offer concrete suggestions based on the purpose:
Explain the widget catalog (see Widget Catalog sections below) in plain English, then ask which ones feel right. Offer concrete suggestions based on the purpose:
- Counting things (open PRs, failing tests, up/down sites) → `stat` widgets.
- A list of items with status → `list` with `text` + `status` per item.
- Counting things (open PRs, failing tests, up/down sites) → `stat` widgets. Add `sparkline: [Number]` (v2.7+) if you have a recent trend handy.
- A list of items with status → `list` with `text` + `status` per item (≤8 items). 12+ items → use `status_grid` (v2.7+) for a denser layout.
- Time-series data → `chart` with `line` or `bar` type.
- Rows × columns of heterogeneous data → `table`.
- A live URL (useful for monitoring a site) → `webview`. **Including a webview widget exposes a Site tab** next to the Dashboard tab — worth noting to the user.
- A static image / generated chart → `image` (v2.7+; local file or remote URL).
- A progress bar for something with a clear 0-to-N scale → `progress`.
- Static help / markdown → `text` with `format: "markdown"`.
- A longer markdown report the cron job writes → `markdown_file` (v2.7+; reads from a file under the project, refreshes when the cron job rewrites it).
- The last N lines of a log/output file → `log_tail` (v2.7+).
- The state of one Hermes cron job (last run / next run / output) → `cron_status` (v2.7+).
**v2.7 file-reading widgets** (`markdown_file`, `log_tail`, `image`-with-`path`) read files relative to the project root. **By convention, write the underlying files inside `<project>/.scarf/`** (e.g. `.scarf/reports/weekly.md`, `.scarf/reports/run.log`) so the project-wide directory watch picks up changes and the widgets refresh automatically. Files outside `.scarf/` work too but only refresh when `dashboard.json` itself changes, so cron jobs writing outside `.scarf/` should `touch dashboard.json` after each run.
### 4. Configuration needs
@@ -145,12 +151,12 @@ Every row MUST have the same length as `columns`.
```json
{ "type": "list", "title": "Watched Sites",
"items": [
{ "text": "https://example.com", "status": "up" },
{ "text": "https://example.org", "status": "down" }
{ "text": "https://example.com", "status": "success" },
{ "text": "https://example.org", "status": "danger" }
]
}
```
`status` values: `"up"`, `"down"`, `"pending"`, `"ok"`, `"warn"`, `"error"` — render as coloured badges.
**Status values (typed in v2.7+):** prefer the canonical set — `"success"`, `"warning"`, `"danger"`, `"info"`, `"pending"`, `"done"`, `"neutral"`. Common synonyms also work and map to the canonical case (`"ok"`, `"up"`, `"passing"` → success; `"down"`, `"error"`, `"failed"` → danger; `"active"` → info; `"complete"`, `"finished"` → done; `"warn"`, `"degraded"` → warning). Unknown strings render as plain text rather than crashing — old dashboards using ad-hoc statuses keep working unchanged. **For new templates, prefer the canonical names** so the colors stay predictable across Scarf releases.
### `webview` — embedded live URL
```json
@@ -159,6 +165,65 @@ Every row MUST have the same length as `columns`.
```
**Important:** including any `webview` widget in a dashboard exposes a **Site** tab next to the Dashboard tab in the project view. Useful for templates that watch something renderable. The agent can update `url` on cron runs to keep the Site tab in sync with config (e.g., set it to `values.sites[0]`).
---
## Widget Catalog (v2.7+ — file-reading and richer widgets)
Five new widget types landed in v2.7. They all read from disk relative to the project root, and refresh automatically when any file under `<project>/.scarf/` changes — so a cron job that writes `<project>/.scarf/reports/uptime.md` will trigger the corresponding widget to re-render. **Convention: place the underlying files inside `.scarf/` (or a subdir of it) so the directory watch picks them up.** Files outside `.scarf/` work too but only refresh when `dashboard.json` itself changes.
### `markdown_file` — renders a markdown file from disk
```json
{ "type": "markdown_file", "title": "This Week", "path": ".scarf/reports/weekly.md" }
```
`path` is relative to the project root. Refuses absolute paths and `..` escape. Use this when the cron job writes a longer-form report; use `text` when the content is short and authored inline.
### `log_tail` — last N lines of a file, monospaced
```json
{ "type": "log_tail", "title": "Last cron run", "path": ".scarf/reports/run.log", "lines": 30 }
```
Default `lines` is 20, capped at 200. ANSI color codes are stripped automatically. Pair with cron jobs that write atomic log snapshots (write-temp + rename) — in-place appends won't refresh until `dashboard.json` is touched.
### `cron_status` — last/next run + state for one Hermes cron job
```json
{ "type": "cron_status", "title": "Uptime sweep", "jobId": "uptime-sweep", "lines": 5 }
```
`jobId` matches a `HermesCronJob.id` (visible in the Cron tab). Read-only — Run/Pause/Resume actions stay on the Cron tab; this widget only reports state. Great for dashboards that drive a single scheduled task.
### `image` — local file or remote URL
```json
{ "type": "image", "title": "Latency p95", "path": ".scarf/reports/latency.png", "height": 200 }
{ "type": "image", "title": "Build status", "url": "https://example.com/badge.svg" }
```
Either `path` (local, relative to project root) OR `url` (remote). `path` wins when both are set. Useful for chart PNGs the cron job generates with matplotlib / Plotly.
### `status_grid` — compact NxM grid of colored cells
```json
{ "type": "status_grid", "title": "Fleet", "gridColumns": 6, "cells": [
{ "label": "us-east-1", "status": "success", "tooltip": "200ms p50" },
{ "label": "us-west-2", "status": "warning", "tooltip": "elevated latency" },
{ "label": "eu-central-1", "status": "danger", "tooltip": "down" }
]}
```
Reuses the typed status enum from `list`. Auto-fits columns when `gridColumns` is omitted. Denser than a `list` when monitoring 12+ services at a glance.
### `stat` — sparkline (v2.7+ additive field)
```json
{ "type": "stat", "title": "Releases this month", "value": 4,
"color": "blue", "sparkline": [1, 2, 1, 3, 2, 4] }
```
Optional `sparkline: [Number]` renders a 1-line trend under the big number. Min 2 points, no max — tiny SVG path, cheap. Works on every existing `stat` widget without breaking older Scarf builds (they ignore the unknown field).
### Choosing a widget type — quick guide
- Counting things → `stat` (add `sparkline` if you have a recent trend).
- Progress toward a target → `progress`.
- Authored copy or short instructions → `text` (markdown).
- A report the cron job writes to disk → `markdown_file`.
- The most-recent run output of a cron job → `log_tail` or `cron_status`.
- A list of services / URLs / items with health → `list` (≤8 items) or `status_grid` (12+ items).
- Tabular data → `table` (or `chart` if it's numeric and you want trends).
- A live website or chart from a cron-generated PNG → `webview` (browsable) or `image` (static).
## Config Schema Design
If the project needs user-configurable values, design a schema. Put it in `<project>/.scarf/manifest.json` with this shape:
+2 -2
View File
@@ -139,8 +139,8 @@
"name": "Alan Wizemann",
"url": "https://github.com/awizemann"
},
"bundleSha256": "56ab97eeb45ab7b9e6715ce9c88ec2c953bf795698cd19628d300d5b8cffd475",
"bundleSize": 14610,
"bundleSha256": "f0f3e7960a07b66ffd84e521184750f826f2df521438c2a1a138b246da2731c5",
"bundleSize": 16487,
"category": "developer-tools",
"config": null,
"contents": {
+29 -1
View File
@@ -56,7 +56,24 @@ SUPPORTED_SCHEMA_VERSIONS = {SCHEMA_VERSION_V1, SCHEMA_VERSION_V2, SCHEMA_VERSIO
SLASH_COMMAND_NAME_RE = re.compile(r"^[a-z][a-z0-9-]*$")
MAX_BUNDLE_BYTES = 5 * 1024 * 1024 # 5 MB cap on submissions; installer is 50 MB
REQUIRED_BUNDLE_FILES = ("template.json", "README.md", "AGENTS.md", "dashboard.json")
SUPPORTED_WIDGET_TYPES = {"stat", "progress", "text", "table", "chart", "list", "webview"}
# Widget vocabulary — loaded from tools/widget-schema.json (single source of
# truth, also referenced by the agent-authoring SKILL.md). Each entry has
# `required` + `optional` field name lists. Adding a widget type means
# editing widget-schema.json + implementing the Swift view + the JS
# renderer; this file picks up the additions automatically.
def _load_widget_schema() -> dict:
schema_path = Path(__file__).resolve().parent / "widget-schema.json"
with schema_path.open("r", encoding="utf-8") as f:
schema = json.load(f)
if schema.get("schemaVersion") != 1:
raise SystemExit(f"unsupported widget-schema version: {schema.get('schemaVersion')}")
widgets = schema.get("widgets") or {}
if not isinstance(widgets, dict) or not widgets:
raise SystemExit("widget-schema.json: 'widgets' must be a non-empty object")
return widgets
WIDGET_SCHEMA = _load_widget_schema()
SUPPORTED_WIDGET_TYPES = set(WIDGET_SCHEMA.keys())
# Mirror of Swift's TemplateConfigField.FieldType. Order matters only
# for error messages that echo this set.
@@ -423,6 +440,17 @@ def _validate_dashboard(zf: zipfile.ZipFile, template_dir: Path, errors: list[Va
template_dir,
f"dashboard widget {widget.get('title')!r} has unknown type {widget_type!r}"
))
continue
spec = WIDGET_SCHEMA[widget_type]
for required_field in spec.get("required", []):
if required_field == "title":
continue # validated implicitly by the title in the error message
if widget.get(required_field) in (None, "", []):
errors.append(ValidationError(
template_dir,
f"dashboard widget {widget.get('title')!r} (type {widget_type!r}) "
f"missing required field {required_field!r}"
))
def _scan_for_secrets(zf: zipfile.ZipFile, template_dir: Path, errors: list[ValidationError]) -> None:
+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 = {
+78
View File
@@ -0,0 +1,78 @@
{
"schemaVersion": 1,
"comment": "Canonical project-dashboard widget vocabulary. Single source of truth for the catalog validator (tools/build-catalog.py) and the agent-authoring skill (templates/awizemann/template-author/staging/skills/scarf-template-author/SKILL.md). The Swift renderer (scarf/scarf/Features/Projects/Views/ProjectsView.swift WidgetView) and the JS renderer (site/widgets.js) are hand-written but MUST stay aligned with the type list here. Adding a new widget type: add it here first, then implement the Swift view + JS renderer, then update the SKILL.md Widget Catalog section. Removing or renaming a type breaks every dashboard.json that uses it — don't.",
"widgets": {
"stat": {
"description": "Single big-number metric with optional icon, subtitle, color, and inline sparkline trend.",
"since": "v2.2",
"required": ["title"],
"optional": ["value", "icon", "color", "subtitle", "sparkline"]
},
"progress": {
"description": "0.0..1.0 horizontal progress bar with optional label.",
"since": "v2.2",
"required": ["title", "value"],
"optional": ["label", "color"]
},
"text": {
"description": "Inline text or markdown block. Use 'markdown_file' if the content lives in an external file.",
"since": "v2.2",
"required": ["title", "content"],
"optional": ["format"]
},
"table": {
"description": "Columns x rows of strings.",
"since": "v2.2",
"required": ["title", "columns", "rows"],
"optional": []
},
"chart": {
"description": "Line / bar / area / pie chart over named series.",
"since": "v2.2",
"required": ["title", "series"],
"optional": ["chartType", "xLabel", "yLabel"]
},
"list": {
"description": "Bulleted list with optional typed status badges per item (success / warning / danger / info / pending / done / neutral; unknown values render as plain text).",
"since": "v2.2",
"required": ["title", "items"],
"optional": []
},
"webview": {
"description": "Embedded URL in an iframe / WKWebView. Including a webview also exposes a Site tab in the project view.",
"since": "v2.2",
"required": ["title", "url"],
"optional": ["height"]
},
"markdown_file": {
"description": "Renders a markdown file from disk, relative to the project root. Refreshes when any file under the project's .scarf/ directory changes.",
"since": "v2.7",
"required": ["title", "path"],
"optional": []
},
"log_tail": {
"description": "Tails the last N lines of a file from disk, monospaced. Useful for surfacing the most recent cron-job output. Strips ANSI color codes.",
"since": "v2.7",
"required": ["title", "path"],
"optional": ["lines"]
},
"cron_status": {
"description": "Last run / next run / current state for one Hermes cron job by id, with a tiny inline log tail.",
"since": "v2.7",
"required": ["title", "jobId"],
"optional": ["lines"]
},
"image": {
"description": "Local image file (path relative to project root) or remote URL.",
"since": "v2.7",
"required": ["title"],
"optional": ["path", "url", "height"]
},
"status_grid": {
"description": "Compact grid of colored cells, one per service / item, with hover labels. Reuses the typed status enum.",
"since": "v2.7",
"required": ["title", "cells"],
"optional": ["columns"]
}
}
}