diff --git a/.changeset/add-fingerprint-viewer.md b/.changeset/add-fingerprint-viewer.md new file mode 100644 index 0000000..049b471 --- /dev/null +++ b/.changeset/add-fingerprint-viewer.md @@ -0,0 +1,5 @@ +--- +"ghost-fingerprint": minor +--- + +Add `ghost-fingerprint emit viewer` to generate a portable HTML explorer for a fingerprint package. diff --git a/apps/docs/src/content/docs/cli-reference.mdx b/apps/docs/src/content/docs/cli-reference.mdx index e0b8af2..659bc4f 100644 --- a/apps/docs/src/content/docs/cli-reference.mdx +++ b/apps/docs/src/content/docs/cli-reference.mdx @@ -217,13 +217,14 @@ ghost-fingerprint diff a/fingerprint.md b/fingerprint.md ghost-fingerprint diff a.md b.md --format json ``` -### Emit — derive outputs from `fingerprint.md` +### Emit — derive outputs from `profile.md` -Derive an output from `fingerprint.md`. Kinds: `review-command` (a +Derive an output from the fingerprint package profile. Kinds: `review-command` (a per-project slash command at `.claude/commands/design-review.md`), -`context-bundle` (SKILL.md + fingerprint.md + prompt.md + tokens.css for any -generator), or `skill` (the `ghost-fingerprint` agentskills.io bundle — -install this into your host agent for the `profile` recipe). +`context-bundle` (SKILL.md + profile.md + prompt.md + tokens.css for any +generator), `viewer` (a portable single-file HTML explorer), or `skill` (the +`ghost-fingerprint` agentskills.io bundle — install this into your host agent +for the `profile` recipe). @@ -237,6 +238,9 @@ ghost-fingerprint emit review-command # Emit a context bundle any generator can consume ghost-fingerprint emit context-bundle +# Emit a portable HTML viewer for humans +ghost-fingerprint emit viewer + # Single prompt.md for plain-text LLM context ghost-fingerprint emit context-bundle --prompt-only diff --git a/apps/docs/src/content/docs/getting-started.mdx b/apps/docs/src/content/docs/getting-started.mdx index 9548d6b..f38f375 100644 --- a/apps/docs/src/content/docs/getting-started.mdx +++ b/apps/docs/src/content/docs/getting-started.mdx @@ -153,8 +153,15 @@ ghost-fingerprint lint map.md # ghost.map/v2 ghost-fingerprint lint survey.json # ghost.survey/v2 ghost-fingerprint verify-profile fingerprint.md survey.json --root . # fingerprint-to-survey fidelity + +# Optional human-readable explorer +ghost-fingerprint emit viewer # ./fingerprint-viewer.html ``` +`emit viewer` is a derived artifact, not a second source of truth. It renders +the profile plus sibling `survey.json`, `map.md`, and `checks.yml` when they +exist so humans can visually inspect the design-language package. + diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index 714518f..f36bcf6 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-05-07T00:33:39.453Z", + "generatedAt": "2026-05-07T11:10:39.905Z", "tools": [ { "tool": "ghost-drift", @@ -426,7 +426,7 @@ "tool": "ghost-fingerprint", "name": "emit", "rawName": "emit ", - "description": "Emit a derived artifact from the fingerprint package profile (kinds: review-command, context-bundle, skill)", + "description": "Emit a derived artifact from the fingerprint package profile (kinds: review-command, context-bundle, viewer, skill)", "options": [ { "rawName": "-p, --profile ", @@ -439,7 +439,7 @@ { "rawName": "-o, --out ", "name": "out", - "description": "Output path (review-command → .claude/commands/design-review.md; context-bundle → ghost-context/; skill → .claude/skills/ghost-fingerprint/)", + "description": "Output path (review-command → .claude/commands/design-review.md; context-bundle → ghost-context/; skill → .claude/skills/ghost-fingerprint/; viewer → fingerprint-viewer.html)", "default": null, "takesValue": true, "negated": false @@ -447,11 +447,19 @@ { "rawName": "--stdout", "name": "stdout", - "description": "Write to stdout instead of a file (review-command only)", + "description": "Write to stdout instead of a file (review-command, viewer)", "default": null, "takesValue": false, "negated": false }, + { + "rawName": "--budget ", + "name": "budget", + "description": "Survey summary depth for viewer: compact, standard, or full (default: standard)", + "default": null, + "takesValue": true, + "negated": false + }, { "rawName": "--no-tokens", "name": "tokens", diff --git a/packages/ghost-fingerprint/src/core/context/index.ts b/packages/ghost-fingerprint/src/core/context/index.ts index 2001255..82ca0bc 100644 --- a/packages/ghost-fingerprint/src/core/context/index.ts +++ b/packages/ghost-fingerprint/src/core/context/index.ts @@ -1,6 +1,13 @@ export type { EmitReviewInput } from "./review-command.js"; export { emitReviewCommand } from "./review-command.js"; export { buildTokensCss } from "./tokens-css.js"; +export type { + BuildFingerprintViewerHtmlInput, + ViewerArtifactName, + ViewerArtifactState, + ViewerArtifactStatus, +} from "./viewer.js"; +export { buildFingerprintViewerHtml } from "./viewer.js"; export type { ContextFormat, WriteContextOptions, diff --git a/packages/ghost-fingerprint/src/core/context/viewer.ts b/packages/ghost-fingerprint/src/core/context/viewer.ts new file mode 100644 index 0000000..d813e09 --- /dev/null +++ b/packages/ghost-fingerprint/src/core/context/viewer.ts @@ -0,0 +1,864 @@ +import type { + Fingerprint, + GhostChecksDocument, + MapFrontmatter, + SurveySummary, + SurveySummaryBudget, +} from "@ghost/core"; + +export type ViewerArtifactName = + | "profile.md" + | "survey.json" + | "map.md" + | "checks.yml"; +export type ViewerArtifactState = "included" | "missing" | "invalid"; + +export interface ViewerArtifactStatus { + name: ViewerArtifactName; + state: ViewerArtifactState; + path?: string; + message?: string; +} + +export interface BuildFingerprintViewerHtmlInput { + fingerprint: Fingerprint; + sourcePath?: string; + generatedAt?: string; + surveySummary?: SurveySummary; + surveyBudget?: SurveySummaryBudget; + map?: MapFrontmatter; + checks?: GhostChecksDocument; + artifacts?: ViewerArtifactStatus[]; + warnings?: string[]; +} + +export function buildFingerprintViewerHtml( + input: BuildFingerprintViewerHtmlInput, +): string { + const generatedAt = input.generatedAt ?? new Date().toISOString(); + const artifacts = input.artifacts ?? [ + { name: "profile.md", state: "included", path: input.sourcePath }, + ]; + const title = `${input.fingerprint.id} fingerprint viewer`; + + return ` + + + + + ${escapeHtml(title)} + + + +
+ ${renderHeader(input, generatedAt, artifacts)} + ${renderTabs()} +
+ ${renderOverview(input.fingerprint)} + ${renderValues(input.fingerprint)} + ${renderDecisions(input.fingerprint, input.checks)} + ${renderEvidence(input.surveySummary, input.surveyBudget)} + ${renderTopology(input.map)} +
+
+ + + +`; +} + +function renderHeader( + input: BuildFingerprintViewerHtmlInput, + generatedAt: string, + artifacts: ViewerArtifactStatus[], +): string { + const fp = input.fingerprint; + const warnings = input.warnings ?? []; + return `
+
+

Ghost fingerprint viewer

+

${escapeHtml(fp.id)}

+
+ ${escapeHtml(fp.source)} + ${escapeHtml(fp.timestamp)} + Generated ${escapeHtml(generatedAt)} +
+
+
+ + +
${artifacts.map(renderArtifactStatus).join("")}
+ ${ + warnings.length + ? `
${warnings.map((w) => `

${escapeHtml(w)}

`).join("")}
` + : "" + } +
+
`; +} + +function renderArtifactStatus(status: ViewerArtifactStatus): string { + return `
+ ${escapeHtml(status.name)} + ${escapeHtml(status.state)} + ${status.path ? `${escapeHtml(status.path)}` : ""} + ${status.message ? `${escapeHtml(status.message)}` : ""} +
`; +} + +function renderTabs(): string { + const tabs = [ + ["overview", "Overview"], + ["values", "Values"], + ["decisions", "Decisions"], + ["evidence", "Evidence"], + ["topology", "Topology"], + ]; + return ``; +} + +function renderOverview(fingerprint: Fingerprint): string { + const observation = fingerprint.observation; + return `
+ ${sectionHead("overview", "Overview")} +
+
+

Character

+

${escapeHtml(observation?.summary || "No character summary has been authored yet.")}

+ ${renderTags("Personality", observation?.personality)} + ${renderTags("Resembles", observation?.resembles)} +
+
+

Signature

+

${escapeHtml(fingerprint.signature || "No signature prose has been authored yet.")}

+
+
+
+

References

+
+ ${renderReferenceGroup("Specs", fingerprint.references?.specs)} + ${renderReferenceGroup("Components", fingerprint.references?.components)} + ${renderReferenceGroup("Examples", fingerprint.references?.examples)} +
+
+
`; +} + +function renderValues(fingerprint: Fingerprint): string { + return ``; +} + +function renderDecisions( + fingerprint: Fingerprint, + checks: GhostChecksDocument | undefined, +): string { + const decisions = fingerprint.decisions ?? []; + return ``; +} + +function renderEvidence( + summary: SurveySummary | undefined, + budget: SurveySummaryBudget | undefined, +): string { + if (!summary) { + return ``; + } + return ``; +} + +function renderTopology(map: MapFrontmatter | undefined): string { + if (!map) { + return ``; + } + return ``; +} + +function renderTags(label: string, values: string[] | undefined): string { + if (!values?.length) return ""; + return `
${escapeHtml(label)}${values + .map((v) => `${escapeHtml(v)}`) + .join("")}
`; +} + +function renderReferenceGroup( + label: string, + values: string[] | undefined, +): string { + return `
+

${escapeHtml(label)}

+ ${ + values?.length + ? `
    ${values.map((v) => `
  • ${escapeHtml(v)}
  • `).join("")}
` + : `

None promoted yet.

` + } +
`; +} + +function renderSwatch(role: string, value: string, kind: string): string { + const color = safeCssColor(value); + const style = color ? ` style="--swatch:${color}"` : ""; + return `
+ + ${escapeHtml(role)} + ${escapeHtml(value)} + ${escapeHtml(kind)} +
`; +} + +function renderMetricBlock( + title: string, + values: Array, + notes: Array, +): string { + return `
+

${escapeHtml(title)}

+ ${ + values.length + ? `
${values.map((v) => `${escapeHtml(v)}`).join("")}
` + : `

None recorded.

` + } + ${notes + .filter(Boolean) + .map((note) => `

${escapeHtml(note)}

`) + .join("")} +
`; +} + +function renderDecision( + decision: NonNullable[number], +): string { + return `
+
+

${escapeHtml(decision.dimension)}

+ ${decision.dimension_kind ? `${escapeHtml(decision.dimension_kind)}` : ""} +
+

${escapeHtml(decision.decision || "No rationale prose has been authored yet.")}

+ ${renderList("Evidence", decision.evidence)} +
`; +} + +function renderCheck(check: GhostChecksDocument["checks"][number]): string { + const detector = [ + check.detector.type, + check.detector.pattern, + check.detector.value, + check.detector.contexts?.join(", "), + ].filter(Boolean); + return `
+
+

${escapeHtml(check.title)}

+ ${escapeHtml(check.severity)} / ${escapeHtml(check.status)} +
+

${escapeHtml(check.id)}

+

${escapeHtml(detector.join(" - "))}

+ ${ + check.evidence + ? `

Support: ${escapeHtml(check.evidence.support ?? "n/a")}. Observed: ${escapeHtml( + check.evidence.observed_count ?? "n/a", + )}.

` + : "" + } + ${check.repair ? `

${escapeHtml(check.repair)}

` : ""} +
`; +} + +function renderValueKind( + kind: SurveySummary["values"]["kinds"][number], +): string { + return `
+

${escapeHtml(kind.kind)} ${kind.rows} rows, ${kind.occurrences} uses

+ ${table( + ["Value", "Raw", "Occurrences", "Files", "Role"], + kind.top.map((row) => [ + code(row.value), + code(row.raw), + String(row.occurrences), + String(row.files_count), + row.role_hypothesis ?? "", + ]), + )} + ${kind.omitted > 0 ? `

${kind.omitted} more row(s) omitted by the summary budget.

` : ""} +
`; +} + +function renderTokenTable(summary: SurveySummary): string { + const rows = [...summary.tokens.top, ...summary.tokens.semantic_or_themed]; + if (!rows.length) return `

No token rows recorded.

`; + return table( + ["Token", "Resolved", "Uses", "Depth"], + rows.map((row) => [ + code(row.name), + code(row.resolved_value), + String(row.occurrences), + String(row.alias_depth), + ]), + ); +} + +function renderComponentTable(summary: SurveySummary): string { + const rows = summary.components.top; + if (!rows.length) return `

No component rows recorded.

`; + return table( + ["Name", "Discovered via", "Variants", "Sizes"], + rows.map((row) => [ + row.name, + row.discovered_via, + row.variants?.join(", ") ?? "", + row.sizes?.join(", ") ?? "", + ]), + ); +} + +function renderSurfaceTable(summary: SurveySummary): string { + const rows = summary.ui_surfaces.surfaces; + if (!rows.length) return `

No UI surface rows recorded.

`; + return table( + ["Name", "Kind", "Locator", "Renderability", "Shape"], + rows.map((row) => [ + row.name, + row.kind, + code(row.locator), + row.renderability, + row.classification?.layout_shape ?? "", + ]), + ); +} + +function renderCountCard(label: string, value: number): string { + return `
+ ${escapeHtml(label)} + ${escapeHtml(value)} +
`; +} + +function renderList(label: string, values: string[] | undefined): string { + if (!values?.length) return ""; + return `
+

${escapeHtml(label)}

+
    ${values.map((v) => `
  • ${escapeHtml(v)}
  • `).join("")}
+
`; +} + +function sectionHead(id: string, title: string): string { + return `
+

${escapeHtml(title)}

+ +
`; +} + +type TableCell = string | { html: string; text: string }; + +function table(headers: string[], rows: TableCell[][]): string { + return `
+ ${headers.map((h) => ``).join("")} + ${rows + .map( + (row) => + `${row.map((cell) => ``).join("")}`, + ) + .join("")} +
${escapeHtml(h)}
${cellHtml(cell)}
`; +} + +function code(value: string): TableCell { + return { html: `${escapeHtml(value)}`, text: value }; +} + +function cellHtml(cell: TableCell): string { + return typeof cell === "string" ? escapeHtml(cell) : cell.html; +} + +function cellText(cell: TableCell): string { + return typeof cell === "string" ? cell : cell.text; +} + +function toList(value: string | string[]): string[] { + return Array.isArray(value) ? value : [value]; +} + +function searchText(value: unknown): string { + const flat = flatten(value) + .map((item) => String(item)) + .join(" ") + .toLowerCase(); + return escapeHtml(flat); +} + +function flatten(value: unknown): unknown[] { + if (value === undefined || value === null) return []; + if (Array.isArray(value)) return value.flatMap(flatten); + return [value]; +} + +function escapeHtml(value: unknown): string { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function safeCssColor(value: string): string | null { + const trimmed = value.trim(); + if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed; + if (/^rgba?\([\d\s.,%/+-]+\)$/.test(trimmed)) return trimmed; + if (/^hsla?\([\d\s.,%/a-zA-Z+-]+\)$/.test(trimmed)) return trimmed; + if (/^oklch\([\d\s.,%/a-zA-Z+-]+\)$/.test(trimmed)) return trimmed; + return null; +} + +function buildCss(): string { + return ` +:root { + color-scheme: light; + --bg: #f7f7f4; + --panel: #ffffff; + --ink: #171717; + --muted: #62645f; + --line: #d9d9d2; + --soft: #eeeeea; + --accent: #0f766e; + --accent-soft: #d9f3ef; + --warn: #8a4b0f; + --warn-soft: #fff3d7; + --bad: #a32626; + --bad-soft: #ffe2e2; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +* { box-sizing: border-box; } +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-size: 14px; + line-height: 1.5; +} +.shell { + width: min(1180px, calc(100vw - 32px)); + margin: 0 auto; + padding: 28px 0 56px; +} +.hero { + display: grid; + grid-template-columns: minmax(0, 1fr) 420px; + gap: 24px; + align-items: start; + border-bottom: 1px solid var(--line); + padding-bottom: 24px; +} +.eyebrow { + margin: 0 0 10px; + color: var(--accent); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} +h1 { + margin: 0; + font-size: 44px; + line-height: 1; + letter-spacing: 0; +} +h2, h3, h4, p { margin-top: 0; } +.meta, .tag-group, .pill-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.meta { margin-top: 14px; color: var(--muted); } +.meta span, .pill-row span, .tag-group b { + border: 1px solid var(--line); + background: var(--panel); + border-radius: 999px; + padding: 4px 9px; +} +.hero-panel, .block, .item, .count-card, .reference { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; +} +.hero-panel { padding: 16px; } +label { + display: block; + margin-bottom: 8px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} +input { + width: 100%; + height: 40px; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0 12px; + font: inherit; +} +.status-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 12px; +} +.artifact { + border-radius: 6px; + padding: 9px; + background: var(--soft); +} +.artifact span, .artifact small { display: block; color: var(--muted); } +.artifact strong { display: block; font-size: 13px; } +.artifact.included { background: var(--accent-soft); } +.artifact.invalid { background: var(--bad-soft); color: var(--bad); } +.artifact.missing { background: var(--warn-soft); color: var(--warn); } +.warnings { + margin-top: 12px; + color: var(--warn); +} +.warnings p { margin-bottom: 4px; } +.tabs { + position: sticky; + top: 0; + z-index: 5; + display: flex; + gap: 6px; + padding: 12px 0; + background: var(--bg); +} +.tab, .copy-anchor { + border: 1px solid var(--line); + background: var(--panel); + color: var(--ink); + border-radius: 6px; + padding: 8px 11px; + font: inherit; + cursor: pointer; +} +.tab.active { + background: var(--ink); + border-color: var(--ink); + color: white; +} +.panel { padding-top: 16px; } +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} +.section-head h2 { margin: 0; font-size: 24px; } +.two-col, .metric-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} +.metric-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.block, .item, .reference, .count-card { padding: 16px; } +.block { margin-bottom: 14px; } +.block h3, .item h3, .reference h4 { margin-bottom: 8px; } +.reference-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} +ul { margin: 8px 0 0; padding-left: 18px; } +code { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 12px; + background: var(--soft); + border-radius: 4px; + padding: 2px 4px; +} +.tag-group { align-items: center; margin-top: 12px; } +.tag-group span, .fine, .empty { color: var(--muted); } +.swatch-grid, .neutral-ramp { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 10px; +} +.neutral-ramp { margin-top: 10px; } +.swatch { + display: grid; + grid-template-columns: 36px minmax(0, 1fr); + grid-template-areas: "chip role" "chip value" "chip kind"; + gap: 0 10px; + align-items: center; + border: 1px solid var(--line); + border-radius: 8px; + padding: 10px; +} +.chip { + grid-area: chip; + width: 36px; + height: 36px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--swatch); +} +.chip.unresolved { + background: repeating-linear-gradient(45deg, #ddd, #ddd 4px, #fff 4px, #fff 8px); +} +.swatch strong { grid-area: role; } +.swatch code { grid-area: value; width: fit-content; } +.swatch small { grid-area: kind; color: var(--muted); } +.item { margin-bottom: 10px; } +.item-head { + display: flex; + justify-content: space-between; + gap: 12px; +} +.item-head span { + align-self: start; + border-radius: 999px; + background: var(--soft); + padding: 3px 8px; + color: var(--muted); + font-size: 12px; +} +.count-card span { display: block; color: var(--muted); } +.count-card strong { display: block; font-size: 30px; } +.subsection { margin-bottom: 18px; } +.subsection h4 span { color: var(--muted); font-weight: 400; } +.table-wrap { + width: 100%; + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 8px; +} +table { + width: 100%; + border-collapse: collapse; + min-width: 620px; +} +th, td { + padding: 9px 10px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top; +} +th { + background: var(--soft); + font-size: 12px; + color: var(--muted); +} +tr:last-child td { border-bottom: 0; } +.is-hidden-by-search { display: none !important; } +@media (max-width: 860px) { + .hero, .two-col, .metric-grid, .reference-grid { + grid-template-columns: 1fr; + } + h1 { font-size: 34px; } + .tabs { overflow-x: auto; } +} +`; +} + +function buildJs(): string { + return ` +const tabs = Array.from(document.querySelectorAll("[data-tab]")); +const panels = Array.from(document.querySelectorAll("[data-panel]")); +function showPanel(id) { + tabs.forEach((tab) => tab.classList.toggle("active", tab.dataset.tab === id)); + panels.forEach((panel) => { + const active = panel.dataset.panel === id; + panel.hidden = !active; + panel.classList.toggle("active", active); + }); + if (location.hash !== "#" + id) history.replaceState(null, "", "#" + id); +} +tabs.forEach((tab) => tab.addEventListener("click", () => showPanel(tab.dataset.tab))); +const initial = location.hash ? location.hash.slice(1) : "overview"; +if (tabs.some((tab) => tab.dataset.tab === initial)) showPanel(initial); + +const search = document.getElementById("global-search"); +search?.addEventListener("input", () => { + const query = search.value.trim().toLowerCase(); + document.querySelectorAll(".searchable").forEach((node) => { + const text = node.getAttribute("data-search") || node.textContent.toLowerCase(); + node.classList.toggle("is-hidden-by-search", Boolean(query) && !text.includes(query)); + }); +}); + +document.querySelectorAll("[data-copy-anchor]").forEach((button) => { + button.addEventListener("click", async () => { + const anchor = button.getAttribute("data-copy-anchor"); + const url = location.href.split("#")[0] + anchor; + try { + await navigator.clipboard.writeText(url); + button.textContent = "Copied"; + setTimeout(() => { button.textContent = "Copy anchor"; }, 1200); + } catch { + location.hash = anchor; + } + }); +}); +`; +} diff --git a/packages/ghost-fingerprint/src/core/index.ts b/packages/ghost-fingerprint/src/core/index.ts index a7c8644..429afa4 100644 --- a/packages/ghost-fingerprint/src/core/index.ts +++ b/packages/ghost-fingerprint/src/core/index.ts @@ -29,12 +29,17 @@ export { } from "./constants.js"; // --- Context (review-command + context-bundle) --- export type { + BuildFingerprintViewerHtmlInput, ContextFormat, EmitReviewInput, + ViewerArtifactName, + ViewerArtifactState, + ViewerArtifactStatus, WriteContextOptions, WriteContextResult, } from "./context/index.js"; export { + buildFingerprintViewerHtml, buildSkillMd, buildTokensCss, emitReviewCommand, diff --git a/packages/ghost-fingerprint/src/emit-command.ts b/packages/ghost-fingerprint/src/emit-command.ts index 6295005..e6e8184 100644 --- a/packages/ghost-fingerprint/src/emit-command.ts +++ b/packages/ghost-fingerprint/src/emit-command.ts @@ -1,12 +1,28 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { loadSkillBundle } from "@ghost/core"; +import { + GHOST_CHECKS_FILENAME, + type GhostChecksDocument, + GhostChecksSchema, + loadSkillBundle, + MAP_FILENAME, + type MapFrontmatter, + MapFrontmatterSchema, + SURVEY_FILENAME, + SurveySchema, + type SurveySummary, + type SurveySummaryBudget, + summarizeSurvey, +} from "@ghost/core"; import type { CAC } from "cac"; +import { parse as parseYaml } from "yaml"; import { + buildFingerprintViewerHtml, emitReviewCommand, loadFingerprint, resolveFingerprintPackage, + type ViewerArtifactStatus, writeContextBundle, } from "./core/index.js"; @@ -23,10 +39,12 @@ const SKILL_BUNDLE_ROOT = fileURLToPath( const DEFAULT_REVIEW_OUT = ".claude/commands/design-review.md"; const DEFAULT_CONTEXT_OUT = "ghost-context"; const DEFAULT_SKILL_OUT = ".claude/skills/ghost-fingerprint"; +const DEFAULT_VIEWER_OUT = "fingerprint-viewer.html"; export const SUPPORTED_KINDS = [ "review-command", "context-bundle", + "viewer", "skill", ] as const; export type EmitKind = (typeof SUPPORTED_KINDS)[number]; @@ -61,11 +79,15 @@ export function registerEmitCommand(cli: CAC): void { ) .option( "-o, --out ", - `Output path (review-command → ${DEFAULT_REVIEW_OUT}; context-bundle → ${DEFAULT_CONTEXT_OUT}/; skill → ${DEFAULT_SKILL_OUT}/)`, + `Output path (review-command → ${DEFAULT_REVIEW_OUT}; context-bundle → ${DEFAULT_CONTEXT_OUT}/; skill → ${DEFAULT_SKILL_OUT}/; viewer → ${DEFAULT_VIEWER_OUT})`, ) .option( "--stdout", - "Write to stdout instead of a file (review-command only)", + "Write to stdout instead of a file (review-command, viewer)", + ) + .option( + "--budget ", + "Survey summary depth for viewer: compact, standard, or full (default: standard)", ) // context-bundle flags: .option("--no-tokens", "Skip tokens.css output (context-bundle)") @@ -84,6 +106,7 @@ export function registerEmitCommand(cli: CAC): void { if (!parsed.ok) { console.error(`Error: ${parsed.error}`); process.exit(2); + return; } if (parsed.kind === "skill") { @@ -104,6 +127,7 @@ export function registerEmitCommand(cli: CAC): void { ); for (const f of written) process.stdout.write(` ${f}\n`); process.exit(0); + return; } const profilePath = resolve( @@ -123,6 +147,7 @@ export function registerEmitCommand(cli: CAC): void { if (opts.stdout) { process.stdout.write(content); process.exit(0); + return; } const outPath = resolve( @@ -133,6 +158,52 @@ export function registerEmitCommand(cli: CAC): void { await writeFile(outPath, content, "utf-8"); console.log(`Wrote ${outPath}`); process.exit(0); + return; + } + + if (parsed.kind === "viewer") { + const budget = parseSurveyBudget(opts.budget as string | undefined); + if (!budget.ok) { + console.error(`Error: ${budget.error}`); + process.exit(2); + return; + } + + const { fingerprint } = await loadFingerprint(profilePath, { + noEmbeddingBackfill: true, + }); + const extras = await loadViewerArtifacts(profilePath, budget.budget); + for (const warning of extras.warnings) { + console.error(`Warning: ${warning}`); + } + + const content = buildFingerprintViewerHtml({ + fingerprint, + sourcePath: profilePath, + generatedAt: new Date().toISOString(), + surveySummary: extras.surveySummary, + surveyBudget: budget.budget, + map: extras.map, + checks: extras.checks, + artifacts: extras.artifacts, + warnings: extras.warnings, + }); + + if (opts.stdout) { + process.stdout.write(content); + process.exit(0); + return; + } + + const outPath = resolve( + process.cwd(), + opts.out ?? DEFAULT_VIEWER_OUT, + ); + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, content, "utf-8"); + console.log(`Wrote ${outPath}`); + process.exit(0); + return; } // kind === "context-bundle" @@ -160,11 +231,182 @@ export function registerEmitCommand(cli: CAC): void { process.stdout.write(` ${f}\n`); } process.exit(0); + return; } catch (err) { console.error( `Error: ${err instanceof Error ? err.message : String(err)}`, ); process.exit(2); + return; } }); } + +type ParseBudgetResult = + | { ok: true; budget: SurveySummaryBudget } + | { ok: false; error: string }; + +function parseSurveyBudget(raw: string | undefined): ParseBudgetResult { + if (raw === undefined) return { ok: true, budget: "standard" }; + if (raw === "compact" || raw === "standard" || raw === "full") { + return { ok: true, budget: raw }; + } + return { + ok: false, + error: `unknown viewer budget '${raw}'. Supported: compact, standard, full`, + }; +} + +interface ViewerExtras { + surveySummary?: SurveySummary; + map?: MapFrontmatter; + checks?: GhostChecksDocument; + artifacts: ViewerArtifactStatus[]; + warnings: string[]; +} + +async function loadViewerArtifacts( + profilePath: string, + budget: SurveySummaryBudget, +): Promise { + const dir = dirname(profilePath); + const artifacts: ViewerArtifactStatus[] = [ + { name: "profile.md", state: "included", path: profilePath }, + ]; + const warnings: string[] = []; + + const surveyPath = join(dir, SURVEY_FILENAME); + const surveyRaw = await readOptional( + surveyPath, + "survey.json", + artifacts, + warnings, + ); + let surveySummary: SurveySummary | undefined; + if (surveyRaw !== undefined) { + try { + const parsedJson = JSON.parse(surveyRaw); + const parsed = SurveySchema.safeParse(parsedJson); + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? "invalid survey"); + } + surveySummary = summarizeSurvey(parsed.data, { budget }); + } catch (err) { + markInvalid( + artifacts, + warnings, + "survey.json", + surveyPath, + `Could not load survey.json: ${formatError(err)}`, + ); + } + } + + const mapPath = join(dir, MAP_FILENAME); + const mapRaw = await readOptional(mapPath, "map.md", artifacts, warnings); + let map: MapFrontmatter | undefined; + if (mapRaw !== undefined) { + try { + const parsedYaml = parseYaml(extractFrontmatter(mapRaw, "map.md")); + const parsed = MapFrontmatterSchema.safeParse(parsedYaml); + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? "invalid map"); + } + map = parsed.data; + } catch (err) { + markInvalid( + artifacts, + warnings, + "map.md", + mapPath, + `Could not load map.md: ${formatError(err)}`, + ); + } + } + + const checksPath = join(dir, GHOST_CHECKS_FILENAME); + const checksRaw = await readOptional( + checksPath, + "checks.yml", + artifacts, + warnings, + ); + let checks: GhostChecksDocument | undefined; + if (checksRaw !== undefined) { + try { + const parsedYaml = parseYaml(checksRaw); + const parsed = GhostChecksSchema.safeParse(parsedYaml); + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? "invalid checks"); + } + checks = parsed.data; + } catch (err) { + markInvalid( + artifacts, + warnings, + "checks.yml", + checksPath, + `Could not load checks.yml: ${formatError(err)}`, + ); + } + } + + return { surveySummary, map, checks, artifacts, warnings }; +} + +async function readOptional( + path: string, + name: ViewerArtifactStatus["name"], + artifacts: ViewerArtifactStatus[], + warnings: string[], +): Promise { + try { + const raw = await readFile(path, "utf-8"); + artifacts.push({ name, state: "included", path }); + return raw; + } catch (err) { + const code = + err && typeof err === "object" && "code" in err + ? String((err as { code?: unknown }).code) + : ""; + if (code === "ENOENT") { + const message = `Optional ${name} not found at ${path}.`; + artifacts.push({ name, state: "missing", path, message }); + warnings.push(message); + return undefined; + } + const message = `Could not read optional ${name} at ${path}: ${formatError(err)}`; + artifacts.push({ name, state: "invalid", path, message }); + warnings.push(message); + return undefined; + } +} + +function markInvalid( + artifacts: ViewerArtifactStatus[], + warnings: string[], + name: ViewerArtifactStatus["name"], + path: string, + message: string, +): void { + const existing = artifacts.find((artifact) => artifact.name === name); + if (existing) { + existing.state = "invalid"; + existing.message = message; + } else { + artifacts.push({ name, state: "invalid", path, message }); + } + warnings.push(message); +} + +function extractFrontmatter(raw: string, label: string): string { + const match = raw.match(/^\s*---\r?\n([\s\S]*?)\r?\n---/); + if (!match) { + throw new Error(`${label} is missing a YAML frontmatter block`); + } + return match[1]; +} + +function formatError(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/packages/ghost-fingerprint/src/skill-bundle/SKILL.md b/packages/ghost-fingerprint/src/skill-bundle/SKILL.md index fb37e4f..a06b3f4 100644 --- a/packages/ghost-fingerprint/src/skill-bundle/SKILL.md +++ b/packages/ghost-fingerprint/src/skill-bundle/SKILL.md @@ -38,7 +38,7 @@ summaries, and structural profile diffs. | `ghost-fingerprint describe [profile.md]` | Print profile section ranges and token estimates. Defaults to `.ghost/fingerprint/profile.md`. | | `ghost-fingerprint diff ` | Structural prose-level diff between two profiles. | | `ghost-fingerprint survey ` | Survey ops: `merge`, `fix-ids`, `summarize`, `catalog`, `patterns`. | -| `ghost-fingerprint emit ` | Derive static artifacts. Kinds: `review-command`, `context-bundle`, `skill`. | +| `ghost-fingerprint emit ` | Derive static artifacts. Kinds: `review-command`, `context-bundle`, `viewer`, `skill`. | When the CLI is unavailable, follow the same recipes manually with file reads, search, and careful validation. Do not block on installation. diff --git a/packages/ghost-fingerprint/test/cli.test.ts b/packages/ghost-fingerprint/test/cli.test.ts index 33d2dac..a70b60f 100644 --- a/packages/ghost-fingerprint/test/cli.test.ts +++ b/packages/ghost-fingerprint/test/cli.test.ts @@ -191,8 +191,203 @@ describe("ghost-fingerprint CLI defaults", () => { expect(result.code).toBe(1); }); + + it("emit viewer writes fingerprint-viewer.html by default", async () => { + await writeViewerPackage(join(dir, ".ghost", "fingerprint")); + + const result = await runCli(["emit", "viewer"], dir); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + const html = await readFile(join(dir, "fingerprint-viewer.html"), "utf-8"); + expect(html).toContain(""); + expect(html).toContain("local"); + expect(html).toContain("survey.json"); + expect(html).toContain("Button"); + }); + + it("emit viewer honors --out", async () => { + await writeViewerPackage(join(dir, ".ghost", "fingerprint")); + + const result = await runCli( + ["emit", "viewer", "--out", "custom.html"], + dir, + ); + + expect(result.code).toBe(0); + const html = await readFile(join(dir, "custom.html"), "utf-8"); + expect(html).toContain("Ghost fingerprint viewer"); + }); + + it("emit viewer honors --stdout without writing the default file", async () => { + await writeViewerPackage(join(dir, ".ghost", "fingerprint")); + + const result = await runCli(["emit", "viewer", "--stdout"], dir); + + expect(result.code).toBe(0); + expect(result.stdout).toContain(""); + await expect( + readFile(join(dir, "fingerprint-viewer.html"), "utf-8"), + ).rejects.toThrow(); + }); + + it("emit viewer loads sibling artifacts next to a custom --profile", async () => { + await writeViewerPackage(join(dir, "custom-package")); + + const result = await runCli( + ["emit", "viewer", "--profile", "custom-package/profile.md", "--stdout"], + dir, + ); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("src/components/ui"); + expect(result.stdout).toContain("No red primary buttons"); + expect(result.stdout).toContain("Button"); + }); + + it("emit viewer exits 2 for an invalid profile", async () => { + await mkdir(join(dir, ".ghost", "fingerprint"), { recursive: true }); + await writeFile(join(dir, ".ghost", "fingerprint", "profile.md"), "nope"); + + const result = await runCli(["emit", "viewer"], dir); + + expect(result.code).toBe(2); + expect(result.stderr).toContain("Error:"); + }); }); +async function writeViewerPackage(packageDir: string): Promise { + await mkdir(packageDir, { recursive: true }); + await writeFile(join(packageDir, "profile.md"), BASE_FINGERPRINT, "utf-8"); + await writeFile( + join(packageDir, "survey.json"), + `${JSON.stringify(makeViewerSurvey(), null, 2)}\n`, + "utf-8", + ); + await writeFile( + join(packageDir, "map.md"), + `--- +schema: ghost.map/v2 +id: local +repo: local +mapped_at: 2026-05-07T00:00:00.000Z +platform: web +languages: + - { name: typescript, files: 1, share: 1 } +build_system: pnpm +package_manifests: + - package.json +composition: + frameworks: + - { name: react } + rendering: browser + styling: + - css +design_system: + paths: + - src/components/ui + status: active +surface_sources: + render_strategy: browser + include: + - src/**/*.tsx + exclude: + - "**/node_modules/**" +feature_areas: + - name: docs + paths: + - apps/docs +orientation_files: + - README.md +--- + +## Identity + +Local. + +## Topology + +Web. + +## Conventions + +Use local tokens. +`, + "utf-8", + ); + await writeFile( + join(packageDir, "checks.yml"), + `schema: ghost.checks/v1 +id: local +checks: + - id: no-red-primary + title: No red primary buttons + status: active + severity: serious + detector: + type: forbidden-regex + pattern: bg-red + evidence: + support: 0.96 + observed_count: 12 + repair: Use the accent token instead. +`, + "utf-8", + ); +} + +function makeViewerSurvey(): Survey { + return { + schema: "ghost.survey/v2", + sources: [SOURCE_A], + values: [ + { + id: valueRowId(SOURCE_A, "color", "#111111", "#111111"), + source: SOURCE_A, + kind: "color", + value: "#111111", + raw: "#111111", + occurrences: 4, + files_count: 2, + role_hypothesis: "primary", + }, + ], + tokens: [ + { + id: tokenRowId(SOURCE_A, "--color-primary"), + source: SOURCE_A, + name: "--color-primary", + alias_chain: [], + resolved_value: "#111111", + occurrences: 3, + }, + ], + components: [ + { + id: componentRowId(SOURCE_A, "Button"), + source: SOURCE_A, + name: "Button", + discovered_via: "registry.json", + variants: ["primary"], + }, + ], + ui_surfaces: [ + { + id: uiSurfaceRowId(SOURCE_A, "Dashboard", "route", "/dashboard"), + source: SOURCE_A, + name: "Dashboard", + kind: "route", + locator: "/dashboard", + renderability: "rendered", + files: ["src/dashboard.tsx"], + classification: { layout_shape: "tracker" }, + signals: { dominant_components: ["Button"] }, + }, + ], + }; +} + const SOURCE_A: SurveySource = { target: "github:block/ghost", commit: "abc123", diff --git a/packages/ghost-fingerprint/test/context/viewer.test.ts b/packages/ghost-fingerprint/test/context/viewer.test.ts new file mode 100644 index 0000000..8cd5be1 --- /dev/null +++ b/packages/ghost-fingerprint/test/context/viewer.test.ts @@ -0,0 +1,239 @@ +import type { + Fingerprint, + GhostChecksDocument, + MapFrontmatter, + Survey, + SurveySource, +} from "@ghost/core"; +import { summarizeSurvey } from "@ghost/core"; +import { describe, expect, it } from "vitest"; +import { buildFingerprintViewerHtml } from "../../src/core/context/index.js"; + +const SOURCE: SurveySource = { + target: "github:block/ghost", + commit: "abc123", + scanned_at: "2026-05-07T12:00:00.000Z", +}; + +const FINGERPRINT: Fingerprint = { + id: "sample-ds", + source: "llm", + timestamp: "2026-05-07T12:00:00.000Z", + observation: { + summary: "Restrained and direct.", + personality: ["restrained", "operational"], + resembles: ["Linear"], + }, + signature: + "Dense control surfaces with quiet chrome and a precise green accent.", + references: { + specs: ["src/styles/tokens.css"], + components: ["src/components/ui"], + examples: ["docs/examples/dashboard.md"], + }, + decisions: [ + { + dimension: "color-strategy", + dimension_kind: "color-strategy", + decision: "Use green as a precise signal, not a page wash.", + evidence: ["--color-accent: #00d64f"], + }, + ], + palette: { + dominant: [{ role: "accent", value: "#00d64f" }], + neutrals: { steps: ["#ffffff", "#111111"], count: 2 }, + semantic: [{ role: "surface", value: "#ffffff" }], + saturationProfile: "muted", + contrast: "high", + }, + spacing: { scale: [4, 8, 16], baseUnit: 4, regularity: 1 }, + typography: { + families: ["Inter"], + sizeRamp: [12, 16, 24], + weightDistribution: { 400: 0.8, 600: 0.2 }, + lineHeightPattern: "normal", + }, + surfaces: { + borderRadii: [4, 8], + shadowComplexity: "deliberate-none", + borderUsage: "minimal", + }, + embedding: [], +}; + +const CHECKS: GhostChecksDocument = { + schema: "ghost.checks/v1", + id: "sample-ds", + checks: [ + { + id: "no-red-buttons", + title: "No red primary buttons", + status: "active", + severity: "serious", + detector: { type: "forbidden-regex", pattern: "bg-red" }, + evidence: { support: 0.96, observed_count: 12 }, + repair: "Use the accent token instead.", + }, + ], +}; + +const MAP: MapFrontmatter = { + schema: "ghost.map/v2", + id: "sample-ds", + repo: "block/ghost", + mapped_at: "2026-05-07T12:00:00.000Z", + platform: "web", + languages: [{ name: "typescript", files: 12, share: 1 }], + build_system: ["pnpm", "vite"], + package_manifests: ["package.json"], + composition: { + frameworks: [{ name: "react" }], + rendering: "browser", + styling: ["css"], + }, + design_system: { + paths: ["src/components/ui"], + status: "active", + token_source: "inline", + }, + surface_sources: { + render_strategy: "browser", + include: ["src/**/*.tsx"], + exclude: ["**/node_modules/**"], + }, + feature_areas: [{ name: "docs", paths: ["apps/docs"] }], + orientation_files: ["README.md"], +}; + +function survey(): Survey { + return { + schema: "ghost.survey/v2", + sources: [SOURCE], + values: [ + { + id: "value-color-accent", + source: SOURCE, + kind: "color", + value: "#00d64f", + raw: "#00D64F", + occurrences: 5, + files_count: 2, + role_hypothesis: "accent", + }, + ], + tokens: [ + { + id: "token-accent", + source: SOURCE, + name: "--color-accent", + alias_chain: [], + resolved_value: "#00d64f", + occurrences: 4, + }, + ], + components: [ + { + id: "component-button", + source: SOURCE, + name: "Button", + discovered_via: "registry.json", + variants: ["primary"], + sizes: ["sm"], + }, + ], + ui_surfaces: [ + { + id: "surface-dashboard", + source: SOURCE, + name: "Dashboard", + kind: "route", + locator: "/dashboard", + renderability: "rendered", + files: ["src/dashboard.tsx"], + classification: { layout_shape: "tracker", density: "compressed" }, + signals: { dominant_components: ["Button"] }, + }, + ], + }; +} + +describe("buildFingerprintViewerHtml", () => { + it("renders profile values, decisions, checks, map data, and survey evidence", () => { + const html = buildFingerprintViewerHtml({ + fingerprint: FINGERPRINT, + sourcePath: "/repo/.ghost/fingerprint/profile.md", + generatedAt: "2026-05-07T13:00:00.000Z", + surveySummary: summarizeSurvey(survey()), + surveyBudget: "standard", + map: MAP, + checks: CHECKS, + artifacts: [ + { name: "profile.md", state: "included" }, + { name: "survey.json", state: "included" }, + { name: "map.md", state: "included" }, + { name: "checks.yml", state: "included" }, + ], + }); + + expect(html).toContain("sample-ds"); + expect(html).toContain("#00d64f"); + expect(html).toContain("color-strategy"); + expect(html).toContain("No red primary buttons"); + expect(html).toContain("--color-accent"); + expect(html).toContain("Button"); + expect(html).toContain("Dashboard"); + expect(html).toContain("src/components/ui"); + }); + + it("escapes profile and survey strings before rendering", () => { + const html = buildFingerprintViewerHtml({ + fingerprint: { + ...FINGERPRINT, + signature: "", + decisions: [ + { + dimension: "unsafe", + decision: "", + evidence: [""], + }, + ], + }, + surveySummary: summarizeSurvey({ + ...survey(), + components: [ + { + id: "component-unsafe", + source: SOURCE, + name: "", + discovered_via: "fixture", + }, + ], + }), + }); + + expect(html).not.toContain(""); + expect(html).not.toContain(""); + expect(html).not.toContain(""); + expect(html).toContain("<script>alert(1)</script>"); + expect(html).toContain("<img src=x onerror=alert(1)>"); + }); + + it("renders warnings for missing optional artifacts", () => { + const html = buildFingerprintViewerHtml({ + fingerprint: FINGERPRINT, + artifacts: [ + { name: "profile.md", state: "included" }, + { + name: "survey.json", + state: "missing", + message: "Optional survey.json not found.", + }, + ], + warnings: ["Optional survey.json not found."], + }); + + expect(html).toContain("Optional survey.json not found."); + expect(html).toContain("missing"); + expect(html).toContain("No survey summary was included."); + }); +}); diff --git a/packages/ghost-fingerprint/test/emit-kind.test.ts b/packages/ghost-fingerprint/test/emit-kind.test.ts index df245a3..872259d 100644 --- a/packages/ghost-fingerprint/test/emit-kind.test.ts +++ b/packages/ghost-fingerprint/test/emit-kind.test.ts @@ -23,6 +23,13 @@ describe("parseEmitKind", () => { }); }); + it("accepts viewer", () => { + expect(parseEmitKind("viewer")).toEqual({ + ok: true, + kind: "viewer", + }); + }); + it("rejects unknown kinds with a helpful error", () => { const result = parseEmitKind("nope"); expect(result.ok).toBe(false); @@ -31,6 +38,7 @@ describe("parseEmitKind", () => { expect(result.error).toContain("review-command"); expect(result.error).toContain("context-bundle"); expect(result.error).toContain("skill"); + expect(result.error).toContain("viewer"); } });