Design-System Index in Agent-Kit
DL#204 — A design-system (region/template) index in the agent-kit
Written for AI agents. See Log Methodology Note below for details.
Status: DESIGN — ready for review (rev. 2, review feedback folded in). Promoted from DL#201
Item 4. Covers a new agent-kit index — plus a human/agent-readable "add-menu" doc — that catalogs,
for every headless component an agent could flatten, its contract and the .jay-html template
variants it ships. Template↔contract association is by the template's declared contract reference, not
a filename convention. Nothing here is implemented yet.
Related: #85 (plugins-index / agent kit), #196 (flatten / regions / template=), #200 (prefer
design-system elements — REGION-NOT-LINKED, COMPONENT-NO-TEMPLATE, availability heuristic),
#201 (region system follow-ups — this is Item 4).
Decisions for the Implementer (TL;DR)
- Emit a new sibling index
agent-kit/design-system-index.yaml, written right afterplugins-index.yamlinmaterializeContracts(mirror the write atcontract-materializer.ts:625-629). Do not overloadplugins-index's per-contract entries — the new index has a different discovery scope (it must cover localsrc/components, which plugins-index does not scan). - Link it from
plugins-index.yamlwith a single top-level pointer fielddesignSystemIndex: ./design-system-index.yaml, and add a pointer in the designer agent-kit docs (agent-kit-template/designer/contracts-and-plugins.md). The YAML link is the machine edge; the doc pointer is the human/agent edge. - Two discovery sources, merged into one catalog:
- Local components — scan
componentsBase(default./src/components, same basevalidateuses) for*.jay-contract, enumerate sibling template variants. - Plugin components — reuse
scanPlugins({ projectRoot, includeDevDeps: true })(plugin-scanner.ts); for eachmanifest.contracts/dynamic_contractsentry, enumerate sibling template variants next to the contract path.
- Local components — scan
- Template-variant enumeration rule: a template is a variant of contract
X.jay-contractwhen its own<script type="application/jay-data" contract="…">resolves to that same.jay-contractfile. No filename convention — the template already declares its contract (contractRef,jay-html-parser.ts:545-546), so we match on that, not on the filename. This also handles a directory holding several contracts correctly: each.jay-htmlis grouped under whichever contract it points at. Generalize DL#200's booleanhasTemplateForContractFile(validate.ts:104-116, which today counts any.jay-htmlin the dir) intolistTemplatesForContractFile(contractFile): TemplateVariant[]that scans the dir, parses each.jay-html, and keeps the ones whose resolvedcontractRef === contractFile; the boolean rule delegates tolist.length > 0. - Each template entry is
{ path, variant, title? }.titlecomes from the template'sheadMeta.title(jay-html-parser.ts:985, exposed asJayHtmlSourceFile.headMeta.title: TemplatePart[]) — reconstruct text from the static parts; if empty or interpolation-only, omit it. No fallback to the contract description — that is a property of the component, not of a specific template (see next bullet).variantis a stable id derived from the filename stem (''/defaultforX.jay-html, else the stem, e.g.card.feature). - The contract
descriptionlives on the component entry, not the template. ADesignSystemComponentEntrycarriesdescription?read from the.jay-contract; template entries carry only their own<title>. One contract → one description; N templates → N titles. - Also emit an agent/editor "add-menu" doc (
agent-kit/design-system.md) generated from the same scan: a human- and agent-readable catalog of every component and its template variants, each with the ready-to-pastetemplate=import +<jay:X>snippet. The YAML index is the machine edge; this doc is the "what can I add here" menu an editor UI or an agent reads. - Feed DL#200's
REGION-NOT-LINKEDsuggestion with the real template path(s) from this index instead of the current"…/card.jay-html"placeholder (validate.tssuggestion string). - Non-goal: no new runtime, no new
.jay-html/contract syntax. This is a build-time catalog generated from files that already exist. If a contract ships no template, it simply has an emptytemplates: [](and DL#200 already warnsCOMPONENT-NO-TEMPLATE).
Background
An agent (or human) authoring a page chooses which <jay:X> regions to flatten. DL#196 made
template= the provenance marker and DL#200 added warnings that nudge toward design-system
elements (REGION-NOT-LINKED when a region could be linked; COMPONENT-NO-TEMPLATE when a
component ships no template). But nothing hands the agent a catalog: which components ship
templates, how many variants each has, and what each variant is for.
The agent-kit already generates plugins-index.yaml (DL#85) via materializeContracts
(contract-materializer.ts:340, written at :611-629). It lists plugin contracts, actions,
services, routes — but no templates, and (critically) it only sees plugins.
Problem
Three concrete gaps:
- No template catalog.
PluginContractEntry(contract-materializer.ts:38-45) hasname/description/type/path/metadata— notemplatesfield. An agent can't discover thatcardshipscard.jay-htmlandcard.feature.jay-html, nor what distinguishes them. - Local components are invisible.
scanPlugins(plugin-scanner.ts:61-188) discovers onlysrc/plugins/*(withplugin.yaml) and node_modules deps (withplugin.yaml). A plain project component undersrc/components/card/is never indexed — yet it is exactly what DL#200's availability heuristic finds and what a page most often flattens. REGION-NOT-LINKEDcan't name the path. DL#200's suggestion emits a placeholder (template="…/card.jay-html") because it only knows a template exists (booleanhasTemplateForContractFile,validate.ts:104-116), not where or which variant.
Prior Art / Adjacent Mechanisms
| Mechanism | Where | Solves / Constrains |
|---|---|---|
plugins-index.yaml generation |
contract-materializer.ts:340-629; CLI run-agent-kit.ts:80-128,348-364 |
The index-emission pattern to mirror; but scope is plugins-only and schema has no templates. |
scanPlugins |
plugin-scanner.ts:61-188 |
Gives us plugin + local-plugin contracts. Does not scan src/components — so we add a local-component scan. |
hasTemplateForContractFile |
validate.ts:104-116 |
Already does directory-co-location template lookup, but collapses to a boolean. Generalize to an enumeration; keep the boolean as a thin wrapper. |
parseHeadMeta → headMeta.title |
jay-html-parser.ts:985-991, :1320,:1364; type at plugin-validators.ts:11-23; exposed jay-html-source-file.ts:75 |
The per-template description source. Already parsed into the pipeline — we just read it. |
componentsBase config |
same base validate/sync scope to (default ./src/components) |
The local-component discovery root — reuse it so the index and the validators agree on what a "project component" is. |
Null hypothesis (why not reuse what exists): Could we just add templates to
PluginContractEntry and be done? No — that only covers plugin contracts, leaving local
src/components (the common case) uncatalogued. The new index needs an independent local scan, so a
separate index with its own discovery is the smaller, more honest surface than stretching
plugins-index to mean two different scopes.
Questions and Answers
Q: New sibling index, or new fields on plugins-index?
A: Sibling index (design-system-index.yaml). Decided by the user ("a second index file that is
linked from plugin-index"), and justified by scope: plugins-index is plugins-only; the design-system
index must also cover local components. Keeping them separate keeps each index's scope coherent.
Q: Does plugins-index already list same-project components?
A: No — only plugins (src/plugins/* + node_modules, both gated on plugin.yaml). Confirmed in
plugin-scanner.ts:82-188. This is why the new index can't just reference plugins-index entries.
Q: Can one contract have multiple templates? How are they associated — by filename?
A: Yes, co-location allows any number of .jay-html beside a .jay-contract. No filename
convention is needed (reviewer: "why not just count any template variant that shares the same
contract?"). Each .jay-html already declares its contract via
<script type="application/jay-data" contract="…"> (contractRef, jay-html-parser.ts:545-546); a
template is a variant of contract X iff its resolved contractRef points at X.jay-contract.
That is both simpler (no new naming rule to teach) and more correct in a multi-contract directory
(each template is grouped by what it actually points at, not by a basename coincidence). DL#200's
"any .jay-html in dir" boolean becomes listTemplatesForContractFile(...).length > 0 over this
contract-reference match. The variant id is just the filename stem, for a stable handle — it is a
label, not the association key.
Q: What is a template's description?
A: Its <title> (headMeta.title), reconstructed from static TemplateParts; omit it when the
title is empty/interpolation-only. It does not fall back to the contract description
(reviewer: the contract description "belongs at the contract level, not the specific template"). The
contract description is a property of the component, so it lives on DesignSystemComponentEntry
(read once from the .jay-contract), while each template entry carries only its own <title>.
Q: Only a YAML index, or a human/agent-facing doc too?
A: Both. Since we already have the scan, also generate an "add-menu" markdown doc
(agent-kit/design-system.md) — a catalog of every component and its template variants with a
copy-paste import + <jay:X> snippet. This is the menu an editor UI's "add element" panel or an
agent reads to decide what to flatten; the YAML stays the structured/machine form. (Reviewer: "if we
create the index, we can as well create an editor/add-menu doc as well.")
Q: Should this index affect the build, or just inform agents?
A: Inform agents, plus one validator improvement: REGION-NOT-LINKED's suggestion names the real
path(s). No build gating — consistent with DL#200 (warnings never block).
Design
New types (contract-materializer.ts, beside the existing index types)
export interface TemplateVariant {
path: string; // repo-relative or agent-kit-relative path to the .jay-html
variant: string; // '' | 'default' for X.jay-html, else the filename stem — a stable label, not the association key
title?: string; // from headMeta.title static parts; omitted when empty/interpolation-only
}
export interface DesignSystemComponentEntry {
name: string; // contract name
contractPath: string; // path to the .jay-contract
description?: string; // from the .jay-contract — a component-level property, read once
source: 'local' | 'plugin';
plugin?: string; // plugin name when source === 'plugin'
templates: TemplateVariant[]; // every .jay-html whose contractRef resolves to this contract
}
export interface DesignSystemIndex {
components: DesignSystemComponentEntry[];
}
And a one-field link on the existing index:
export interface PluginsIndex {
plugins: PluginsIndexEntry[];
designSystemIndex?: string; // './design-system-index.yaml'
}
Generalize the template lookup (validate.ts)
// Replaces the boolean-only hasTemplateForContractFile; boolean becomes list.length > 0.
// Association is by the template's declared contract (contractRef), not by filename.
export function listTemplatesForContractFile(contractFile: string | undefined): TemplateVariant[] {
if (!contractFile) return [];
const dir = path.dirname(contractFile);
const target = path.resolve(contractFile);
let names: string[];
try {
names = fs.readdirSync(dir);
} catch {
return [];
}
return names
.filter((f) => f.endsWith(JAY_EXTENSION))
.map((f) => path.join(dir, f))
.filter((p) => resolvedContractRef(p) === target) // parse .jay-html, resolve its contract=, compare
.map((p) => toTemplateVariant(p));
}
export function hasTemplateForContractFile(c: string | undefined): boolean {
return listTemplatesForContractFile(c).length > 0;
}
resolvedContractRef(jayHtmlPath) parses the .jay-html (reuse the existing jay-html parse), reads
the contractRef from its <script type="application/jay-data" contract="…">
(jay-html-parser.ts:545-546), and resolves it relative to the template's directory; returns
undefined for templates with inline data / no contract (those are never design-system variants).
toTemplateVariant reads headMeta.title for the title and derives variant from the filename
stem ('' when the file is <contract-basename>.jay-html, else the stem) — a label only.
Note — we parse each candidate
.jay-htmlanyway. Both the old filename rule and this contract-reference rule have to parse the template to read its<title>. Matching oncontractRefadds no parse cost over what thetitlealready requires; it only removes the naming convention.
Generation (materializeContracts, after the plugins-index write at :625-629)
const designSystem: DesignSystemIndex = { components: [] };
// local components — scan componentsBase for *.jay-contract
for (const contractFile of globContracts(componentsBase)) {
designSystem.components.push(localEntry(contractFile)); // source: 'local'
}
// plugin components — reuse the same scan plugins-index already did
for (const plugin of scannedPlugins) {
for (const c of [...plugin.manifest.contracts, ...plugin.manifest.dynamic_contracts]) {
designSystem.components.push(pluginEntry(plugin, c)); // source: 'plugin'
}
}
pluginsIndex.designSystemIndex = './design-system-index.yaml';
fs.writeFileSync(
path.join(agentKitDir, 'design-system-index.yaml'),
YAML.stringify(designSystem),
'utf-8',
);
// human/agent "add-menu" doc, generated from the same object
fs.writeFileSync(path.join(agentKitDir, 'design-system.md'), renderAddMenu(designSystem), 'utf-8');
(agentKitDir = path.dirname(outputDir), already computed at :625.)
localEntry/pluginEntry set the component description by parsing the .jay-contract and reading
its description (component-level). renderAddMenu walks the same DesignSystemIndex and emits a
markdown catalog — one section per component (name + contract description), a row per template
variant (its <title> + variant), and a ready-to-paste snippet pairing the template= import with
the <jay:X> region.
DL#200 suggestion upgrade (validate.ts)
REGION-NOT-LINKED's suggestion builds its path from listTemplatesForContractFile(imp.contractPath):
one variant → name it exactly; multiple → list them so the author picks. Placeholder only when the
list is somehow empty (shouldn't happen, since the rule only fires when a template exists).
Implementation Plan
- Types — add
TemplateVariant,DesignSystemComponentEntry,DesignSystemIndex, andPluginsIndex.designSystemIndexincontract-materializer.ts. (TemplateVarianthas nodescription;DesignSystemComponentEntryhasdescription.) - Lookup — generalize
hasTemplateForContractFile→listTemplatesForContractFileinvalidate.ts, matching on the template's resolvedcontractRef(not filename); keep the boolean as a wrapper; addresolvedContractRefandtoTemplateVariant(readsheadMeta.title, derivesvariantfrom the filename stem). - Generation — in
materializeContracts, after the plugins-index write: local scan overcomponentsBase+ plugin scan overscannedPlugins; read each contract'sdescriptionfor the component entry; emitdesign-system-index.yaml; set thedesignSystemIndexlink on the plugins-index object before it serializes. - Add-menu doc —
renderAddMenu(designSystem)→ writeagent-kit/design-system.md(component sections with contractdescription, per-variant rows, copy-paste import +<jay:X>snippet). - CLI print —
run-agent-kit.ts: when printing the index (:118-128), also surface the new index (and a--design-systemlist mode mirroring--list, optional). - DL#200 suggestion — rewrite
REGION-NOT-LINKED's suggestion to name real paths. - Docs — add a pointer in
agent-kit-template/designer/contracts-and-plugins.md("readdesign-system.md/design-system-index.yamlto choose a template to flatten"). - Tests (fixture-based,
toEqualon parsed YAML — nevertoContain): a fixture project with (a) a local component that ships two variants both pointing at the same contract, (b) a local component with no template (emptytemplates), (c) a plugin contract with a template, and (d) a directory holding two contracts with one template each, to prove association is bycontractRefnot filename. Assert the emitteddesign-system-index.yamlobject and thedesignSystemIndexlink.
Examples
agent-kit/design-system-index.yaml:
components:
- name: card
contractPath: ./src/components/card/card.jay-contract
description: A product card with media, title and body. # from the .jay-contract
source: local
templates:
- path: ./src/components/card/card.jay-html
variant: ''
title: Card
- path: ./src/components/card/card.feature.jay-html
variant: card.feature # the filename stem
title: Feature card
- name: badge
contractPath: ./src/components/badge/badge.jay-contract
description: A small status badge.
source: local
templates: [] # COMPONENT-NO-TEMPLATE also fires (DL#200)
- name: rating
contractPath: ./node_modules/@acme/reviews/rating.jay-contract
description: Star rating control.
source: plugin
plugin: '@acme/reviews'
templates:
- path: ./node_modules/@acme/reviews/rating.jay-html
variant: ''
title: Star rating
agent-kit/design-system.md (the add-menu doc, same data):
# Design-system elements you can add
## card — A product card with media, title and body.
| Variant | Title | Template |
| ------------ | ------------ | ------------------------------------------- |
| (default) | Card | ./src/components/card/card.jay-html |
| card.feature | Feature card | ./src/components/card/card.feature.jay-html |
To add the **Feature card** variant:
```html
<script
type="application/jay-headless"
contract="./src/components/card/card.jay-contract"
template="./src/components/card/card.feature.jay-html"
></script>
<jay:card ref="myCard"><!-- flattened copy; edit freely --></jay:card>
```
Then run `jay-stack sync` to flatten it.
## rating — Star rating control.
| Variant | Title | Template |
| --------- | ----------- | -------------------------------------------- |
| (default) | Star rating | ./node_modules/@acme/reviews/rating.jay-html |
plugins-index.yaml gains one line:
designSystemIndex: ./design-system-index.yaml
plugins:
- name: '@acme/reviews'
# …
Trade-offs
- Two indexes instead of one. Slight duplication (plugin contracts appear in both), but each index keeps a coherent scope; merging would force plugins-index to also mean "local components," which it currently doesn't.
- No new naming convention. Association is by the template's declared
contractRef, which already exists, so there is nothing new to teach and a multi-contract directory groups correctly. (Earlier draft proposed anX.jay-html/X.<variant>.jay-htmlfilename rule; dropped per review as unnecessary surface.) A.jay-htmlwhosecontractRefresolves to no scanned contract is simply uncatalogued — worth a companionvalidatenote so a stray template is reported rather than silently ignored. - Parse cost at agent-kit time. We parse every candidate
.jay-htmlto read its<title>andcontractRef. Agent-kit generation is a dev/build-time step, not a hot path; acceptable — and the contract-reference match adds no parse over what the title already needs. - A second generated artifact (the add-menu doc).
design-system.mdduplicates the YAML's content in prose. It is regenerated from the same object each run, so they can't drift; the cost is one more file write.
Verification criteria
- A project with local components (some multi-variant, some template-less) and a plugin component
emits a
design-system-index.yamlwhose object equals the fixture (toEqual). Each component entry carries its contractdescription; each template entry carries only its<title>. - A directory holding two contracts with one template each groups each template under the contract
its
contractRefpoints at (proves association is by reference, not filename). plugins-index.yamlcarriesdesignSystemIndex: ./design-system-index.yaml.agent-kit/design-system.mdis emitted and lists every component + variant with a paste-ready import/<jay:X>snippet.hasTemplateForContractFilestill returns the same boolean for existing DL#200 tests (wrapper over the new enumeration) — no regression inCOMPONENT-NO-TEMPLATE/REGION-NOT-LINKED.REGION-NOT-LINKED's suggestion names the real template path(s) for a contract with a sibling template.
Implementation Results
Status: implemented. All verification criteria met. Tests: design-system-index 8/8,
validate 84/84, full stack-cli 127/127, full stack-server-build 59/59; both packages
type-check and build clean.
What shipped
packages/jay-stack/stack-server-build/lib/design-system-index.ts(new) — the shared module. Exports theTemplateVariant/DesignSystemComponentEntry/DesignSystemIndextypes, pluslistTemplatesForContractFile,hasTemplateForContractFile,buildDesignSystemIndex, andrenderAddMenu. A lightweightreadTemplateMetareads each.jay-htmlwithnode-html-parserto extractcontractRef(thecontract=attr onscript[type="application/jay-data"], resolved absolute) and<title>(interpolation-only titles are dropped).contract-materializer.ts— generation wired intomaterializeContracts: builds the index, writesagent-kit/design-system-index.yaml+agent-kit/design-system.md, and links the YAML fromplugins-index.yamlviadesignSystemIndex.readComponentsBasehonors the.jaydevServer.componentsBase(default./src/components).stack-cli/lib/validate.ts—checkRegionNotLinkednow takes atemplatesFor: (imp) => TemplateVariant[]and names real, project-relative template paths in its suggestion; the localhasTemplateForContractFilewas removed in favor of the shared one.stack-cli/lib/run-agent-kit.ts+designer/contracts-and-plugins.md— summary line and a "Discovery: Design-System Index" doc section.
Deviations from the design
- Shared lookup lives in
stack-server-build, notvalidate.ts. The DL placed the enumeration invalidate.ts, butstack-server-build(which generates the index) cannot import fromstack-cli— the dependency runs the other way. The functions therefore live instack-server-build's new module andvalidate.tsimports them. - Lightweight
node-html-parserread, not a fullparseJayFile. OnlycontractRef+<title>are needed, so a full parse is unnecessary; addednode-html-parseras an explicit dependency. variantis the filename stem (e.g.card.feature), with the base template (stem === contract basename) normalized to''. The DL examples showed a barefeature; the stem form is unambiguous when a directory holds multiple contracts.- Dynamic plugin contracts are excluded — they are generated at runtime and ship no co-located template, so they can never be flattened. Only static manifest contracts are cataloged.
Note on association semantics (intentional)
Association is strictly by declared contract reference: a .jay-html is a variant of contract X
iff its script[type="application/jay-data"] carries contract="…X.jay-contract". This is stricter
than DL#200's old "any sibling .jay-html" filename rule. A headfull component whose template uses
inline application/jay-data (no contract= attr) — e.g. the smoke-test inner-block — is
correctly reported as template-less: such a template is not contract-bound and cannot be flattened
into a <jay:X> region. This is the desired behavior for the index, and consistent with
REGION-NOT-LINKED / COMPONENT-NO-TEMPLATE, which concern flattenable contract-referencing
templates.
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.