Files
scarf/site/widgets.js
T
Alan Wizemann c7bcfd8655 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>
2026-05-04 21:16:29 +02:00

723 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Scarf dashboard widget renderer — the dogfood piece.
//
// Takes the SAME `dashboard.json` shape the Scarf macOS app renders
// (see scarf/scarf/Core/Models/ProjectDashboard.swift) and produces an
// HTML approximation for the catalog site. A template's detail page
// shows a live preview of exactly what the user's project dashboard
// will look like after install.
//
// 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.
(function (global) {
"use strict";
const SF_SYMBOL_FALLBACK = "●"; // SF Symbols aren't available on the web — use a dot.
// ---------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------
/**
* Render a ProjectDashboard JSON into `container`.
* @param {HTMLElement} container
* @param {object} dashboard
*/
function renderDashboard(container, dashboard) {
container.innerHTML = "";
if (!dashboard || !Array.isArray(dashboard.sections)) {
container.appendChild(elt("div", "dashboard-error", "Could not render dashboard."));
return;
}
const root = elt("div", "dashboard");
if (dashboard.title) {
const header = elt("div", "dashboard-header");
header.appendChild(elt("h1", "dashboard-title", dashboard.title));
if (dashboard.description) {
header.appendChild(elt("p", "dashboard-desc", dashboard.description));
}
root.appendChild(header);
}
for (const section of dashboard.sections) {
root.appendChild(renderSection(section));
}
container.appendChild(root);
}
function renderSection(section) {
const wrap = elt("section", "dashboard-section");
if (section.title) {
wrap.appendChild(elt("h2", "section-title", section.title));
}
const cols = Math.max(1, Math.min(6, section.columns || 3));
const grid = elt("div", "widget-grid");
grid.style.setProperty("--cols", String(cols));
// Webview widgets render in a dedicated tab in the Scarf app but
// we inline them here so the catalog preview is single-scroll.
for (const widget of section.widgets || []) {
grid.appendChild(renderWidget(widget));
}
wrap.appendChild(grid);
return wrap;
}
function renderWidget(widget) {
try {
switch (widget.type) {
case "stat": return renderStat(widget);
case "progress": return renderProgress(widget);
case "text": return renderText(widget);
case "table": return renderTable(widget);
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) {
console.error("widget render error", widget, e);
return renderUnknown({ ...widget, title: (widget.title || "") + " (render error)" });
}
}
// ---------------------------------------------------------------------
// Stat
// ---------------------------------------------------------------------
function renderStat(widget) {
const card = elt("div", "widget widget-stat");
card.dataset.color = widget.color || "blue";
const top = elt("div", "widget-stat-top");
top.appendChild(elt("span", "widget-stat-icon", SF_SYMBOL_FALLBACK));
top.appendChild(elt("span", "widget-title", widget.title || ""));
card.appendChild(top);
const value = elt("div", "widget-stat-value", displayValue(widget.value));
card.appendChild(value);
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") {
return Number.isInteger(v) ? v.toLocaleString() : v.toFixed(1);
}
return String(v);
}
// ---------------------------------------------------------------------
// Progress
// ---------------------------------------------------------------------
function renderProgress(widget) {
const card = elt("div", "widget widget-progress");
card.appendChild(elt("div", "widget-title", widget.title || ""));
if (widget.label) {
card.appendChild(elt("div", "widget-progress-label", widget.label));
}
const bar = elt("div", "progress-bar");
const fill = elt("div", "progress-fill");
const pct = Math.max(0, Math.min(1, Number(widget.value) || 0));
fill.style.width = (pct * 100).toFixed(1) + "%";
bar.appendChild(fill);
card.appendChild(bar);
return card;
}
// ---------------------------------------------------------------------
// Text (markdown)
// ---------------------------------------------------------------------
function renderText(widget) {
const card = elt("div", "widget widget-text");
card.appendChild(elt("div", "widget-title", widget.title || ""));
const body = elt("div", "widget-text-body");
if ((widget.format || "").toLowerCase() === "markdown") {
body.innerHTML = renderMarkdown(widget.content || "");
} else {
body.textContent = widget.content || "";
}
card.appendChild(body);
return card;
}
/** Minimal markdown subset: headings, bold, italic, inline code, code
* blocks, bullet/numbered lists, links, paragraphs. Deliberately tiny
* — the catalog showcases dashboards, not blog posts. */
function renderMarkdown(src) {
const lines = src.split(/\r?\n/);
let html = "";
let inCode = false;
let inList = null; // "ul" | "ol" | null
const flushList = () => {
if (inList) {
html += `</${inList}>`;
inList = null;
}
};
for (const rawLine of lines) {
const line = rawLine;
if (line.trim().startsWith("```")) {
flushList();
if (inCode) {
html += "</code></pre>";
inCode = false;
} else {
html += "<pre><code>";
inCode = true;
}
continue;
}
if (inCode) {
html += escapeHTML(line) + "\n";
continue;
}
if (/^#{1,6}\s/.test(line)) {
flushList();
const level = Math.min(6, (line.match(/^#+/) || ["#"])[0].length);
const text = line.replace(/^#+\s*/, "");
html += `<h${level}>${renderInline(text)}</h${level}>`;
continue;
}
const bulletMatch = line.match(/^\s*[-*]\s+(.*)$/);
const orderedMatch = line.match(/^\s*\d+\.\s+(.*)$/);
if (bulletMatch) {
if (inList !== "ul") { flushList(); html += "<ul>"; inList = "ul"; }
html += `<li>${renderInline(bulletMatch[1])}</li>`;
continue;
}
if (orderedMatch) {
if (inList !== "ol") { flushList(); html += "<ol>"; inList = "ol"; }
html += `<li>${renderInline(orderedMatch[1])}</li>`;
continue;
}
if (line.trim() === "") {
flushList();
continue;
}
flushList();
html += `<p>${renderInline(line)}</p>`;
}
flushList();
if (inCode) html += "</code></pre>";
return html;
}
function renderInline(text) {
// Escape first, then re-apply formatting on the escaped text.
let s = escapeHTML(text);
// Inline code before bold/italic so the markers inside `…` stay literal.
s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
s = s.replace(/(^|[^\w])\*([^*]+)\*/g, "$1<em>$2</em>");
s = s.replace(/(^|[^\w])_([^_]+)_/g, "$1<em>$2</em>");
// Links: [text](url)
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text, url) => {
return `<a href="${url}">${text}</a>`;
});
return s;
}
// ---------------------------------------------------------------------
// Table
// ---------------------------------------------------------------------
function renderTable(widget) {
const card = elt("div", "widget widget-table");
card.appendChild(elt("div", "widget-title", widget.title || ""));
const table = elt("table", "data-table");
if (Array.isArray(widget.columns)) {
const thead = elt("thead");
const tr = elt("tr");
for (const col of widget.columns) {
tr.appendChild(elt("th", null, col));
}
thead.appendChild(tr);
table.appendChild(thead);
}
if (Array.isArray(widget.rows)) {
const tbody = elt("tbody");
for (const row of widget.rows) {
const tr = elt("tr");
for (const cell of row) {
tr.appendChild(elt("td", null, cell));
}
tbody.appendChild(tr);
}
table.appendChild(tbody);
}
card.appendChild(table);
return card;
}
// ---------------------------------------------------------------------
// 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 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);
}
card.appendChild(ul);
return card;
}
// ---------------------------------------------------------------------
// Chart (SVG — no Chart.js dep)
// ---------------------------------------------------------------------
function renderChart(widget) {
const card = elt("div", "widget widget-chart");
card.appendChild(elt("div", "widget-title", widget.title || ""));
const series = widget.series || [];
if (series.length === 0) {
card.appendChild(elt("div", "widget-chart-empty", "No chart data."));
return card;
}
// Collect x-labels (assume aligned across series).
const xs = series[0].data.map((p) => p.x);
const ys = series.flatMap((s) => s.data.map((p) => p.y));
const maxY = Math.max(0, ...ys);
const minY = Math.min(0, ...ys);
const W = 320;
const H = 120;
const padL = 24, padR = 8, padT = 8, padB = 22;
const plotW = W - padL - padR;
const plotH = H - padT - padB;
const svgNS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(svgNS, "svg");
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
svg.classList.add("widget-chart-svg");
const yToPixel = (y) => {
if (maxY === minY) return padT + plotH / 2;
return padT + plotH - ((y - minY) / (maxY - minY)) * plotH;
};
const xToPixel = (i) => padL + (plotW * (i / Math.max(1, xs.length - 1)));
// Axis baseline
const axis = document.createElementNS(svgNS, "line");
axis.setAttribute("x1", String(padL));
axis.setAttribute("y1", String(padT + plotH));
axis.setAttribute("x2", String(W - padR));
axis.setAttribute("y2", String(padT + plotH));
axis.setAttribute("class", "chart-axis");
svg.appendChild(axis);
const kind = (widget.chartType || "line").toLowerCase();
series.forEach((s, idx) => {
const color = s.color || ["accent", "red", "blue", "orange"][idx % 4];
if (kind === "bar") {
const barW = Math.max(2, plotW / (xs.length * series.length) - 2);
s.data.forEach((p, i) => {
const rect = document.createElementNS(svgNS, "rect");
const x = xToPixel(i) - barW / 2 + idx * barW;
const y = yToPixel(p.y);
rect.setAttribute("x", String(x));
rect.setAttribute("y", String(y));
rect.setAttribute("width", String(barW));
rect.setAttribute("height", String(padT + plotH - y));
rect.setAttribute("class", "chart-bar");
rect.dataset.color = color;
svg.appendChild(rect);
});
} else {
const d = s.data.map((p, i) => {
const x = xToPixel(i);
const y = yToPixel(p.y);
return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`;
}).join(" ");
const path = document.createElementNS(svgNS, "path");
path.setAttribute("d", d);
path.setAttribute("class", "chart-line");
path.dataset.color = color;
svg.appendChild(path);
}
});
card.appendChild(svg);
return card;
}
// ---------------------------------------------------------------------
// Webview
// ---------------------------------------------------------------------
function renderWebview(widget) {
const card = elt("div", "widget widget-webview");
card.appendChild(elt("div", "widget-title", widget.title || ""));
const frame = document.createElement("iframe");
frame.src = widget.url || "about:blank";
frame.setAttribute("sandbox", "allow-scripts allow-popups allow-forms");
frame.style.width = "100%";
frame.style.height = (widget.height ? Number(widget.height) : 300) + "px";
frame.loading = "lazy";
card.appendChild(frame);
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) {
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;
}
// ---------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------
function elt(tag, cls, text) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined && text !== null) e.textContent = String(text);
return e;
}
function escapeHTML(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// ---------------------------------------------------------------------
// Config-schema display (v2.3 — template configuration).
// ---------------------------------------------------------------------
//
// Renders the author-declared schema as a read-only listing on the
// catalog detail page. The site itself never collects values — the
// form UI lives inside the Scarf app. This is purely informational
// so visitors know what they'll need to fill in before installing.
/**
* Render a manifest.config block into `container` as a summary.
* Safe to call with a null schema (no-op).
* @param {HTMLElement} container
* @param {{schema: Array, modelRecommendation?: object} | null | undefined} config
*/
function renderConfigSchema(container, config) {
container.innerHTML = "";
if (!config || !Array.isArray(config.schema) || config.schema.length === 0) {
return;
}
const wrap = elt("div", "config-schema");
const header = elt("h3", "config-schema-header", "Configuration");
wrap.appendChild(header);
const desc = elt("p", "config-schema-desc",
"Fields you'll fill in during install. Secrets are stored in the macOS Keychain; non-secret values live at <project>/.scarf/config.json.");
wrap.appendChild(desc);
const list = elt("dl", "config-schema-list");
for (const field of config.schema) {
const dt = elt("dt", "config-field-header");
dt.appendChild(elt("span", "config-field-key", field.key || ""));
dt.appendChild(elt("span", "config-field-type", field.type || ""));
if (field.required) {
const req = elt("span", "config-field-required", "required");
dt.appendChild(req);
}
list.appendChild(dt);
const dd = elt("dd", "config-field-body");
if (field.label) {
dd.appendChild(elt("div", "config-field-label", field.label));
}
if (field.description) {
const descEl = elt("div", "config-field-description");
descEl.innerHTML = renderInline(field.description);
dd.appendChild(descEl);
}
const constraint = summariseConstraint(field);
if (constraint) {
dd.appendChild(elt("div", "config-field-constraint", constraint));
}
list.appendChild(dd);
}
wrap.appendChild(list);
if (config.modelRecommendation) {
const rec = config.modelRecommendation;
const recBlock = elt("div", "config-model-rec");
recBlock.appendChild(elt("div", "config-model-label", "Recommended model"));
recBlock.appendChild(elt("div", "config-model-preferred", rec.preferred || ""));
if (rec.rationale) {
recBlock.appendChild(elt("div", "config-model-rationale", rec.rationale));
}
if (Array.isArray(rec.alternatives) && rec.alternatives.length > 0) {
recBlock.appendChild(elt("div", "config-model-alternatives",
"Also works: " + rec.alternatives.join(", ")));
}
wrap.appendChild(recBlock);
}
container.appendChild(wrap);
}
/** One-line human summary of a field's type-specific constraints.
* Empty string if nothing noteworthy to say. */
function summariseConstraint(field) {
const type = field.type;
if (type === "enum") {
const opts = Array.isArray(field.options) ? field.options : [];
const values = opts.map(o => o && o.label ? o.label : (o && o.value) || "").filter(Boolean);
if (values.length > 0) return "Choices: " + values.join(", ");
} else if (type === "list") {
const min = field.minItems, max = field.maxItems;
if (min && max) return `${min}${max} items`;
if (min) return `At least ${min} item${min === 1 ? "" : "s"}`;
if (max) return `At most ${max} item${max === 1 ? "" : "s"}`;
} else if (type === "string" || type === "text") {
if (field.pattern) return `Pattern: ${field.pattern}`;
const min = field.minLength, max = field.maxLength;
if (min && max) return `${min}${max} characters`;
if (min) return `At least ${min} characters`;
if (max) return `At most ${max} characters`;
} else if (type === "number") {
const min = field.min, max = field.max;
if (min !== undefined && max !== undefined) return `${min}${max}`;
if (min !== undefined) return `${min}`;
if (max !== undefined) return `${max}`;
} else if (type === "secret") {
return "Stored in the macOS Keychain on install — never in git, never in config.json.";
}
return "";
}
// ---------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------
global.ScarfWidgets = {
renderDashboard,
renderMarkdown, // used by the detail page's README block
renderConfigSchema, // used by the detail page's Configuration block
};
})(typeof window !== "undefined" ? window : this);