// The speckit SDLC's FIRST-PARTY code extension (VIZ-4/VIZ-14). AC = user story (priority + MVP
// + scenarios + tasks, with verification-layer commits). Issue-level panels render the rest of
// the captured Spec Kit process: metadata, FR/SC, key entities/edge cases/assumptions/
// clarifications, the plan (technical context + Constitution Check), constitution principles,
// non-story task phases, and design-artifact presence.
//
// Vocabulary (statusOrder/acUnitLabel) is NOT here โ it moved to DATA, the `visualizer` block in
// `boilerplates/presets/speckit.ts` (VIZ-2), because this file conforms to the render-only
// `VisualizerExtension` contract (`client/extensions.tsx`), which deliberately excludes
// vocabulary members (VIZ-14's drift guard). This module is discovered by filename at
// bundle-build time (server.ts's generated entry scans `client/presets/*.tsx`, imports this
// file's default export, and calls `registerExtension('speckit', ...)`) โ no hardcoded
// name->extension map anywhere.
import React from 'react';
import type { VisualizerExtension } from '../extensions';
type Task = { id: string; title: string; status: string; parallel?: boolean; commit?: string; dependsOn?: string[] };
function TaskList({ tasks }: { tasks: Task[] }) {
if (!tasks.length) return null;
return (
{tasks.map((t) => (
{t.status === 'done' ? '[x]' : '[ ]'}
{t.id}
{t.parallel && P}
{t.title}
{t.commit && {t.commit.slice(0, 7)}}
))}
);
}
function Panel({ title, count, children }: { title: string; count?: number; children: React.ReactNode }) {
return {title}
{count !== undefined && {count}}{children};
}
const speckitExtension: VisualizerExtension = {
acText: (ac) => {
const a = ac as { id: string; text?: string; priority?: string; mvp?: boolean; needsClarification?: boolean };
return (
<>
{a.priority && {a.priority}}{a.mvp && ๐ฏ MVP}{' '}
{a.id} {a.text}
{a.needsClarification && NEEDS CLARIFICATION}
>
);
},
acEvidence: (ac) => {
const a = ac as { tasks?: Task[]; scenarios?: Array<{ id: string; text: string }> };
return (
<>
{a.scenarios && a.scenarios.length > 0 && {a.scenarios.map((s) =>
{s.text}
)}
}
>
);
},
// The kit's issuePanels receives (issue, projectUrl) โ the same `/project/` URL mapper
// acEvidence already gets โ so a panel can link evidence/design-artifact files under the
// project root. This preset's captured process is all inline text/structure today, so
// projectUrl is unused here, but the parameter is wired through at the call site (main.tsx)
// for any first-party or repo-local extension (VIZ-13) that needs it.
issuePanels: (issue, _projectUrl) => {
const i = issue as {
metadata?: { featureBranch?: string; status?: string; created?: string; input?: string };
requirements?: Array<{ id: string; text: string; needsClarification: boolean }>;
successCriteria?: Array<{ id: string; text: string; needsClarification: boolean }>;
keyEntities?: Array<{ name: string; description: string }>;
edgeCases?: string[]; assumptions?: string[]; clarifications?: Array<{ text: string }>;
phases?: Array<{ name: string; kind: string; tasks: Task[] }>;
plan?: { present: boolean; technicalContext: Array<{ field: string; value: string }>; constitutionGates: Array<{ text: string; passed?: boolean }>; complexity: string[] };
constitution?: { present: boolean; principles: string[] };
artifacts?: { research: boolean; dataModel: boolean; quickstart: boolean; contracts: string[] };
};
const m = i.metadata ?? {}, reqs = i.requirements ?? [], scs = i.successCriteria ?? [], plan = i.plan, con = i.constitution, art = i.artifacts;
return (
<>
{(m.featureBranch || m.status || m.created || m.input) && (
{m.featureBranch && <>branch{m.featureBranch}>}
{m.status && <>status{m.status}>}
{m.created && <>created{m.created}>}
{m.input && <>input{m.input}>}
)}
{reqs.length > 0 && {reqs.map((r) =>
{r.id} {r.text}{r.needsClarification && NEEDS CLARIFICATION}
)}
}
{scs.length > 0 && {scs.map((c) =>
{c.id} {c.text}
)}
}
{i.keyEntities && i.keyEntities.length > 0 && {i.keyEntities.map((e) =>
{e.name} โ {e.description}
)}
}
{((i.edgeCases?.length ?? 0) > 0 || (i.assumptions?.length ?? 0) > 0 || (i.clarifications?.length ?? 0) > 0) && (
{(i.edgeCases ?? []).map((e, n) =>
edge {e}
)}
{(i.assumptions ?? []).map((a, n) =>
assume {a}
)}
{(i.clarifications ?? []).map((c, n) =>
clarified {c.text}
)}
)}
{plan?.present && (
{plan.technicalContext.length > 0 && {plan.technicalContext.map((f) => {f.field}{f.value})}
}
{plan.constitutionGates.length > 0 && (
Constitution Check
{plan.constitutionGates.map((g, n) =>
{g.passed === false ? 'โ' : 'โ'} {g.text}
)}
)}
)}
{con?.present && con.principles.length > 0 && {con.principles.map((p) =>
{p}
)}
}
{(i.phases ?? []).filter((p) => p.tasks.length > 0).map((p) => (
{p.kind}
))}
{art && (art.research || art.dataModel || art.quickstart || art.contracts.length > 0) && (
{art.research && research.md}
{art.dataModel && data-model.md}
{art.quickstart && quickstart.md}
{art.contracts.map((c) => {c.split('/').pop()})}
)}
>
);
},
};
// Default export ONLY โ registration itself happens in the generated bundle entry (server.ts),
// which scans this directory, imports each file's default export, and calls
// `registerExtension(, )`. Filename = canonical preset name (mirroring
// the boilerplates/presets two-file convention) is the ONE place that name is decided, so this
// file itself never hardcodes it.
export default speckitExtension;