",
+ "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 ``;
+}
+
+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 `
+ ${sectionHead("values", "Values")}
+
+
Palette
+
+ ${fingerprint.palette.dominant.map((c) => renderSwatch(c.role, c.value, "dominant")).join("")}
+ ${fingerprint.palette.semantic.map((c) => renderSwatch(c.role, c.value, "semantic")).join("")}
+
+
${fingerprint.palette.neutrals.steps
+ .map((value, index) =>
+ renderSwatch(`step ${index + 1}`, value, "neutral"),
+ )
+ .join("")}
+
Saturation: ${escapeHtml(fingerprint.palette.saturationProfile)}. Contrast: ${escapeHtml(
+ fingerprint.palette.contrast,
+ )}.
+
+
+ ${renderMetricBlock(
+ "Spacing scale",
+ fingerprint.spacing.scale.map((v) => `${v}px`),
+ [
+ `Base unit: ${fingerprint.spacing.baseUnit ?? "none"}`,
+ `Regularity: ${fingerprint.spacing.regularity}`,
+ ],
+ )}
+ ${renderMetricBlock(
+ "Type ramp",
+ fingerprint.typography.sizeRamp.map((v) => `${v}px`),
+ [
+ `Families: ${fingerprint.typography.families.join(", ") || "none"}`,
+ `Line height: ${fingerprint.typography.lineHeightPattern}`,
+ ],
+ )}
+ ${renderMetricBlock(
+ "Weights",
+ Object.entries(fingerprint.typography.weightDistribution).map(
+ ([k, v]) => `${k}: ${v}`,
+ ),
+ [],
+ )}
+ ${renderMetricBlock(
+ "Surfaces",
+ fingerprint.surfaces.borderRadii.map((v) => `${v}px`),
+ [
+ `Shadows: ${fingerprint.surfaces.shadowComplexity}`,
+ `Borders: ${fingerprint.surfaces.borderUsage}`,
+ fingerprint.surfaces.borderTokenCount !== undefined
+ ? `Border tokens: ${fingerprint.surfaces.borderTokenCount}`
+ : undefined,
+ ],
+ )}
+
+ `;
+}
+
+function renderDecisions(
+ fingerprint: Fingerprint,
+ checks: GhostChecksDocument | undefined,
+): string {
+ const decisions = fingerprint.decisions ?? [];
+ return `
+ ${sectionHead("decisions", "Decisions")}
+
+ ${
+ decisions.length
+ ? decisions.map(renderDecision).join("")
+ : `
No decisions have been authored yet.
`
+ }
+
+
+
Checks
+ ${
+ checks?.checks.length
+ ? `
${checks.checks.map(renderCheck).join("")}
`
+ : `
No package checks were included.
`
+ }
+
+ `;
+}
+
+function renderEvidence(
+ summary: SurveySummary | undefined,
+ budget: SurveySummaryBudget | undefined,
+): string {
+ if (!summary) {
+ return `
+ ${sectionHead("evidence", "Evidence")}
+ No survey summary was included.
+ `;
+ }
+ return `
+ ${sectionHead("evidence", "Evidence")}
+
+ ${renderCountCard("Sources", summary.counts.sources)}
+ ${renderCountCard("Values", summary.counts.values)}
+ ${renderCountCard("Tokens", summary.counts.tokens)}
+ ${renderCountCard("Components", summary.counts.components)}
+ ${renderCountCard("UI surfaces", summary.counts.ui_surfaces)}
+
+ Survey budget: ${escapeHtml(budget ?? summary.budget)}
+
+
Top values
+ ${summary.values.kinds.map(renderValueKind).join("") || `
No value rows recorded.
`}
+
+
+
Tokens
+ ${renderTokenTable(summary)}
+
+
+
Components
+ ${renderComponentTable(summary)}
+
+
+
UI surfaces
+ ${renderSurfaceTable(summary)}
+
+ `;
+}
+
+function renderTopology(map: MapFrontmatter | undefined): string {
+ if (!map) {
+ return `
+ ${sectionHead("topology", "Topology")}
+ No map frontmatter was included.
+ `;
+ }
+ return `
+ ${sectionHead("topology", "Topology")}
+
+ ${renderMetricBlock("Platform", toList(map.platform), [`Repo: ${map.repo}`, `Mapped: ${map.mapped_at}`])}
+ ${renderMetricBlock("Build", toList(map.build_system), [
+ `Rendering: ${map.composition.rendering}`,
+ `Styling: ${map.composition.styling.join(", ")}`,
+ ])}
+ ${renderMetricBlock(
+ "Languages",
+ map.languages.map((l) => `${l.name} ${Math.round(l.share * 100)}%`),
+ [],
+ )}
+ ${renderMetricBlock("Design system", map.design_system.paths, [
+ `Status: ${map.design_system.status}`,
+ map.design_system.token_source
+ ? `Token source: ${map.design_system.token_source}`
+ : undefined,
+ ])}
+
+
+
+
Surface sources
+ ${renderList("Include", map.surface_sources.include)}
+ ${renderList("Exclude", map.surface_sources.exclude)}
+
Render strategy: ${escapeHtml(map.surface_sources.render_strategy)}
+
+
+
Feature areas
+
${map.feature_areas
+ .map(
+ (
+ area,
+ ) => `
+ ${escapeHtml(area.name)}
+ ${escapeHtml(area.paths.join(", "))}
+ ${area.sub_areas?.length ? `${escapeHtml(area.sub_areas.join(", "))}
` : ""}
+ `,
+ )
+ .join("")}
+
+
+ `;
+}
+
+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) => `| ${escapeHtml(h)} | `).join("")}
+ ${rows
+ .map(
+ (row) =>
+ `${row.map((cell) => `| ${cellHtml(cell)} | `).join("")}
`,
+ )
+ .join("")}
+
`;
+}
+
+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: ["