Add design system package quality guard (#2224)

* Add design system import manifest schema

* Generate hybrid design system imports

* Read design system usage and cached manifests

* Add design system pull-file tool

* Show design system package evidence

* Wire design system import semantics

* Add design system package quality guard

---------

Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local>
This commit is contained in:
chaoxiaoche 2026-05-19 16:53:29 +08:00 committed by GitHub
parent b311885bee
commit 6a08dfe111
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 4255 additions and 52 deletions

View file

@ -4,6 +4,7 @@ import { runDaemonCliStartup } from './daemon-startup.js';
import { runLiveArtifactsMcpServer } from './mcp-live-artifacts-server.js';
import { runArtifactsCli } from './artifacts-cli.js';
import { runConnectorsToolCli } from './tools-connectors-cli.js';
import { runDesignSystemsToolCli } from './tools-design-systems-cli.js';
import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js';
import { splitResearchSubcommand } from './research/cli-args.js';
import { resolveDaemonUrl } from './daemon-url.js';
@ -264,6 +265,16 @@ if (argv[0] === 'tools' && argv[1] === 'live-artifacts') {
process.stderr.write(`${JSON.stringify({ ok: false, error: { message } })}\n`);
process.exitCode = 1;
});
} else if (argv[0] === 'tools' && argv[1] === 'design-systems') {
runDesignSystemsToolCli(argv.slice(2))
.then(({ exitCode }) => {
process.exitCode = exitCode;
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${JSON.stringify({ ok: false, error: { message } })}\n`);
process.exitCode = 1;
});
} else {
await runDaemonCliStartup(argv, { printHelp: printRootHelp });
}
@ -282,6 +293,9 @@ function printRootHelp() {
od tools connectors <list|execute|github-design-context> [options]
Discover and execute configured connectors.
od tools design-systems read --path <manifest-declared-path>
Read active design-system pull-layer files through daemon wrapper commands.
od mcp live-artifacts
Start the MCP server exposing live-artifact and connector tools.

View file

@ -14,7 +14,7 @@ const execFileAsync = promisify(execFile);
export type GitHubDesignSystemImportOptions = Pick<
LocalDesignSystemImportOptions,
'name' | 'now' | 'reservedIds'
'craftApplies' | 'importMode' | 'name' | 'now' | 'reservedIds'
> & {
branch?: string;
gitBin?: string;
@ -63,6 +63,8 @@ export async function importGitHubDesignSystemProject(
fallbackName: parsed.repo,
...(options.name ? { name: options.name } : {}),
...(options.reservedIds ? { reservedIds: options.reservedIds } : {}),
...(options.importMode ? { importMode: options.importMode } : {}),
...(options.craftApplies ? { craftApplies: options.craftApplies } : {}),
source: {
type: 'github',
url: parsed.cloneUrl,

View file

@ -1,6 +1,8 @@
import { copyFile, mkdir, readFile, readdir, realpath, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { extractComponentsManifest } from '@open-design/contracts';
export type LocalDesignSystemImportResult = {
id: string;
dir: string;
@ -13,6 +15,8 @@ export type LocalDesignSystemImportOptions = {
fallbackName?: string;
reservedIds?: Iterable<string>;
source?: DesignSystemProjectSource;
importMode?: 'normalized' | 'hybrid' | 'verbatim';
craftApplies?: string[];
};
export type DesignSystemProjectSource =
@ -38,7 +42,9 @@ type ProjectScan = {
cssVariables: CssVariable[];
tailwindSignals: string[];
assets: AssetCandidate[];
fonts: FileCandidate[];
components: ComponentSignal[];
files: ProjectFile[];
};
type CssVariable = {
@ -53,9 +59,23 @@ type AssetCandidate = {
size: number;
};
type FileCandidate = {
absPath: string;
relPath: string;
size: number;
};
type ProjectFile = {
absPath: string;
relPath: string;
size: number;
};
type ComponentSignal = {
name: string;
relPath: string;
absPath: string;
size: number;
};
const IGNORED_DIRS = new Set([
@ -76,6 +96,7 @@ const IGNORED_DIRS = new Set([
const STYLE_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less']);
const COMPONENT_EXTENSIONS = new Set(['.tsx', '.jsx', '.vue', '.svelte']);
const ASSET_EXTENSIONS = new Set(['.svg', '.png', '.jpg', '.jpeg', '.webp', '.ico']);
const FONT_EXTENSIONS = new Set(['.woff', '.woff2', '.ttf', '.otf']);
const COMPONENT_NAMES = ['Button', 'Input', 'Card', 'Nav', 'Navbar', 'Sidebar'];
const TOKEN_FALLBACKS = {
bg: '#f8fafc',
@ -116,19 +137,44 @@ export async function importLocalDesignSystemProject(
const id = await nextAvailableSlug(userDesignSystemsRoot, slugify(displayName), options.reservedIds);
const outDir = path.join(userDesignSystemsRoot, id);
await mkdir(outDir, { recursive: true });
const importMode = normalizeImportMode(options.importMode);
const craftApplies = normalizeCraftList(options.craftApplies);
const files = ['DESIGN.md', 'tokens.css', 'components.html', 'manifest.json'];
await writeFile(path.join(outDir, 'DESIGN.md'), renderDesignMd(id, displayName, scan), 'utf8');
await writeFile(path.join(outDir, 'tokens.css'), renderTokensCss(scan), 'utf8');
await writeFile(path.join(outDir, 'components.html'), renderComponentsHtml(displayName), 'utf8');
const files = ['USAGE.md', 'DESIGN.md', 'tokens.css', 'components.html', 'components.manifest.json', 'manifest.json'];
const designMd = renderDesignMd(id, displayName, scan);
const tokensCss = renderTokensCss(scan);
const componentsHtml = renderComponentsHtml(displayName);
const componentsManifest = extractComponentsManifest({
brandId: id,
fixtureHtml: componentsHtml,
tokensCss,
});
await writeFile(path.join(outDir, 'USAGE.md'), renderUsageMd(displayName, scan), 'utf8');
await writeFile(path.join(outDir, 'DESIGN.md'), designMd, 'utf8');
await writeFile(path.join(outDir, 'tokens.css'), tokensCss, 'utf8');
await writeFile(path.join(outDir, 'components.html'), componentsHtml, 'utf8');
await writeFile(
path.join(outDir, 'components.manifest.json'),
`${JSON.stringify(componentsManifest, null, 2)}\n`,
'utf8',
);
files.push(...(await writePreviewFiles(outDir, displayName, scan)));
files.push(...(await writeSourceEvidenceFiles(outDir, scan)));
await writeFile(
path.join(outDir, 'manifest.json'),
`${JSON.stringify(renderManifest(id, displayName, scan, options.now ?? new Date(), options.source), null, 2)}\n`,
`${JSON.stringify(
renderManifest(id, displayName, scan, options.now ?? new Date(), options.source, importMode, craftApplies),
null,
2,
)}\n`,
'utf8',
);
const copiedAssets = await copyAssets(scan.assets, outDir);
const copiedFonts = await copyFonts(scan.fonts, outDir);
files.push(...copiedAssets);
files.push(...copiedFonts);
return { id, dir: outDir, files };
}
@ -163,7 +209,9 @@ async function scanProject(sourceRoot: string): Promise<ProjectScan> {
cssVariables,
tailwindSignals: await readTailwindSignals(sourceRoot),
assets: await findAssets(sourceRoot, files),
fonts: findFonts(sourceRoot, files),
components: findComponentSignals(files),
files,
};
}
@ -294,19 +342,30 @@ async function findAssets(
}));
}
function findComponentSignals(files: Array<{ absPath: string; relPath: string }>): ComponentSignal[] {
function findComponentSignals(files: ProjectFile[]): ComponentSignal[] {
const found = new Map<string, ComponentSignal>();
for (const file of files) {
if (!COMPONENT_EXTENSIONS.has(path.extname(file.relPath).toLowerCase())) continue;
const basename = path.basename(file.relPath).replace(/\.[^.]+$/, '');
const component = COMPONENT_NAMES.find((name) => basename.toLowerCase().includes(name.toLowerCase()));
if (component && !found.has(component)) {
found.set(component, { name: component, relPath: normalizeRel(file.relPath) });
found.set(component, { name: component, relPath: normalizeRel(file.relPath), absPath: file.absPath, size: file.size });
}
}
return Array.from(found.values()).slice(0, 10);
}
function findFonts(sourceRoot: string, files: ProjectFile[]): FileCandidate[] {
return files
.filter((file) => FONT_EXTENSIONS.has(path.extname(file.relPath).toLowerCase()) && file.size <= 2 * 1024 * 1024)
.slice(0, 8)
.map((file) => ({
absPath: file.absPath,
relPath: normalizeRel(path.relative(sourceRoot, file.absPath)),
size: file.size,
}));
}
async function copyAssets(assets: AssetCandidate[], outDir: string): Promise<string[]> {
if (assets.length === 0) return [];
const assetsDir = path.join(outDir, 'assets');
@ -320,6 +379,19 @@ async function copyAssets(assets: AssetCandidate[], outDir: string): Promise<str
return copied;
}
async function copyFonts(fonts: FileCandidate[], outDir: string): Promise<string[]> {
if (fonts.length === 0) return [];
const fontsDir = path.join(outDir, 'fonts');
await mkdir(fontsDir, { recursive: true });
const copied: string[] = [];
for (const font of fonts) {
const targetName = slugify(path.basename(font.relPath, path.extname(font.relPath))) + path.extname(font.relPath).toLowerCase();
await copyFile(font.absPath, path.join(fontsDir, targetName));
copied.push(`fonts/${targetName}`);
}
return copied;
}
async function nextAvailableSlug(
root: string,
preferred: string,
@ -346,6 +418,8 @@ function renderManifest(
scan: ProjectScan,
now: Date,
sourceOverride: DesignSystemProjectSource | undefined,
importMode: 'normalized' | 'hybrid' | 'verbatim',
craftApplies: string[],
) {
const importedAt = now.toISOString();
const source = sourceOverride ?? {
@ -368,10 +442,61 @@ function renderManifest(
tokens: 'tokens.css',
components: 'components.html',
},
usage: 'USAGE.md',
componentsManifest: 'components.manifest.json',
importMode,
craft: {
applies: craftApplies,
suggested: craftApplies.includes('color') ? [] : ['color'],
exemptions: [],
},
...(scan.assets.length > 0 ? { assetsDir: 'assets' } : {}),
...(scan.fonts.length > 0
? {
fonts: scan.fonts.map((font) => ({
family: cleanDisplayName(path.basename(font.relPath, path.extname(font.relPath))),
file: `fonts/${slugify(path.basename(font.relPath, path.extname(font.relPath)))}${path.extname(font.relPath).toLowerCase()}`,
})),
}
: {}),
preview: {
dir: 'preview',
pages: [
{ path: 'preview/colors.html', role: 'colors', title: 'Colors' },
{ path: 'preview/typography.html', role: 'typography', title: 'Typography' },
{ path: 'preview/spacing.html', role: 'spacing', title: 'Spacing' },
{ path: 'preview/components-buttons.html', role: 'buttons', title: 'Buttons' },
{ path: 'preview/components-inputs.html', role: 'inputs', title: 'Inputs' },
{ path: 'preview/app.html', role: 'app', title: 'App Preview' },
],
},
sourceFiles: {
scanned: 'source/scanned-files.json',
evidence: 'source/evidence.md',
tokens: 'source/tokens.source.json',
snippets: 'source/snippets/INDEX.json',
},
};
}
function normalizeImportMode(value: unknown): 'normalized' | 'hybrid' | 'verbatim' {
return value === 'normalized' || value === 'verbatim' || value === 'hybrid' ? value : 'hybrid';
}
function normalizeCraftList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const entry of value) {
if (typeof entry !== 'string') continue;
const slug = entry.trim().toLowerCase();
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) || seen.has(slug)) continue;
seen.add(slug);
out.push(slug);
}
return out;
}
function renderDesignMd(id: string, name: string, scan: ProjectScan): string {
const colors = tokenCandidates(scan.cssVariables, ['color', 'accent', 'primary', 'background', 'surface', 'border'])
.slice(0, 16)
@ -418,6 +543,46 @@ function renderDesignMd(id: string, name: string, scan: ProjectScan): string {
].join('\n');
}
function renderUsageMd(name: string, scan: ProjectScan): string {
const highlights = [
scan.packageDescription,
scan.packageTech.length > 0 ? `Detected stack: ${scan.packageTech.join(', ')}` : undefined,
scan.cssVariables.length > 0 ? `${scan.cssVariables.length} CSS custom properties found` : undefined,
scan.components.length > 0 ? `Representative source components: ${scan.components.map((component) => component.name).join(', ')}` : undefined,
].filter((line): line is string => line !== undefined);
return [
`# ${name} Usage`,
'',
'> Auto-generated by Open Design importer. Review and edit before treating it as a canonical brand guide.',
'',
'## Read Order',
'',
'1. Read `DESIGN.md` for product context and visual principles.',
'2. Paste `tokens.css` into the first `<style>` block of generated artifacts.',
'3. Use `components.manifest.json` for available component patterns.',
'4. Pull `preview/app.html` when layout fidelity matters.',
'5. Pull `source/snippets/*` only when verbatim source behavior matters.',
'',
'## Design Highlights',
'',
...(highlights.length > 0 ? highlights.map((line) => `- ${line}`) : ['- Imported web design system with generated OD tokens.']),
'',
'## Do',
'',
'- Preserve semantic roles from the source project before inventing new values.',
'- Use `tokens.css` as the normalized OD token contract.',
'- Check `source/tokens.source.json` when a source variable name matters.',
'',
'## Avoid',
'',
'- Do not paste source snippets blindly into production code.',
'- Do not treat fallback token values as high-confidence source evidence.',
'- Do not rename OD standard tokens in generated artifacts.',
'',
].join('\n');
}
function renderTokensCss(scan: ProjectScan): string {
const picked = pickDesignTokens(scan.cssVariables);
const cssVarLines = scan.cssVariables
@ -524,6 +689,228 @@ function renderComponentsHtml(name: string): string {
`;
}
async function writePreviewFiles(outDir: string, name: string, scan: ProjectScan): Promise<string[]> {
const previewDir = path.join(outDir, 'preview');
await mkdir(previewDir, { recursive: true });
const pages: Array<[string, string]> = [
['colors.html', renderColorsPreview(name, scan)],
['typography.html', renderTypographyPreview(name)],
['spacing.html', renderSpacingPreview(name)],
['components-buttons.html', renderButtonsPreview(name)],
['components-inputs.html', renderInputsPreview(name)],
['app.html', renderAppPreview(name, scan)],
];
await Promise.all(pages.map(([fileName, html]) => writeFile(path.join(previewDir, fileName), html, 'utf8')));
return pages.map(([fileName]) => `preview/${fileName}`);
}
async function writeSourceEvidenceFiles(outDir: string, scan: ProjectScan): Promise<string[]> {
const sourceDir = path.join(outDir, 'source');
const snippetsDir = path.join(sourceDir, 'snippets');
await mkdir(snippetsDir, { recursive: true });
const snippetEntries: Array<{
path: string;
role: string;
language: string;
sourcePath: string;
bytes: number;
reason: string;
}> = [];
const writtenSnippetFiles: string[] = [];
for (const component of scan.components.slice(0, 10)) {
const ext = path.extname(component.relPath);
const targetName = `${slugify(path.basename(component.relPath, ext))}${ext}`;
const targetRel = `source/snippets/${targetName}`;
await copyFile(component.absPath, path.join(outDir, targetRel));
snippetEntries.push({
path: targetRel,
role: component.name.toLowerCase(),
language: ext.replace(/^\./, '') || 'text',
sourcePath: component.relPath,
bytes: component.size,
reason: `Representative ${component.name} component detected by filename.`,
});
writtenSnippetFiles.push(targetRel);
}
await Promise.all([
writeFile(
path.join(sourceDir, 'scanned-files.json'),
`${JSON.stringify({
schemaVersion: 1,
sourceRoot: scan.sourceRoot,
files: scan.files.map((file) => ({
path: normalizeRel(file.relPath),
bytes: file.size,
kind: classifyScannedFile(file.relPath),
})),
}, null, 2)}\n`,
'utf8',
),
writeFile(path.join(sourceDir, 'evidence.md'), renderEvidenceMd(scan), 'utf8'),
writeFile(
path.join(sourceDir, 'tokens.source.json'),
`${JSON.stringify({
schemaVersion: 1,
strategy: ['css-vars', ...(scan.tailwindSignals.length > 0 ? ['tailwind-config'] : [])],
tokenCount: scan.cssVariables.length,
confidence: {
color: scan.cssVariables.some((token) => isColorValue(token.value)) ? 'high' : 'low',
type: scan.fonts.length > 0 ? 'medium' : 'low',
spacing: scan.tailwindSignals.includes('spacing') ? 'medium' : 'low',
},
tokens: scan.cssVariables.map((token) => ({
name: token.name,
value: token.value,
source: token.source,
normalizedRole: inferTokenRole(token),
})),
}, null, 2)}\n`,
'utf8',
),
writeFile(
path.join(snippetsDir, 'INDEX.json'),
`${JSON.stringify({ schemaVersion: 1, snippets: snippetEntries }, null, 2)}\n`,
'utf8',
),
]);
return [
'source/scanned-files.json',
'source/evidence.md',
'source/tokens.source.json',
'source/snippets/INDEX.json',
...writtenSnippetFiles,
];
}
function renderColorsPreview(name: string, scan: ProjectScan): string {
const colors = pickDesignTokens(scan.cssVariables);
return renderPreviewPage(
`${name} colors`,
'Color evidence',
([
['Background', '--bg', colors.bg],
['Surface', '--surface', colors.surface],
['Foreground', '--fg', colors.fg],
['Muted', '--muted', colors.muted],
['Border', '--border', colors.border],
['Accent', '--accent', colors.accent],
['Success', '--success', colors.success],
['Warning', '--warn', colors.warn],
['Danger', '--danger', colors.danger],
] satisfies Array<[string, string, string]>).map(([label, token, value]) => `<article class="swatch"><div style="background:${escapeHtml(value)}"></div><strong>${label}</strong><code>${token}: ${escapeHtml(value)}</code></article>`).join('\n'),
);
}
function renderTypographyPreview(name: string): string {
return renderPreviewPage(
`${name} typography`,
'Typography',
'<h1>Build focused product surfaces</h1><h2>Section heading</h2><p>Body text uses the imported body token stack and normalized rhythm.</p><code>Code and metadata use the mono token.</code>',
);
}
function renderSpacingPreview(name: string): string {
return renderPreviewPage(
`${name} spacing`,
'Spacing and radius',
[1, 2, 3, 4, 5, 6, 8].map((step) => `<article class="meter"><strong>--space-${step}</strong><span style="width:var(--space-${step})"></span></article>`).join('\n'),
);
}
function renderButtonsPreview(name: string): string {
return renderPreviewPage(
`${name} buttons`,
'Buttons',
'<p><button class="primary">Primary action</button> <button class="secondary">Secondary action</button></p>',
);
}
function renderInputsPreview(name: string): string {
return renderPreviewPage(
`${name} inputs`,
'Inputs',
'<label>Project name<input value="Imported design system" /></label><label>Notes<textarea>Preserve source evidence.</textarea></label>',
);
}
function renderAppPreview(name: string, scan: ProjectScan): string {
const componentItems = scan.components.map((component) => `<li>${escapeHtml(component.name)} <code>${escapeHtml(component.relPath)}</code></li>`).join('');
return renderPreviewPage(
`${name} app preview`,
'App preview',
`<section class="app-shell"><aside><strong>${escapeHtml(name)}</strong><nav>Overview<br/>Components<br/>Assets</nav></aside><main><h1>${escapeHtml(name)}</h1><p>${escapeHtml(scan.packageDescription ?? 'Imported design system preview.')}</p><ul>${componentItems || '<li>No representative components detected.</li>'}</ul></main></section>`,
);
}
function renderPreviewPage(title: string, heading: string, body: string): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(title)}</title>
<link rel="stylesheet" href="../tokens.css" />
<style>
body { margin: 0; font-family: var(--font-sans); color: var(--fg); background: var(--bg); }
main { max-width: 1040px; margin: 0 auto; padding: var(--space-8) var(--space-5); }
h1 { font-size: var(--text-3xl); line-height: var(--leading-tight); }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-4); }
.swatch, .meter, label { display: grid; gap: var(--space-2); padding: var(--space-4); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); }
.swatch div { height: 96px; border-radius: var(--radius-sm); border: 1px solid var(--border-soft); }
.meter span { display: block; height: 20px; background: var(--accent); border-radius: var(--radius-sm); }
button { border: 1px solid transparent; border-radius: var(--radius-md); padding: .7rem 1rem; font: inherit; }
.primary { background: var(--accent); color: var(--accent-on); }
.secondary { background: var(--surface-warm); border-color: var(--border); color: var(--fg); }
input, textarea { border: 1px solid var(--border); border-radius: var(--radius-md); padding: .75rem; font: inherit; color: var(--fg); background: var(--surface); }
.app-shell { display: grid; grid-template-columns: 240px 1fr; min-height: 420px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
aside { padding: var(--space-5); background: var(--surface-warm); border-right: 1px solid var(--border); }
aside nav { margin-top: var(--space-5); color: var(--muted); line-height: 2; }
.app-shell main { margin: 0; padding: var(--space-6); }
</style>
</head>
<body>
<main>
<p>Generated preview</p>
<h1>${escapeHtml(heading)}</h1>
<section class="grid">${body}</section>
</main>
</body>
</html>
`;
}
function renderEvidenceMd(scan: ProjectScan): string {
return [
'# Import Evidence',
'',
`- Source root: \`${scan.sourceRoot}\``,
`- Package: ${scan.packageName === undefined ? 'not declared' : `\`${scan.packageName}\``}`,
`- Description: ${scan.packageDescription ?? 'not declared'}`,
`- CSS variables: ${scan.cssVariables.length}`,
`- Tailwind signals: ${scan.tailwindSignals.length > 0 ? scan.tailwindSignals.join(', ') : 'none detected'}`,
`- Assets copied: ${scan.assets.length}`,
`- Fonts copied: ${scan.fonts.length}`,
`- Representative snippets: ${scan.components.length}`,
'',
'## Representative Components',
'',
scan.components.length > 0
? scan.components.map((component) => `- ${component.name}: \`${component.relPath}\``).join('\n')
: '- None detected.',
'',
'## Token Evidence',
'',
scan.cssVariables.length > 0
? scan.cssVariables.slice(0, 40).map((token) => `- \`${token.name}: ${token.value}\` from \`${token.source}\``).join('\n')
: '- No CSS custom properties detected; normalized tokens use fallback values.',
'',
].join('\n');
}
function pickDesignTokens(tokens: CssVariable[]): typeof TOKEN_FALLBACKS {
const valueFor = (needles: string[], fallback: string, validator: (value: string) => boolean = Boolean) =>
tokenCandidates(tokens, needles).find((token) => validator(token.value))?.value ?? fallback;
@ -553,6 +940,30 @@ function isColorValue(value: string): boolean {
return /^(#(?:[0-9a-f]{3,8})|rgb[a]?\(|hsl[a]?\(|oklch\(|color-mix\(|var\()/i.test(value.trim());
}
function classifyScannedFile(relPath: string): string {
const ext = path.extname(relPath).toLowerCase();
if (STYLE_EXTENSIONS.has(ext)) return 'style';
if (COMPONENT_EXTENSIONS.has(ext)) return 'component';
if (ASSET_EXTENSIONS.has(ext)) return 'asset';
if (FONT_EXTENSIONS.has(ext)) return 'font';
if (path.basename(relPath).toLowerCase().includes('readme')) return 'readme';
if (path.basename(relPath) === 'package.json') return 'package';
return 'other';
}
function inferTokenRole(token: CssVariable): string | undefined {
const name = token.name.toLowerCase();
if (['background', 'bg'].some((needle) => name.includes(needle))) return 'background';
if (['surface', 'card'].some((needle) => name.includes(needle))) return 'surface';
if (['foreground', 'text', 'fg'].some((needle) => name.includes(needle))) return 'foreground';
if (name.includes('border')) return 'border';
if (['accent', 'primary', 'brand'].some((needle) => name.includes(needle))) return 'accent';
if (name.includes('radius')) return 'radius';
if (name.includes('font')) return 'font';
if (name.includes('space') || name.includes('gap')) return 'spacing';
return undefined;
}
function compactMarkdown(raw: string): string {
return raw
.replace(/```[\s\S]*?```/g, '')

View file

@ -0,0 +1,104 @@
import type { Express, Request, Response } from 'express';
import type { ToolTokenGrant } from './tool-tokens.js';
import { readDesignSystemPullFile } from './design-systems.js';
type ProjectRecord = {
id: string;
designSystemId?: string | null;
};
type SendApiError = (
res: Response,
status: number,
code: string,
message: string,
extras?: Record<string, unknown>,
) => void;
export type RegisterDesignSystemToolRoutesDeps = {
auth: {
authorizeToolRequest: (req: Request, res: Response, operation: string) => ToolTokenGrant | null;
};
http: {
sendApiError: SendApiError;
};
paths: {
DESIGN_SYSTEMS_DIR: string;
USER_DESIGN_SYSTEMS_DIR: string;
};
projects: {
getProject: (id: string) => ProjectRecord | null | undefined;
};
};
export function registerDesignSystemToolRoutes(
app: Express,
ctx: RegisterDesignSystemToolRoutesDeps,
): void {
const { authorizeToolRequest } = ctx.auth;
const { sendApiError } = ctx.http;
app.post('/api/tools/design-systems/read', async (req, res) => {
try {
const grant = authorizeToolRequest(req, res, 'design-systems:read');
if (!grant) return;
const project = ctx.projects.getProject(grant.projectId);
const activeDesignSystemId = project?.designSystemId;
if (!activeDesignSystemId) {
return sendApiError(res, 404, 'DESIGN_SYSTEM_NOT_FOUND', 'project has no active design system');
}
const requestedDesignSystemId = typeof req.body?.designSystemId === 'string'
? req.body.designSystemId
: undefined;
if (requestedDesignSystemId !== undefined && requestedDesignSystemId !== activeDesignSystemId) {
return sendApiError(res, 403, 'DESIGN_SYSTEM_DENIED', 'designSystemId is derived from the tool token project', {
details: { requestedDesignSystemId, activeDesignSystemId },
});
}
const requestedPath = typeof req.body?.path === 'string' ? req.body.path : '';
if (!requestedPath) {
return sendApiError(res, 400, 'INVALID_INPUT', 'path is required');
}
const file = await readActiveDesignSystemPullFile(
ctx.paths.DESIGN_SYSTEMS_DIR,
ctx.paths.USER_DESIGN_SYSTEMS_DIR,
activeDesignSystemId,
requestedPath,
);
if (!file) {
return sendApiError(
res,
404,
'DESIGN_SYSTEM_FILE_NOT_FOUND',
'design system file was not found or is not declared in manifest.json',
{ details: { path: requestedPath } },
);
}
res.json({ file });
} catch (error) {
sendApiError(res, 500, 'INTERNAL_ERROR', error instanceof Error ? error.message : String(error));
}
});
}
async function readActiveDesignSystemPullFile(
builtInRoot: string,
userRoot: string,
designSystemId: string,
relativePath: string,
) {
if (designSystemId.startsWith('user:')) {
return readDesignSystemPullFile(userRoot, designSystemId, relativePath);
}
return (
(await readDesignSystemPullFile(builtInRoot, designSystemId, relativePath))
?? (await readDesignSystemPullFile(userRoot, designSystemId, relativePath))
);
}

View file

@ -14,6 +14,7 @@ import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import path from 'node:path';
import {
type ComponentsManifest,
extractComponentsManifest,
summarizeComponentsManifestForPrompt,
} from '@open-design/contracts';
@ -65,6 +66,27 @@ export type DesignSystemFileDetail = DesignSystemFileSummary & {
content: string;
};
export type DesignSystemPullFileDetail = {
path: string;
name: string;
kind: DesignSystemFileKind;
size: number;
updatedAt: string;
encoding: 'utf8' | 'base64';
content: string;
};
export type DesignSystemPackageInfo = {
manifest?: DesignSystemProjectManifest;
sourceEvidence?: {
scannedFileCount?: number;
tokenCount?: number;
snippetCount?: number;
confidence?: Record<string, string | number>;
evidenceExcerpt?: string;
};
};
export type DesignSystemRevision = {
id: string;
designSystemId: string;
@ -91,6 +113,36 @@ type DesignSystemProjectManifest = {
tokens: 'tokens.css';
components?: 'components.html';
};
assetsDir?: 'assets';
previewDir?: 'preview';
usage?: string;
componentsManifest?: string;
fonts?: Array<{
family: string;
file: string;
weight?: number | string;
style?: string;
}>;
preview?: {
dir: string;
pages: Array<{
path: string;
role?: string;
title?: string;
}>;
};
sourceFiles?: {
scanned?: string;
evidence?: string;
tokens?: string;
snippets?: string;
};
importMode?: 'normalized' | 'hybrid' | 'verbatim';
craft?: {
applies?: string[];
suggested?: string[];
exemptions?: string[];
};
};
export type DesignSystemProvenance = {
@ -297,6 +349,24 @@ export async function readDesignSystem(
}
}
export async function readDesignSystemPackageInfo(
root: string,
id: string,
options: { idPrefix?: string } = {},
): Promise<DesignSystemPackageInfo | null> {
const dirId = stripPrefixAndValidateId(id, options.idPrefix);
if (!dirId) return null;
const brandRoot = path.join(root, dirId);
const manifest = await readProjectManifest(brandRoot, dirId);
if (manifest === null) return null;
const sourceEvidence = await readDesignSystemSourceEvidence(brandRoot, manifest);
return {
manifest,
...(sourceEvidence ? { sourceEvidence } : {}),
};
}
/**
* Structured (compiled) form of a brand's design system. Optional sibling
* files alongside DESIGN.md that, when present, give agents a
@ -307,15 +377,25 @@ export async function readDesignSystem(
* hand-authored or derived tokens.
*
* - `tokensCss` verbatim content of `<brand>/tokens.css`.
* - `usageMd` optional agent-facing router for the package.
* - `fixtureHtml` verbatim content of `<brand>/components.html`.
* - `componentsManifest` concise summary derived from components.html
* or read from components.manifest.json cache
* for prompt injection; when absent, callers
* can fall back to `fixtureHtml`.
* - `pullIndex` short manifest-derived file index. It lists
* richer preview/source evidence paths without
* loading those files into the push prompt.
*/
export type DesignSystemAssets = {
usageMd?: string | undefined;
tokensCss?: string | undefined;
fixtureHtml?: string | undefined;
componentsManifest?: string | undefined;
pullIndex?: string | undefined;
importMode?: 'normalized' | 'hybrid' | 'verbatim' | undefined;
craftApplies?: string[] | undefined;
craftExemptions?: string[] | undefined;
};
export async function readDesignSystemAssets(
@ -326,13 +406,66 @@ export async function readDesignSystemAssets(
if (!dirId) return {};
const brandRoot = path.join(root, dirId);
const manifest = await readProjectManifest(brandRoot, dirId);
const [tokensCss, fixtureHtml] = await Promise.all([
const [usageMd, tokensCss, fixtureHtml, componentsManifestJson] = await Promise.all([
readManifestFileOptional(brandRoot, manifest?.usage ?? 'USAGE.md'),
readFileOptional(path.join(brandRoot, manifest?.files.tokens ?? 'tokens.css')),
manifest?.files.components === undefined && manifest !== null
? Promise.resolve(undefined)
: readFileOptional(path.join(brandRoot, manifest?.files.components ?? 'components.html')),
readManifestFileOptional(brandRoot, manifest?.componentsManifest ?? 'components.manifest.json'),
]);
return withComponentsManifest(id, { tokensCss, fixtureHtml });
return withComponentsManifest(id, {
usageMd,
tokensCss,
fixtureHtml,
componentsManifestJson,
pullIndex: buildDesignSystemPullIndex(manifest),
importMode: manifest?.importMode,
craftApplies: manifest?.craft?.applies,
craftExemptions: manifest?.craft?.exemptions,
});
}
export async function readDesignSystemPullFile(
root: string,
id: string,
relativePath: string,
): Promise<DesignSystemPullFileDetail | null> {
const dirId = stripPrefixAndValidateId(id, id.startsWith('user:') ? 'user:' : '');
const cleanPath = sanitizeRelativeFilePath(relativePath);
if (!dirId || !cleanPath) return null;
const brandRoot = path.join(root, dirId);
const manifest = await readProjectManifest(brandRoot, dirId);
if (manifest === null) return null;
const allowed = await buildDesignSystemPullFileAllowlist(brandRoot, manifest);
if (!allowed.has(cleanPath)) return null;
const resolvedRoot = path.resolve(brandRoot);
const filePath = path.resolve(brandRoot, cleanPath);
if (filePath !== resolvedRoot && !filePath.startsWith(`${resolvedRoot}${path.sep}`)) {
return null;
}
try {
const stats = await stat(filePath);
if (!stats.isFile()) return null;
const bytes = await readFile(filePath);
const encoding = isTextDesignSystemPullFile(cleanPath) ? 'utf8' : 'base64';
return {
path: cleanPath,
name: path.basename(cleanPath),
kind: classifyDesignSystemFile(cleanPath, false),
size: stats.size,
updatedAt: stats.mtime.toISOString(),
encoding,
content: encoding === 'utf8' ? bytes.toString('utf8') : bytes.toString('base64'),
};
} catch (err) {
if (isAbsenceError(err)) return null;
throw err;
}
}
export function isDesignTokenChannelEnabled(
@ -348,7 +481,16 @@ export async function resolveDesignSystemAssets(
env: NodeJS.ProcessEnv = process.env,
): Promise<DesignSystemAssets> {
if (!isDesignTokenChannelEnabled(env)) {
return { tokensCss: undefined, fixtureHtml: undefined };
return {
usageMd: undefined,
tokensCss: undefined,
fixtureHtml: undefined,
componentsManifest: undefined,
pullIndex: undefined,
importMode: undefined,
craftApplies: undefined,
craftExemptions: undefined,
};
}
const builtIn = await readDesignSystemAssets(builtInRoot, designSystemId);
@ -358,21 +500,53 @@ export async function resolveDesignSystemAssets(
const userInstalled = await readDesignSystemAssets(userInstalledRoot, designSystemId);
return withComponentsManifest(designSystemId, {
usageMd: builtIn.usageMd ?? userInstalled.usageMd,
tokensCss: builtIn.tokensCss ?? userInstalled.tokensCss,
fixtureHtml: builtIn.fixtureHtml ?? userInstalled.fixtureHtml,
componentsManifestJson: undefined,
componentsManifest: builtIn.componentsManifest ?? userInstalled.componentsManifest,
pullIndex: builtIn.pullIndex ?? userInstalled.pullIndex,
importMode: builtIn.importMode ?? userInstalled.importMode,
craftApplies: builtIn.craftApplies ?? userInstalled.craftApplies,
craftExemptions: builtIn.craftExemptions ?? userInstalled.craftExemptions,
});
}
function withComponentsManifest(
designSystemId: string,
assets: Pick<DesignSystemAssets, 'tokensCss' | 'fixtureHtml'>,
assets: Pick<
DesignSystemAssets,
| 'usageMd'
| 'tokensCss'
| 'fixtureHtml'
| 'componentsManifest'
| 'pullIndex'
| 'importMode'
| 'craftApplies'
| 'craftExemptions'
> & {
componentsManifestJson?: string | undefined;
},
): DesignSystemAssets {
const componentsManifest = buildComponentsManifestSummary(
designSystemId,
assets.fixtureHtml,
assets.tokensCss,
);
return { ...assets, componentsManifest };
const { componentsManifestJson, ...publicAssets } = assets;
const componentsManifest =
publicAssets.componentsManifest
?? summarizeComponentsManifestCache(componentsManifestJson)
?? buildComponentsManifestSummary(
designSystemId,
publicAssets.fixtureHtml,
publicAssets.tokensCss,
);
return { ...publicAssets, componentsManifest };
}
function summarizeComponentsManifestCache(raw: string | undefined): string | undefined {
if (raw === undefined || raw.trim().length === 0) return undefined;
try {
return summarizeComponentsManifestForPrompt(JSON.parse(raw) as ComponentsManifest);
} catch {
return undefined;
}
}
function buildComponentsManifestSummary(
@ -395,6 +569,196 @@ function buildComponentsManifestSummary(
}
}
function buildDesignSystemPullIndex(
manifest: DesignSystemProjectManifest | null,
): string | undefined {
if (manifest === null) return undefined;
const entries: string[] = [];
const add = (filePath: string | undefined, label: string): void => {
if (!filePath || !isSafeManifestPath(filePath)) return;
entries.push(`- ${filePath}: ${label}`);
};
if (manifest.preview?.pages) {
for (const page of manifest.preview.pages) {
if (!isSafeManifestPath(page.path)) continue;
const labelParts = [page.title, page.role].filter((part) => typeof part === 'string' && part.trim().length > 0);
entries.push(`- ${page.path}: ${labelParts.join('; ') || 'preview page'}`);
}
} else if (manifest.previewDir === 'preview') {
entries.push('- preview/: preview pages');
}
if (manifest.assetsDir === 'assets') entries.push('- assets/: brand assets');
for (const font of manifest.fonts ?? []) {
add(font.file, `font: ${font.family}${font.weight ? ` ${font.weight}` : ''}${font.style ? ` ${font.style}` : ''}`);
}
add(manifest.sourceFiles?.scanned, 'scanned source file inventory');
add(manifest.sourceFiles?.evidence, 'import evidence notes');
add(manifest.sourceFiles?.tokens, 'source-token evidence');
add(manifest.sourceFiles?.snippets, 'source snippet index');
if (entries.length === 0) return undefined;
return ['Additional design-system files declared by manifest.json:', ...entries].join('\n');
}
async function buildDesignSystemPullFileAllowlist(
brandRoot: string,
manifest: DesignSystemProjectManifest,
): Promise<Set<string>> {
const allowed = new Set<string>();
const add = (filePath: string | undefined): void => {
const cleanPath = typeof filePath === 'string' ? sanitizeRelativeFilePath(filePath) : null;
if (cleanPath) allowed.add(cleanPath);
};
for (const page of manifest.preview?.pages ?? []) add(page.path);
add(manifest.sourceFiles?.scanned);
add(manifest.sourceFiles?.evidence);
add(manifest.sourceFiles?.tokens);
add(manifest.sourceFiles?.snippets);
if (manifest.assetsDir === 'assets') {
await addFilesUnderDeclaredDir(brandRoot, 'assets', allowed);
}
if (manifest.sourceFiles?.snippets) {
await addSnippetIndexEntries(brandRoot, manifest.sourceFiles.snippets, allowed);
}
return allowed;
}
async function addFilesUnderDeclaredDir(
brandRoot: string,
dir: string,
allowed: Set<string>,
): Promise<void> {
if (!isSafeManifestPath(dir)) return;
const absoluteDir = path.join(brandRoot, dir);
let entries;
try {
entries = await readdir(absoluteDir, { withFileTypes: true });
} catch (err) {
if (isAbsenceError(err)) return;
throw err;
}
await Promise.all(entries.map(async (entry) => {
const relativePath = `${dir}/${entry.name}`;
if (!isSafeManifestPath(relativePath)) return;
if (entry.isDirectory()) {
await addFilesUnderDeclaredDir(brandRoot, relativePath, allowed);
} else if (entry.isFile()) {
allowed.add(relativePath);
}
}));
}
async function addSnippetIndexEntries(
brandRoot: string,
indexPath: string,
allowed: Set<string>,
): Promise<void> {
if (!isSafeManifestPath(indexPath)) return;
let raw: string | undefined;
try {
raw = await readFileOptional(path.join(brandRoot, indexPath));
} catch {
return;
}
if (raw === undefined) return;
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return;
const snippets = (parsed as { snippets?: unknown }).snippets;
if (!Array.isArray(snippets)) return;
for (const snippet of snippets) {
if (!snippet || typeof snippet !== 'object' || Array.isArray(snippet)) continue;
const snippetPath = (snippet as { path?: unknown }).path;
if (typeof snippetPath === 'string') {
const cleanPath = sanitizeRelativeFilePath(snippetPath);
if (cleanPath?.startsWith('source/snippets/')) allowed.add(cleanPath);
}
}
} catch {
// A malformed snippets index should not widen the allowlist.
}
}
function isTextDesignSystemPullFile(relativePath: string): boolean {
const ext = path.extname(relativePath).toLowerCase();
return new Set([
'.css',
'.html',
'.js',
'.jsx',
'.json',
'.md',
'.mjs',
'.svg',
'.ts',
'.tsx',
'.txt',
'.xml',
'.yaml',
'.yml',
]).has(ext);
}
async function readDesignSystemSourceEvidence(
brandRoot: string,
manifest: DesignSystemProjectManifest,
): Promise<DesignSystemPackageInfo['sourceEvidence'] | undefined> {
const [scanned, tokens, snippets, evidence] = await Promise.all([
readManifestJsonOptional(brandRoot, manifest.sourceFiles?.scanned),
readManifestJsonOptional(brandRoot, manifest.sourceFiles?.tokens),
readManifestJsonOptional(brandRoot, manifest.sourceFiles?.snippets),
readManifestFileOptional(brandRoot, manifest.sourceFiles?.evidence ?? ''),
]);
const out: NonNullable<DesignSystemPackageInfo['sourceEvidence']> = {};
if (scanned && typeof scanned === 'object' && !Array.isArray(scanned)) {
const files = (scanned as { files?: unknown }).files;
if (Array.isArray(files)) out.scannedFileCount = files.length;
}
if (tokens && typeof tokens === 'object' && !Array.isArray(tokens)) {
const tokenCount = (tokens as { tokenCount?: unknown }).tokenCount;
if (typeof tokenCount === 'number') out.tokenCount = tokenCount;
const confidence = (tokens as { confidence?: unknown }).confidence;
if (confidence && typeof confidence === 'object' && !Array.isArray(confidence)) {
const cleanConfidence: Record<string, string | number> = {};
for (const [key, value] of Object.entries(confidence)) {
if (typeof value === 'string' || typeof value === 'number') cleanConfidence[key] = value;
}
if (Object.keys(cleanConfidence).length > 0) out.confidence = cleanConfidence;
}
}
if (snippets && typeof snippets === 'object' && !Array.isArray(snippets)) {
const entries = (snippets as { snippets?: unknown }).snippets;
if (Array.isArray(entries)) out.snippetCount = entries.length;
}
if (typeof evidence === 'string' && evidence.trim().length > 0) {
out.evidenceExcerpt = evidence.trim().split(/\r?\n/).filter(Boolean).slice(0, 5).join('\n');
}
return Object.keys(out).length > 0 ? out : undefined;
}
async function readManifestJsonOptional(
brandRoot: string,
relativePath: string | undefined,
): Promise<unknown | undefined> {
if (!relativePath) return undefined;
const raw = await readManifestFileOptional(brandRoot, relativePath);
if (raw === undefined) return undefined;
try {
return JSON.parse(raw) as unknown;
} catch {
return undefined;
}
}
export async function createUserDesignSystem(
root: string,
input: UserDesignSystemInput,
@ -2305,6 +2669,21 @@ async function readFileOptional(file: string): Promise<string | undefined> {
}
}
async function readManifestFileOptional(
brandRoot: string,
relativePath: string,
): Promise<string | undefined> {
if (!isSafeManifestPath(relativePath)) return undefined;
return readFileOptional(path.join(brandRoot, relativePath));
}
function isSafeManifestPath(relativePath: string): boolean {
if (relativePath.trim().length === 0) return false;
if (path.isAbsolute(relativePath)) return false;
const parts = relativePath.split(/[\\/]+/);
return parts.every((part) => part.length > 0 && part !== '.' && part !== '..');
}
function isAbsenceError(err: unknown): boolean {
if (typeof err !== 'object' || err === null) return false;
const code = (err as { code?: unknown }).code;

View file

@ -171,6 +171,23 @@ Active design system exception: the active design system is the visual direction
- When a downstream framework mentions "active direction" or "theme tokens", bind those fields from the active design system instead of the built-in direction library.
`;
const DEFAULT_DESIGN_SYSTEM_USAGE = `Read DESIGN.md for visual principles, paste tokens.css verbatim into the first <style> when it is provided, and match component shapes from the reference component manifest or fixture when available. Treat any pull-layer index as optional context for deeper inspection; do not assume those files have already been loaded.`;
function renderDesignSystemImportModeGuidance(
importMode: ComposeInput['designSystemImportMode'],
): string | undefined {
if (importMode === 'normalized') {
return 'This package is normalized. Treat tokens.css and DESIGN.md as the contract, and prefer OD token names over source-project names. Use pull-layer source evidence only as optional background.';
}
if (importMode === 'hybrid') {
return 'This package is hybrid. Build with OD-normalized tokens first, then inspect pull-layer source evidence or snippets only when original component behavior, density, or naming would materially improve fidelity.';
}
if (importMode === 'verbatim') {
return 'This package is verbatim-oriented. Preserve source semantics and source naming as much as possible. Before translating component behavior, inspect the relevant pull-layer source evidence or snippets when the runtime tool is available.';
}
return undefined;
}
export interface ComposeInput {
agentId?: string | null | undefined;
includeCodexImagegenOverride?: boolean | undefined;
@ -197,6 +214,8 @@ export interface ComposeInput {
// prose still sets the high-level voice and the structured form
// disambiguates token names + worked component shapes.
//
// - `designSystemUsageMd` — optional USAGE.md router that tells
// agents how to consume this package.
// - `designSystemTokensCss` — verbatim `tokens.css` :root contract
// that the agent pastes into the
// artifact's <style>.
@ -205,9 +224,15 @@ export interface ComposeInput {
// - `designSystemFixtureHtml` — verbatim `components.html`
// fallback when no manifest can
// be derived.
// - `designSystemPullIndex` — lightweight manifest-derived
// list of richer files available
// for later pull-channel work.
designSystemUsageMd?: string | undefined;
designSystemTokensCss?: string | undefined;
designSystemComponentsManifest?: string | undefined;
designSystemFixtureHtml?: string | undefined;
designSystemPullIndex?: string | undefined;
designSystemImportMode?: 'normalized' | 'hybrid' | 'verbatim' | undefined;
// Craft references the active skill opted into via `od.craft.requires`.
// The daemon resolves the slug list to file contents and concatenates
// them with section headers; we inject them between the DESIGN.md and
@ -288,9 +313,12 @@ export function composeSystemPrompt({
skillMode,
designSystemBody,
designSystemTitle,
designSystemUsageMd,
designSystemTokensCss,
designSystemComponentsManifest,
designSystemFixtureHtml,
designSystemPullIndex,
designSystemImportMode,
craftBody,
craftSections,
memoryBody,
@ -358,9 +386,24 @@ export function composeSystemPrompt({
}
if (activeDesignSystemBody && activeDesignSystemBody.length > 0) {
const usageBlock =
designSystemUsageMd && designSystemUsageMd.trim().length > 0
? designSystemUsageMd.trim()
: DEFAULT_DESIGN_SYSTEM_USAGE;
parts.push(
`\n\n## How to use this design system${designSystemTitle ? `${designSystemTitle}` : ''}\n\n${usageBlock}`,
);
parts.push(
`\n\n## Active design system${designSystemTitle ? `${designSystemTitle}` : ''}\n\nTreat the following DESIGN.md as authoritative for color, typography, spacing, and component rules. Do not invent tokens outside this palette. When you copy the active skill's seed template, bind these tokens into its \`:root\` block before generating any layout.\n\n${activeDesignSystemBody}`,
);
const importModeGuidance = renderDesignSystemImportModeGuidance(designSystemImportMode);
if (importModeGuidance) {
parts.push(
`\n\n## Design system import mode${designSystemTitle ? `${designSystemTitle}` : ''}\n\n${importModeGuidance}`,
);
}
}
// Structured (compiled) form of the active brand. The DESIGN.md above
@ -389,6 +432,12 @@ export function composeSystemPrompt({
);
}
if (designSystemPullIndex && designSystemPullIndex.trim().length > 0) {
parts.push(
`\n\n## Pull-layer files available on demand${designSystemTitle ? `${designSystemTitle}` : ''}\n\nThis design-system package declares richer files for inspection, source evidence, or human preview. Keep the push prompt light: use the index below to decide what to read later. When the runtime tool environment is available, read a listed path with \`\"$OD_NODE_BIN\" \"$OD_BIN\" tools design-systems read --path <path>\`; the daemon will reject paths outside this manifest allowlist.\n\n\`\`\`text\n${designSystemPullIndex.trim()}\n\`\`\``,
);
}
if (craftBody && craftBody.trim().length > 0) {
const sectionLabel =
Array.isArray(craftSections) && craftSections.length > 0

View file

@ -71,6 +71,7 @@ import {
listUserDesignSystemFiles,
listUserDesignSystemRevisions,
readDesignSystem,
readDesignSystemPackageInfo,
readUserDesignSystemFile,
resolveDesignSystemAssets,
updateUserDesignSystem,
@ -346,6 +347,7 @@ import { registerActiveContextRoutes } from './active-context-routes.js';
import { registerMcpRoutes } from './mcp-routes.js';
import { registerXaiRoutes } from './xai-routes.js';
import { registerLiveArtifactRoutes } from './live-artifact-routes.js';
import { registerDesignSystemToolRoutes } from './design-system-tool-routes.js';
import { registerDeployRoutes, registerDeploymentCheckRoutes } from './deploy-routes.js';
import { registerMediaRoutes } from './media-routes.js';
import { registerProjectRoutes, registerProjectArtifactRoutes, registerProjectFileRoutes, registerProjectUploadRoutes } from './project-routes.js';
@ -2790,6 +2792,16 @@ export async function startServer({
);
}
async function readAvailableDesignSystemPackageInfo(id) {
if (typeof id === 'string' && id.startsWith('user:')) {
return readDesignSystemPackageInfo(USER_DESIGN_SYSTEMS_DIR, id, { idPrefix: 'user:' });
}
return (
(await readDesignSystemPackageInfo(DESIGN_SYSTEMS_DIR, id))
?? (await readDesignSystemPackageInfo(USER_DESIGN_SYSTEMS_DIR, id))
);
}
function isProjectUsableDesignSystem(summary) {
return summary?.status !== 'draft';
}
@ -4199,6 +4211,12 @@ export async function startServer({
liveArtifacts: liveArtifactDeps,
projectStore: projectStoreDeps,
});
registerDesignSystemToolRoutes(app, {
auth: authDeps,
http: httpDeps,
paths: pathDeps,
projects: { getProject },
});
app.use('/artifacts', express.static(ARTIFACTS_DIR));
registerDeployRoutes(app, {
db,
@ -4769,7 +4787,8 @@ export async function startServer({
const body = projectBody ?? await readAvailableDesignSystem(req.params.id);
if (body === null || !summary)
return res.status(404).json({ error: 'design system not found' });
const detail = { ...summary, body };
const packageInfo = await readAvailableDesignSystemPackageInfo(req.params.id);
const detail = { ...summary, body, ...(packageInfo ? { packageInfo } : {}) };
res.json({ ...detail, designSystem: detail });
} catch (err) {
res.status(500).json({ error: String(err) });
@ -8317,13 +8336,6 @@ export async function startServer({
let craftBody;
let craftSections;
if (skillCraftRequires.length > 0) {
const loaded = await loadCraftSections(CRAFT_DIR, skillCraftRequires);
if (loaded.body) {
craftBody = loaded.body;
craftSections = loaded.sections;
}
}
// Personal-memory body is always recomputed at compose time so a
// memory the user just edited in settings shows up on the very next
@ -8363,9 +8375,14 @@ export async function startServer({
// including the structured ones. Any other value (unset, `1`,
// `true`, etc.) keeps the new default. Drift on prose-only brands
// is pinned by `scripts/check-design-system-flag-parity.ts`.
let designSystemUsageMd;
let designSystemTokensCss;
let designSystemComponentsManifest;
let designSystemFixtureHtml;
let designSystemPullIndex;
let designSystemImportMode;
let designSystemCraftApplies = [];
let designSystemCraftExemptions = [];
if (effectiveDesignSystemId) {
let systems = await listAllDesignSystems();
let summary = systems.find((s) => s.id === effectiveDesignSystemId);
@ -8391,9 +8408,26 @@ export async function startServer({
DESIGN_SYSTEMS_DIR,
USER_DESIGN_SYSTEMS_DIR,
);
designSystemUsageMd = assets.usageMd;
designSystemTokensCss = assets.tokensCss;
designSystemComponentsManifest = assets.componentsManifest;
designSystemFixtureHtml = assets.fixtureHtml;
designSystemPullIndex = assets.pullIndex;
designSystemImportMode = assets.importMode;
designSystemCraftApplies = Array.isArray(assets.craftApplies) ? assets.craftApplies : [];
designSystemCraftExemptions = Array.isArray(assets.craftExemptions) ? assets.craftExemptions : [];
}
}
const excludedCraft = new Set(designSystemCraftExemptions);
const requestedCraft = Array.from(
new Set([...skillCraftRequires, ...designSystemCraftApplies]),
).filter((slug) => !excludedCraft.has(slug));
if (requestedCraft.length > 0) {
const loaded = await loadCraftSections(CRAFT_DIR, requestedCraft);
if (loaded.body) {
craftBody = loaded.body;
craftSections = loaded.sections;
}
}
@ -8548,9 +8582,12 @@ export async function startServer({
skillMode,
designSystemBody,
designSystemTitle,
designSystemUsageMd,
designSystemTokensCss,
designSystemComponentsManifest,
designSystemFixtureHtml,
designSystemPullIndex,
designSystemImportMode,
craftBody,
craftSections,
memoryBody,

View file

@ -630,8 +630,12 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
}
const before = await listAllDesignSystems();
const importMode = normalizeDesignSystemImportMode(body.importMode);
const craftApplies = normalizeDesignSystemCraftApplies(body.craftApplies);
const result = await importLocalDesignSystemProject(sourceRoot, USER_DESIGN_SYSTEMS_DIR, {
name: typeof body.name === 'string' ? body.name : undefined,
...(typeof body.name === 'string' ? { name: body.name } : {}),
...(importMode ? { importMode } : {}),
...(craftApplies ? { craftApplies } : {}),
reservedIds: before.map((system) => system.id),
});
const systems = await listAllDesignSystems();
@ -664,13 +668,17 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
? body.url
: '';
const before = await listAllDesignSystems();
const importMode = normalizeDesignSystemImportMode(body.importMode);
const craftApplies = normalizeDesignSystemCraftApplies(body.craftApplies);
const result = await importGitHubDesignSystemProject(
githubUrl,
path.join(PROJECT_ROOT, '.tmp'),
USER_DESIGN_SYSTEMS_DIR,
{
name: typeof body.name === 'string' ? body.name : undefined,
branch: typeof body.branch === 'string' ? body.branch : undefined,
...(typeof body.name === 'string' ? { name: body.name } : {}),
...(typeof body.branch === 'string' ? { branch: body.branch } : {}),
...(importMode ? { importMode } : {}),
...(craftApplies ? { craftApplies } : {}),
reservedIds: before.map((system) => system.id),
},
);
@ -714,6 +722,24 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
}
function normalizeDesignSystemImportMode(value: unknown): 'normalized' | 'hybrid' | 'verbatim' | undefined {
return value === 'normalized' || value === 'hybrid' || value === 'verbatim' ? value : undefined;
}
function normalizeDesignSystemCraftApplies(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const seen = new Set<string>();
const out: string[] = [];
for (const entry of value) {
if (typeof entry !== 'string') continue;
const slug = entry.trim().toLowerCase();
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) || seen.has(slug)) continue;
seen.add(slug);
out.push(slug);
}
return out;
}
function assembleExample(templateHtml: string, slidesHtml: string, title: string) {
return templateHtml
.replace('<!-- SLIDES_HERE -->', slidesHtml)

View file

@ -9,6 +9,7 @@ export const CHAT_TOOL_ENDPOINTS = [
'/api/tools/live-artifacts/update',
'/api/tools/connectors/list',
'/api/tools/connectors/execute',
'/api/tools/design-systems/read',
] as const;
export const CHAT_TOOL_OPERATIONS = [
@ -18,6 +19,7 @@ export const CHAT_TOOL_OPERATIONS = [
'live-artifacts:update',
'connectors:list',
'connectors:execute',
'design-systems:read',
] as const;
export type ToolEndpoint = (typeof CHAT_TOOL_ENDPOINTS)[number] | (string & {});

View file

@ -0,0 +1,160 @@
type JsonObject = Record<string, unknown>;
interface ToolCliResult {
exitCode: number;
}
interface ParsedOptions {
command: string | undefined;
path?: string;
designSystemId?: string;
help: boolean;
}
const DESIGN_SYSTEMS_USAGE = `Usage:
od tools design-systems read --path <manifest-declared-path> [--design-system <id>]
Environment:
OD_NODE_BIN Node-compatible runtime for agent wrapper invocations
OD_BIN Open Design CLI script for agent wrapper invocations
OD_DAEMON_URL Daemon base URL injected into agent runs
OD_TOOL_TOKEN Bearer token injected into agent runs
Agent runtime invocation:
"$OD_NODE_BIN" "$OD_BIN" tools design-systems read --path preview/colors.html
`;
function writeJson(value: unknown, stream: NodeJS.WriteStream = process.stdout): void {
stream.write(`${JSON.stringify(value)}\n`);
}
function fail(message: string, details?: unknown): ToolCliResult {
writeJson({ ok: false, error: { message, ...(details === undefined ? {} : { details }) } }, process.stderr);
return { exitCode: 1 };
}
function parseOptions(args: string[]): ParsedOptions | { error: string } {
const [command, ...rest] = args;
const options: ParsedOptions = {
command: command === '-h' || command === '--help' ? undefined : command,
help: command === '-h' || command === '--help',
};
for (let index = 0; index < rest.length; index += 1) {
const arg = rest[index];
if (arg === '--path') {
const value = rest[++index];
if (!value) return { error: '--path requires a relative file path' };
options.path = value;
} else if (arg === '--design-system') {
const value = rest[++index];
if (!value) return { error: '--design-system requires an id' };
options.designSystemId = value;
} else if (arg === '-h' || arg === '--help') {
options.help = true;
} else {
return { error: `unknown option: ${arg}` };
}
}
return options;
}
function daemonUrl(): URL | { error: string } {
const rawUrl = process.env.OD_DAEMON_URL;
if (!rawUrl) return { error: 'OD_DAEMON_URL is required' };
try {
const url = new URL(rawUrl);
url.pathname = url.pathname.replace(/\/+$/u, '');
url.search = '';
url.hash = '';
return url;
} catch {
return { error: 'OD_DAEMON_URL must be a valid URL' };
}
}
function toolToken(): string | { error: string } {
const token = process.env.OD_TOOL_TOKEN;
if (!token) return { error: 'OD_TOOL_TOKEN is required' };
return token;
}
function endpoint(baseUrl: URL, pathname: string): string {
const url = new URL(baseUrl.toString());
url.pathname = `${url.pathname}${pathname}`.replace(/\/+/gu, '/');
return url.toString();
}
async function requestJson(baseUrl: URL, token: string, pathname: string, init: RequestInit = {}): Promise<{ status: number; body: unknown }> {
const response = await fetch(endpoint(baseUrl, pathname), {
...init,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
...(init.body === undefined ? {} : { 'Content-Type': 'application/json' }),
...init.headers,
},
});
const text = await response.text();
let body: unknown = text;
if (text.length > 0) {
try {
body = JSON.parse(text) as unknown;
} catch {
body = { message: text };
}
}
return { status: response.status, body };
}
function normalizeCliError(body: unknown): JsonObject {
const rawError = body && typeof body === 'object' && 'error' in body ? (body as JsonObject).error : body;
if (typeof rawError === 'string') return { message: rawError };
if (!rawError || typeof rawError !== 'object') return { message: String(rawError ?? 'request failed') };
const error = rawError as JsonObject;
return {
...(typeof error.code === 'string' ? { code: error.code } : {}),
message: typeof error.message === 'string' ? error.message : String(error.error ?? 'request failed'),
...(error.details === undefined ? {} : { details: error.details }),
};
}
async function printApiResult(response: { status: number; body: unknown }): Promise<ToolCliResult> {
if (response.status < 200 || response.status >= 300) {
writeJson({ ok: false, status: response.status, error: normalizeCliError(response.body) }, process.stderr);
return { exitCode: 1 };
}
const body = response.body && typeof response.body === 'object' && !Array.isArray(response.body)
? response.body as JsonObject
: { result: response.body };
writeJson({ ok: true, ...body });
return { exitCode: 0 };
}
export async function runDesignSystemsToolCli(args: string[]): Promise<ToolCliResult> {
const options = parseOptions(args);
if ('error' in options) return fail(options.error);
if (options.help || !options.command) {
process.stdout.write(DESIGN_SYSTEMS_USAGE);
return { exitCode: options.command ? 0 : 1 };
}
const baseUrl = daemonUrl();
if ('error' in baseUrl) return fail(baseUrl.error);
const token = toolToken();
if (typeof token !== 'string') return fail(token.error);
if (options.command !== 'read') return fail(`unknown design-systems command: ${options.command}`);
if (!options.path) return fail('read requires --path <manifest-declared-path>');
return printApiResult(
await requestJson(baseUrl, token, '/api/tools/design-systems/read', {
method: 'POST',
body: JSON.stringify({
path: options.path,
...(options.designSystemId ? { designSystemId: options.designSystemId } : {}),
}),
}),
);
}

View file

@ -19,6 +19,8 @@ import {
listDesignSystems,
readDesignSystem,
readDesignSystemAssets,
readDesignSystemPackageInfo,
readDesignSystemPullFile,
resolveDesignSystemAssets,
} from '../src/design-systems.js';
@ -235,6 +237,208 @@ describe('Design System Project manifest runtime consumption', () => {
expect(assets.tokensCss).toBe(':root { --accent: #2F6FEB; }');
expect(assets.fixtureHtml).toBeUndefined();
});
it('reads USAGE.md, committed component cache, and manifest pull index without loading rich files', async () => {
const root = fresh();
const dir = writeDesignSystemProject(root, 'hybrid-project', {
manifest: {
schemaVersion: 'od-design-system-project/v1',
id: 'hybrid-project',
name: 'Hybrid Project',
category: 'Imported',
source: { type: 'local', path: '/tmp/project' },
files: {
design: 'DESIGN.md',
tokens: 'tokens.css',
components: 'components.html',
},
usage: 'USAGE.md',
componentsManifest: 'components.manifest.json',
importMode: 'verbatim',
craft: {
applies: ['color'],
suggested: [],
exemptions: ['typography'],
},
assetsDir: 'assets',
fonts: [{ family: 'Inter', weight: 500, file: 'fonts/Inter-Medium.woff2' }],
preview: {
dir: 'preview',
pages: [
{ path: 'preview/colors.html', role: 'colors', title: 'Colors' },
{ path: 'preview/app.html', role: 'app', title: 'App Preview' },
],
},
sourceFiles: {
scanned: 'source/scanned-files.json',
evidence: 'source/evidence.md',
tokens: 'source/tokens.source.json',
snippets: 'source/snippets/INDEX.json',
},
},
tokens: ':root { --accent: #00aa55; }',
components: '<button class="btn">Derived should lose to cache</button>',
});
writeFileSync(path.join(dir, 'USAGE.md'), '## Read Order\n\nUse cache first.');
writeFileSync(
path.join(dir, 'components.manifest.json'),
`${JSON.stringify({
schemaVersion: 1,
brandId: 'cache-brand',
source: { componentsHtml: 'components.html', tokensCss: 'tokens.css' },
fixture: {
styleBlockCount: 1,
selectorCount: 3,
classCount: 2,
elementCount: 1,
},
tokens: {
declared: ['--accent'],
referenced: ['--accent'],
unusedDeclared: [],
undeclaredReferenced: [],
},
selectors: ['.cached-button'],
classes: ['cached-button'],
elements: ['button'],
groups: [
{
id: 'buttons',
label: 'Cached buttons',
present: true,
selectors: ['.cached-button'],
classes: ['cached-button'],
elements: ['button'],
tokenReferences: ['--accent'],
},
],
literals: {
colorExpressions: 0,
pixelValues: 0,
hardcodedFontFamilies: 0,
},
}, null, 2)}\n`,
);
const assets = await readDesignSystemAssets(root, 'hybrid-project');
expect(assets.usageMd).toContain('Use cache first');
expect(assets.importMode).toBe('verbatim');
expect(assets.craftApplies).toEqual(['color']);
expect(assets.craftExemptions).toEqual(['typography']);
expect(assets.componentsManifest).toContain('components.manifest schema v1 for cache-brand');
expect(assets.componentsManifest).toContain('Cached buttons');
expect(assets.pullIndex).toContain('preview/colors.html: Colors; colors');
expect(assets.pullIndex).toContain('fonts/Inter-Medium.woff2: font: Inter 500');
expect(assets.pullIndex).toContain('source/snippets/INDEX.json: source snippet index');
});
it('allows pull reads only for manifest-declared rich-layer files', async () => {
const root = fresh();
const dir = writeDesignSystemProject(root, 'pull-project', {
manifest: {
schemaVersion: 'od-design-system-project/v1',
id: 'pull-project',
name: 'Pull Project',
category: 'Imported',
source: { type: 'local', path: '/tmp/project' },
files: {
design: 'DESIGN.md',
tokens: 'tokens.css',
components: 'components.html',
},
assetsDir: 'assets',
preview: {
dir: 'preview',
pages: [{ path: 'preview/colors.html', role: 'colors', title: 'Colors' }],
},
sourceFiles: {
snippets: 'source/snippets/INDEX.json',
},
},
});
mkdirSync(path.join(dir, 'preview'), { recursive: true });
mkdirSync(path.join(dir, 'source', 'snippets'), { recursive: true });
mkdirSync(path.join(dir, 'assets', 'icons'), { recursive: true });
writeFileSync(path.join(dir, 'preview', 'colors.html'), '<h1>Colors</h1>');
writeFileSync(path.join(dir, 'preview', 'spacing.html'), '<h1>Spacing</h1>');
writeFileSync(path.join(dir, 'source', 'snippets', 'INDEX.json'), `${JSON.stringify({
schemaVersion: 1,
snippets: [{ path: 'source/snippets/Button.tsx', role: 'button' }],
})}\n`);
writeFileSync(path.join(dir, 'source', 'snippets', 'Button.tsx'), 'export function Button() {}');
writeFileSync(path.join(dir, 'assets', 'icons', 'mark.svg'), '<svg />');
await expect(readDesignSystemPullFile(root, 'pull-project', 'preview/colors.html')).resolves.toMatchObject({
path: 'preview/colors.html',
encoding: 'utf8',
content: '<h1>Colors</h1>',
});
await expect(readDesignSystemPullFile(root, 'pull-project', 'source/snippets/Button.tsx')).resolves.toMatchObject({
path: 'source/snippets/Button.tsx',
content: 'export function Button() {}',
});
await expect(readDesignSystemPullFile(root, 'pull-project', 'assets/icons/mark.svg')).resolves.toMatchObject({
path: 'assets/icons/mark.svg',
content: '<svg />',
});
await expect(readDesignSystemPullFile(root, 'pull-project', 'preview/spacing.html')).resolves.toBeNull();
await expect(readDesignSystemPullFile(root, 'pull-project', '../pull-project/preview/colors.html')).resolves.toBeNull();
});
it('summarizes manifest and source evidence for the detail page', async () => {
const root = fresh();
const dir = writeDesignSystemProject(root, 'detail-project', {
manifest: {
schemaVersion: 'od-design-system-project/v1',
id: 'detail-project',
name: 'Detail Project',
category: 'Imported',
source: { type: 'local', path: '/tmp/project' },
files: {
design: 'DESIGN.md',
tokens: 'tokens.css',
components: 'components.html',
},
usage: 'USAGE.md',
componentsManifest: 'components.manifest.json',
importMode: 'hybrid',
preview: {
dir: 'preview',
pages: [{ path: 'preview/colors.html', role: 'colors', title: 'Colors' }],
},
sourceFiles: {
scanned: 'source/scanned-files.json',
evidence: 'source/evidence.md',
tokens: 'source/tokens.source.json',
snippets: 'source/snippets/INDEX.json',
},
},
});
mkdirSync(path.join(dir, 'source', 'snippets'), { recursive: true });
writeFileSync(path.join(dir, 'source', 'scanned-files.json'), JSON.stringify({ files: [{ path: 'Button.tsx' }] }));
writeFileSync(path.join(dir, 'source', 'evidence.md'), '# Evidence\n\n- Buttons matched source.');
writeFileSync(path.join(dir, 'source', 'tokens.source.json'), JSON.stringify({
tokenCount: 7,
confidence: { color: 'high', spacing: 0.4 },
}));
writeFileSync(path.join(dir, 'source', 'snippets', 'INDEX.json'), JSON.stringify({
snippets: [{ path: 'source/snippets/Button.tsx' }],
}));
await expect(readDesignSystemPackageInfo(root, 'detail-project')).resolves.toMatchObject({
manifest: {
usage: 'USAGE.md',
importMode: 'hybrid',
preview: { pages: [{ path: 'preview/colors.html' }] },
},
sourceEvidence: {
scannedFileCount: 1,
tokenCount: 7,
snippetCount: 1,
confidence: { color: 'high', spacing: 0.4 },
},
});
});
});
// Reviewer feedback (nettee, PR-D #1544): the parity guard at

View file

@ -99,6 +99,8 @@ exit 1
{
gitBin: fakeGit,
now: new Date('2026-05-18T10:00:00.000Z'),
importMode: 'normalized',
craftApplies: ['color'],
},
);
@ -119,9 +121,25 @@ exit 1
tokens: 'tokens.css',
components: 'components.html',
},
usage: 'USAGE.md',
componentsManifest: 'components.manifest.json',
importMode: 'normalized',
craft: {
applies: ['color'],
},
sourceFiles: {
scanned: 'source/scanned-files.json',
evidence: 'source/evidence.md',
tokens: 'source/tokens.source.json',
snippets: 'source/snippets/INDEX.json',
},
});
expect(fs.readFileSync(path.join(result.dir, 'DESIGN.md'), 'utf8')).toContain(
'A GitHub-hosted design kit.',
);
expect(fs.existsSync(path.join(result.dir, 'USAGE.md'))).toBe(true);
expect(fs.existsSync(path.join(result.dir, 'components.manifest.json'))).toBe(true);
expect(fs.existsSync(path.join(result.dir, 'preview', 'app.html'))).toBe(true);
expect(fs.existsSync(path.join(result.dir, 'source', 'snippets', 'card.tsx'))).toBe(true);
});
});

View file

@ -17,6 +17,7 @@ describe('importLocalDesignSystemProject', () => {
userDesignSystemsRoot = path.join(tempRoot, 'user-design-systems');
fs.mkdirSync(path.join(sourceRoot, 'src', 'components'), { recursive: true });
fs.mkdirSync(path.join(sourceRoot, 'src', 'styles'), { recursive: true });
fs.mkdirSync(path.join(sourceRoot, 'src', 'assets', 'fonts'), { recursive: true });
fs.mkdirSync(path.join(sourceRoot, 'public'), { recursive: true });
fs.writeFileSync(
path.join(sourceRoot, 'package.json'),
@ -40,6 +41,7 @@ describe('importLocalDesignSystemProject', () => {
);
fs.writeFileSync(path.join(sourceRoot, 'src', 'components', 'Button.tsx'), 'export function Button() {}');
fs.writeFileSync(path.join(sourceRoot, 'public', 'logo.svg'), '<svg xmlns="http://www.w3.org/2000/svg" />');
fs.writeFileSync(path.join(sourceRoot, 'src', 'assets', 'fonts', 'AcmeSans-Regular.woff2'), 'font');
});
afterEach(() => {
@ -53,7 +55,27 @@ describe('importLocalDesignSystemProject', () => {
expect(result.id).toBe('kami-app');
expect(result.files).toEqual(
expect.arrayContaining(['DESIGN.md', 'tokens.css', 'components.html', 'manifest.json', 'assets/logo.svg']),
expect.arrayContaining([
'USAGE.md',
'DESIGN.md',
'tokens.css',
'components.html',
'components.manifest.json',
'manifest.json',
'assets/logo.svg',
'fonts/acmesans-regular.woff2',
'preview/colors.html',
'preview/typography.html',
'preview/spacing.html',
'preview/components-buttons.html',
'preview/components-inputs.html',
'preview/app.html',
'source/scanned-files.json',
'source/evidence.md',
'source/tokens.source.json',
'source/snippets/INDEX.json',
'source/snippets/button.tsx',
]),
);
const manifest = JSON.parse(fs.readFileSync(path.join(result.dir, 'manifest.json'), 'utf8')) as Record<string, unknown>;
@ -72,14 +94,82 @@ describe('importLocalDesignSystemProject', () => {
tokens: 'tokens.css',
components: 'components.html',
},
usage: 'USAGE.md',
componentsManifest: 'components.manifest.json',
importMode: 'hybrid',
assetsDir: 'assets',
craft: {
applies: [],
suggested: ['color'],
exemptions: [],
},
preview: {
dir: 'preview',
},
sourceFiles: {
scanned: 'source/scanned-files.json',
evidence: 'source/evidence.md',
tokens: 'source/tokens.source.json',
snippets: 'source/snippets/INDEX.json',
},
});
expect((manifest.preview as { pages: unknown[] }).pages).toHaveLength(6);
expect(manifest.fonts).toMatchObject([{ family: 'AcmeSans Regular', file: 'fonts/acmesans-regular.woff2' }]);
const design = fs.readFileSync(path.join(result.dir, 'DESIGN.md'), 'utf8');
expect(design).toContain('A focused workspace for AI design reviews.');
expect(design).toContain('Button: `src/components/Button.tsx`');
expect(design).toContain('`--color-primary: #ff3366`');
const usage = fs.readFileSync(path.join(result.dir, 'USAGE.md'), 'utf8');
expect(usage).toContain('Auto-generated by Open Design importer');
expect(usage).toContain('## Read Order');
expect(usage).toContain('source/tokens.source.json');
const componentsManifest = JSON.parse(
fs.readFileSync(path.join(result.dir, 'components.manifest.json'), 'utf8'),
) as Record<string, unknown>;
expect(componentsManifest).toMatchObject({
schemaVersion: 1,
brandId: 'kami-app',
source: {
componentsHtml: 'components.html',
tokensCss: 'tokens.css',
},
});
const scannedFiles = JSON.parse(
fs.readFileSync(path.join(result.dir, 'source', 'scanned-files.json'), 'utf8'),
) as { files: Array<{ path: string; kind: string }> };
expect(scannedFiles.files).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: 'src/styles/tokens.css', kind: 'style' }),
expect.objectContaining({ path: 'src/components/Button.tsx', kind: 'component' }),
]),
);
const sourceTokens = JSON.parse(
fs.readFileSync(path.join(result.dir, 'source', 'tokens.source.json'), 'utf8'),
) as { tokenCount: number; tokens: Array<{ name: string; normalizedRole?: string }> };
expect(sourceTokens.tokenCount).toBe(3);
expect(sourceTokens.tokens).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: '--color-primary', normalizedRole: 'accent' }),
]),
);
const snippetsIndex = JSON.parse(
fs.readFileSync(path.join(result.dir, 'source', 'snippets', 'INDEX.json'), 'utf8'),
) as { snippets: Array<{ path: string; role: string; sourcePath: string }> };
expect(snippetsIndex.snippets).toEqual([
expect.objectContaining({
path: 'source/snippets/button.tsx',
role: 'button',
sourcePath: 'src/components/Button.tsx',
}),
]);
expect(fs.readFileSync(path.join(result.dir, 'preview', 'app.html'), 'utf8')).toContain('src/components/Button.tsx');
const assets = await readDesignSystemAssets(userDesignSystemsRoot, 'kami-app');
expect(assets.tokensCss).toContain('--accent: #ff3366;');
expect(assets.tokensCss).toContain('--bg: #101014;');
@ -107,4 +197,22 @@ describe('importLocalDesignSystemProject', () => {
expect(first.id).toBe('kami-app-2');
expect(second.id).toBe('kami-app-3');
});
it('writes selected importMode and applied craft semantics into manifest', async () => {
const result = await importLocalDesignSystemProject(sourceRoot, userDesignSystemsRoot, {
now: new Date('2026-05-18T09:00:00.000Z'),
importMode: 'verbatim',
craftApplies: ['color', 'accessibility-baseline', 'color'],
});
const manifest = JSON.parse(fs.readFileSync(path.join(result.dir, 'manifest.json'), 'utf8')) as Record<string, unknown>;
expect(manifest).toMatchObject({
importMode: 'verbatim',
craft: {
applies: ['color', 'accessibility-baseline'],
suggested: [],
exemptions: [],
},
});
});
});

View file

@ -0,0 +1,158 @@
import express from 'express';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import http from 'node:http';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { registerDesignSystemToolRoutes } from '../src/design-system-tool-routes.js';
type JsonFetchResult = { status: number; body: Record<string, any> };
let server: http.Server | undefined;
afterEach(async () => {
await new Promise<void>((resolve, reject) => {
if (!server) return resolve();
server.close((error?: Error) => (error ? reject(error) : resolve()));
});
server = undefined;
});
function fresh(): string {
return mkdtempSync(path.join(tmpdir(), 'od-design-system-tool-routes-'));
}
function writeHybridDesignSystem(root: string, id: string): string {
const dir = path.join(root, id);
mkdirSync(path.join(dir, 'preview'), { recursive: true });
writeFileSync(path.join(dir, 'DESIGN.md'), '# Test\n');
writeFileSync(path.join(dir, 'tokens.css'), ':root { --bg: #fff; }');
writeFileSync(path.join(dir, 'components.html'), '<button>ok</button>');
writeFileSync(path.join(dir, 'preview', 'colors.html'), '<h1>Colors</h1>');
writeFileSync(path.join(dir, 'preview', 'spacing.html'), '<h1>Spacing</h1>');
writeFileSync(path.join(dir, 'manifest.json'), `${JSON.stringify({
schemaVersion: 'od-design-system-project/v1',
id,
name: 'Test',
category: 'Imported',
source: { type: 'local', path: '/tmp/source' },
files: {
design: 'DESIGN.md',
tokens: 'tokens.css',
components: 'components.html',
},
preview: {
dir: 'preview',
pages: [{ path: 'preview/colors.html', role: 'colors', title: 'Colors' }],
},
}, null, 2)}\n`);
return dir;
}
async function startRouteServer(options: {
builtInRoot: string;
userRoot: string;
activeDesignSystemId: string | null;
}): Promise<string> {
const app = express();
app.use(express.json());
registerDesignSystemToolRoutes(app, {
auth: {
authorizeToolRequest: (_req, _res, operation) => {
expect(operation).toBe('design-systems:read');
return {
token: 'token',
runId: 'run-1',
projectId: 'project-1',
allowedEndpoints: ['/api/tools/design-systems/read'],
allowedOperations: ['design-systems:read'],
issuedAt: new Date(0).toISOString(),
expiresAt: new Date(60_000).toISOString(),
};
},
},
http: {
sendApiError: (res, status, code, message, extras = {}) => {
res.status(status).json({ error: { code, message, ...extras } });
},
},
paths: {
DESIGN_SYSTEMS_DIR: options.builtInRoot,
USER_DESIGN_SYSTEMS_DIR: options.userRoot,
},
projects: {
getProject: () => ({
id: 'project-1',
designSystemId: options.activeDesignSystemId,
}),
},
});
server = app.listen(0);
await new Promise<void>((resolve) => server?.once('listening', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('unexpected listen address');
return `http://127.0.0.1:${address.port}`;
}
async function jsonFetch(url: string, body: Record<string, unknown>): Promise<JsonFetchResult> {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer token',
},
body: JSON.stringify(body),
});
return { status: response.status, body: await response.json() as Record<string, any> };
}
describe('design-system pull tool route', () => {
it('reads manifest-allowed files from the active design system', async () => {
const builtInRoot = fresh();
const userRoot = fresh();
writeHybridDesignSystem(builtInRoot, 'pull-brand');
const baseUrl = await startRouteServer({
builtInRoot,
userRoot,
activeDesignSystemId: 'pull-brand',
});
const response = await jsonFetch(`${baseUrl}/api/tools/design-systems/read`, {
path: 'preview/colors.html',
});
expect(response.status).toBe(200);
expect(response.body.file).toMatchObject({
path: 'preview/colors.html',
encoding: 'utf8',
content: '<h1>Colors</h1>',
});
});
it('rejects unlisted files and non-active design-system ids', async () => {
const builtInRoot = fresh();
const userRoot = fresh();
writeHybridDesignSystem(builtInRoot, 'pull-brand');
const baseUrl = await startRouteServer({
builtInRoot,
userRoot,
activeDesignSystemId: 'pull-brand',
});
const unlisted = await jsonFetch(`${baseUrl}/api/tools/design-systems/read`, {
path: 'preview/spacing.html',
});
expect(unlisted.status).toBe(404);
expect(unlisted.body.error.code).toBe('DESIGN_SYSTEM_FILE_NOT_FOUND');
const mismatch = await jsonFetch(`${baseUrl}/api/tools/design-systems/read`, {
designSystemId: 'other-brand',
path: 'preview/colors.html',
});
expect(mismatch.status).toBe(403);
expect(mismatch.body.error.code).toBe('DESIGN_SYSTEM_DENIED');
});
});

View file

@ -325,6 +325,31 @@ describe('composeSystemPrompt', () => {
expect(prompt).toContain('class="btn btn-primary"');
});
it('places USAGE.md before DESIGN.md so it acts as the package router', () => {
const prompt = composeSystemPrompt({
designSystemTitle: 'default',
designSystemBody: 'PROSE_BODY_MARKER',
designSystemUsageMd: 'Read Order: inspect the manifest cache before source evidence.',
});
const usageAt = prompt.indexOf('## How to use this design system — default');
const proseAt = prompt.indexOf('## Active design system — default');
expect(usageAt).toBeGreaterThan(0);
expect(proseAt).toBeGreaterThan(usageAt);
expect(prompt).toContain('Read Order: inspect the manifest cache before source evidence.');
});
it('injects a small default usage router for legacy brands with no USAGE.md', () => {
const prompt = composeSystemPrompt({
designSystemTitle: 'legacy',
designSystemBody: '# Legacy\n\nProse description.',
});
expect(prompt).toContain('## How to use this design system — legacy');
expect(prompt).toContain('Read DESIGN.md for visual principles');
expect(prompt).toContain('do not assume those files have already been loaded');
});
it('prefers the component manifest over the full fixture when both are present', () => {
const prompt = composeSystemPrompt({
designSystemTitle: 'default',
@ -386,6 +411,32 @@ describe('composeSystemPrompt', () => {
expect(manifestOnly).toContain('## Reference component manifest — default');
});
it('adds the pull-layer index without loading pull-layer file contents', () => {
const prompt = composeSystemPrompt({
designSystemTitle: 'default',
designSystemBody: '# x\n\nbody',
designSystemPullIndex:
'Additional design-system files declared by manifest.json:\n- preview/colors.html: Colors; colors\n- source/evidence.md: import evidence notes',
});
expect(prompt).toContain('## Pull-layer files available on demand — default');
expect(prompt).toContain('preview/colors.html: Colors; colors');
expect(prompt).toContain('source/evidence.md: import evidence notes');
expect(prompt).toContain('Keep the push prompt light');
});
it('adds importMode guidance when the manifest declares consumption semantics', () => {
const prompt = composeSystemPrompt({
designSystemTitle: 'source-heavy',
designSystemBody: '# x\n\nbody',
designSystemImportMode: 'verbatim',
});
expect(prompt).toContain('## Design system import mode — source-heavy');
expect(prompt).toContain('Preserve source semantics and source naming');
expect(prompt).toContain('pull-layer source evidence or snippets');
});
it('places the tokens + component manifest blocks AFTER the DESIGN.md prose block (prose sets voice, structured form binds names)', () => {
const prompt = composeSystemPrompt({
designSystemTitle: 'default',

View file

@ -1351,6 +1351,7 @@ export function DesignSystemDetailView({
</button>
) : null}
</div>
<DesignSystemPackageCard system={system} />
<div className="ds-warning-card">
<Icon name="help-circle" />
<span>
@ -1578,6 +1579,146 @@ function findWorkspaceActivityMessage(messages: ChatMessage[]): ChatMessage | nu
return null;
}
function DesignSystemPackageCard({ system }: { system: DesignSystemDetail }) {
const info = system.packageInfo;
const manifest = info?.manifest;
const evidence = info?.sourceEvidence;
const sourceLabel = manifest?.source?.type ? sourceTypeLabel(manifest.source.type) : sourceTypeLabel(system.source);
const previewPages = manifest?.preview?.pages ?? [];
const sourceFiles = manifest?.sourceFiles;
const sourceFileCount = [sourceFiles?.scanned, sourceFiles?.evidence, sourceFiles?.tokens, sourceFiles?.snippets]
.filter(Boolean)
.length;
const protocolItems = [
manifest?.usage ? manifest.usage : null,
manifest?.files?.design ?? 'DESIGN.md',
manifest?.files?.tokens ?? 'tokens.css',
manifest?.files?.components,
manifest?.componentsManifest,
].filter((item): item is string => typeof item === 'string' && item.length > 0);
const evidenceStats = [
evidence?.scannedFileCount !== undefined ? { label: 'Scanned files', value: String(evidence.scannedFileCount) } : null,
evidence?.tokenCount !== undefined ? { label: 'Source tokens', value: String(evidence.tokenCount) } : null,
evidence?.snippetCount !== undefined ? { label: 'Snippets', value: String(evidence.snippetCount) } : null,
manifest?.fonts?.length ? { label: 'Fonts', value: String(manifest.fonts.length) } : null,
].filter((item): item is { label: string; value: string } => item !== null);
const confidence = evidence?.confidence ? Object.entries(evidence.confidence) : [];
return (
<section className="ds-package-card">
<div className="ds-package-card__head">
<span>
<strong>{manifest ? 'Structured import package' : 'Legacy design system'}</strong>
<small>
{manifest
? `${sourceLabel} · ${manifest.importMode ?? 'normalized'} mode · manifest indexed`
: `${sourceLabel} · DESIGN.md-only fallback`}
</small>
</span>
<span className={manifest ? 'ds-package-pill is-ready' : 'ds-package-pill'}>
{manifest ? 'Hybrid ready' : 'Fallback'}
</span>
</div>
<div className="ds-package-grid">
<div>
<h2>Agent push layer</h2>
<div className="ds-package-chips">
{protocolItems.map((item) => (
<code key={item}>{item}</code>
))}
</div>
</div>
<div>
<h2>Pull layer</h2>
<div className="ds-package-metrics">
<span><strong>{previewPages.length}</strong><small>Preview pages</small></span>
<span><strong>{sourceFileCount}</strong><small>Evidence indexes</small></span>
<span><strong>{manifest?.assetsDir ? 'Yes' : 'No'}</strong><small>Assets</small></span>
</div>
</div>
</div>
{evidenceStats.length > 0 || confidence.length > 0 ? (
<div className="ds-evidence-panel">
<div className="ds-evidence-stats">
{evidenceStats.map((item) => (
<span key={item.label}>
<strong>{item.value}</strong>
<small>{item.label}</small>
</span>
))}
</div>
{confidence.length > 0 ? (
<div className="ds-confidence-row">
{confidence.map(([key, value]) => (
<span key={key}>{key}: {String(value)}</span>
))}
</div>
) : null}
</div>
) : null}
{manifest ? (
<div className="ds-package-files">
<PackageFileGroup
title="Preview"
files={previewPages.map((page) => ({
path: page.path ?? '',
meta: [page.title, page.role].filter(Boolean).join(' · '),
}))}
/>
<PackageFileGroup
title="Source evidence"
files={[
sourceFiles?.scanned ? { path: sourceFiles.scanned, meta: 'Scanned file inventory' } : null,
sourceFiles?.evidence ? { path: sourceFiles.evidence, meta: 'Evidence notes' } : null,
sourceFiles?.tokens ? { path: sourceFiles.tokens, meta: 'Token extraction evidence' } : null,
sourceFiles?.snippets ? { path: sourceFiles.snippets, meta: 'Snippet index' } : null,
].filter((item): item is { path: string; meta: string } => item !== null)}
/>
</div>
) : null}
{evidence?.evidenceExcerpt ? (
<pre className="ds-evidence-excerpt">{evidence.evidenceExcerpt}</pre>
) : null}
</section>
);
}
function PackageFileGroup({
title,
files,
}: {
title: string;
files: Array<{ path: string; meta?: string }>;
}) {
const visibleFiles = files.filter((file) => file.path.length > 0);
if (visibleFiles.length === 0) return null;
return (
<div>
<h2>{title}</h2>
<div className="ds-package-file-list">
{visibleFiles.map((file) => (
<span key={file.path}>
<code>{file.path}</code>
{file.meta ? <small>{file.meta}</small> : null}
</span>
))}
</div>
</div>
);
}
function sourceTypeLabel(value: string | undefined): string {
if (value === 'github') return 'GitHub import';
if (value === 'local') return 'Local import';
if (value === 'bundled' || value === 'built-in') return 'Bundled';
if (value === 'user') return 'User workspace';
if (value === 'installed') return 'Installed';
return 'Design system';
}
function WorkspaceActivityCard({
message,
active,

View file

@ -20,6 +20,13 @@ interface Props {
setCfg: Dispatch<SetStateAction<AppConfig>>;
}
function toggleCraftSlug(current: string[], slug: string, enabled: boolean): string[] {
const next = new Set(current);
if (enabled) next.add(slug);
else next.delete(slug);
return Array.from(next);
}
export function DesignSystemsSection({ cfg, setCfg }: Props) {
const t = useT();
const [designSystems, setDesignSystems] = useState<DesignSystemSummary[]>([]);
@ -29,7 +36,9 @@ export function DesignSystemsSection({ cfg, setCfg }: Props) {
const [previewBody, setPreviewBody] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [importPath, setImportPath] = useState('');
const [importMode, setImportMode] = useState<'local' | 'github'>('local');
const [importSource, setImportSource] = useState<'local' | 'github'>('local');
const [packageImportMode, setPackageImportMode] = useState<'normalized' | 'hybrid' | 'verbatim'>('hybrid');
const [craftApplies, setCraftApplies] = useState<string[]>([]);
const [importing, setImporting] = useState(false);
const [importMessage, setImportMessage] = useState<string | null>(null);
const [importError, setImportError] = useState<string | null>(null);
@ -119,10 +128,14 @@ export function DesignSystemsSection({ cfg, setCfg }: Props) {
setImporting(true);
setImportError(null);
setImportMessage(null);
const importOptions = {
importMode: packageImportMode,
craftApplies,
};
const result =
importMode === 'github'
? await importGitHubDesignSystem({ githubUrl: importTarget })
: await importLocalDesignSystem({ baseDir: importTarget });
importSource === 'github'
? await importGitHubDesignSystem({ githubUrl: importTarget, ...importOptions })
: await importLocalDesignSystem({ baseDir: importTarget, ...importOptions });
setImporting(false);
if ('error' in result) {
setImportError(result.error.message);
@ -145,24 +158,73 @@ export function DesignSystemsSection({ cfg, setCfg }: Props) {
<div className="seg-control">
<button
type="button"
className={importMode === 'local' ? 'active' : ''}
onClick={() => setImportMode('local')}
className={importSource === 'local' ? 'active' : ''}
onClick={() => setImportSource('local')}
>
Local
</button>
<button
type="button"
className={importMode === 'github' ? 'active' : ''}
onClick={() => setImportMode('github')}
className={importSource === 'github' ? 'active' : ''}
onClick={() => setImportSource('github')}
>
GitHub
</button>
</div>
<div className="library-import-options">
<div className="library-import-option-group">
<span className="library-import-option-label">Structure</span>
<div className="seg-control library-import-mode-control">
<button
type="button"
className={packageImportMode === 'hybrid' ? 'active' : ''}
onClick={() => setPackageImportMode('hybrid')}
>
Hybrid
</button>
<button
type="button"
className={packageImportMode === 'normalized' ? 'active' : ''}
onClick={() => setPackageImportMode('normalized')}
>
Normalized
</button>
<button
type="button"
className={packageImportMode === 'verbatim' ? 'active' : ''}
onClick={() => setPackageImportMode('verbatim')}
>
Verbatim
</button>
</div>
</div>
<div className="library-import-option-group">
<span className="library-import-option-label">Craft</span>
<label className="library-import-checkbox">
<input
type="checkbox"
checked={craftApplies.includes('color')}
onChange={(e) => setCraftApplies((current) => toggleCraftSlug(current, 'color', e.target.checked))}
/>
<span>Color</span>
</label>
<label className="library-import-checkbox">
<input
type="checkbox"
checked={craftApplies.includes('accessibility-baseline')}
onChange={(e) =>
setCraftApplies((current) => toggleCraftSlug(current, 'accessibility-baseline', e.target.checked))
}
/>
<span>Accessibility</span>
</label>
</div>
</div>
<div className="library-install-row">
<input
type="text"
className="library-search"
placeholder={importMode === 'github' ? 'https://github.com/owner/repo' : '/path/to/project'}
placeholder={importSource === 'github' ? 'https://github.com/owner/repo' : '/path/to/project'}
value={importPath}
onChange={(e) => setImportPath(e.target.value)}
/>

View file

@ -19602,6 +19602,45 @@ body.desktop-pet-shell .pet-task-item {
width: fit-content;
}
.library-import-options {
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
align-items: center;
}
.library-import-option-group {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.library-import-option-label {
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.library-import-mode-control button {
font-size: 11px;
}
.library-import-checkbox {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--text-secondary);
font-size: 12px;
white-space: nowrap;
}
.library-import-checkbox input {
margin: 0;
}
.library-install-row {
display: flex;
gap: 8px;

View file

@ -593,6 +593,7 @@
.ds-warning-card,
.ds-generation-review-card,
.ds-workspace-activity-card,
.ds-package-card,
.ds-revision-card,
.ds-revision-history,
.ds-review-section,
@ -791,6 +792,129 @@
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
background: color-mix(in srgb, var(--accent) 9%, var(--bg-panel));
}
.ds-package-card {
display: grid;
gap: 14px;
padding: 16px;
margin-bottom: 18px;
}
.ds-package-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.ds-package-card__head > span:first-child {
display: grid;
gap: 3px;
min-width: 0;
}
.ds-package-card__head small,
.ds-package-card h2,
.ds-package-file-list small,
.ds-evidence-stats small {
color: var(--text-muted);
}
.ds-package-pill {
flex: 0 0 auto;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.ds-package-pill.is-ready {
border-color: color-mix(in srgb, var(--green) 36%, var(--border));
color: var(--green);
}
.ds-package-grid,
.ds-package-files {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.ds-package-grid > div,
.ds-package-files > div,
.ds-evidence-panel {
display: grid;
gap: 10px;
min-width: 0;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-subtle);
}
.ds-package-card h2 {
margin: 0;
font-size: 11px;
letter-spacing: 0;
text-transform: uppercase;
}
.ds-package-chips,
.ds-package-file-list,
.ds-confidence-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ds-package-chips code,
.ds-package-file-list code {
max-width: 100%;
overflow: hidden;
padding: 4px 7px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg-panel);
color: var(--text);
font: 11px/1.35 var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
text-overflow: ellipsis;
white-space: nowrap;
}
.ds-package-metrics,
.ds-evidence-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.ds-package-metrics span,
.ds-evidence-stats span {
display: grid;
gap: 2px;
}
.ds-package-metrics strong,
.ds-evidence-stats strong {
font-size: 16px;
}
.ds-package-metrics small,
.ds-evidence-stats small {
font-size: 11px;
}
.ds-package-file-list span {
display: grid;
gap: 4px;
min-width: 0;
}
.ds-confidence-row span {
padding: 4px 7px;
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border));
border-radius: 999px;
color: var(--text-muted);
font-size: 11px;
}
.ds-evidence-excerpt {
max-height: 160px;
overflow: auto;
margin: 0;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-subtle);
color: var(--text-muted);
white-space: pre-wrap;
font: 12px/1.45 var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
}
.ds-warning-card span {
flex: 1;
display: grid;
@ -1264,9 +1388,15 @@
}
.ds-resource-row,
.ds-files-panel,
.ds-publish-card {
.ds-publish-card,
.ds-package-grid,
.ds-package-files {
grid-template-columns: 1fr;
}
.ds-package-metrics,
.ds-evidence-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ds-github-access-methods {
grid-template-columns: 1fr;
}

View file

@ -55,12 +55,16 @@ discover the same files without guessing.
```text
design-systems/<slug>/
├── manifest.json ← machine-readable project entry
├── DESIGN.md ← canonical design prose for agents
├── tokens.css ← canonical compiled CSS custom properties
├── components.html ← optional standalone component fixture
├── assets/ ← optional brand assets
└── preview/ ← optional static preview pages
├── manifest.json ← machine-readable project entry
├── USAGE.md ← optional agent-facing package guide
├── DESIGN.md ← canonical design prose for agents
├── tokens.css ← canonical compiled CSS custom properties
├── components.html ← optional standalone component fixture
├── components.manifest.json ← optional rebuildable component cache
├── assets/ ← optional brand assets
├── fonts/ ← optional webfont files
├── preview/ ← optional static preview pages
└── source/ ← optional importer evidence and snippets
```
`manifest.json` is validated by `pnpm guard` when present. PR1 does not
@ -97,6 +101,32 @@ For v1, file locations are intentionally fixed:
- `assetsDir` is optional and, when declared, must be `assets`.
- `previewDir` is optional and, when declared, must be `preview`.
Imported systems may also declare richer optional indexes:
```json
{
"usage": "USAGE.md",
"componentsManifest": "components.manifest.json",
"importMode": "hybrid",
"craft": { "applies": [], "suggested": [], "exemptions": [] },
"fonts": [],
"preview": { "dir": "preview", "pages": [] },
"sourceFiles": {
"scanned": "source/scanned-files.json",
"evidence": "source/evidence.md",
"tokens": "source/tokens.source.json",
"snippets": "source/snippets/INDEX.json"
}
}
```
For PR0, these richer fields are structural only: the guard validates safe
relative paths, declared file or directory existence, JSON readability for
declared JSON indexes, and optional `components.manifest.json` drift. Runtime
prompt composition and picker behavior continue to use the existing
`DESIGN.md` / `tokens.css` / `components.html` paths until later PRs consume
the richer indexes.
The schema source of truth lives in
[`_schema/manifest.schema.ts`](_schema/manifest.schema.ts). The guard lives in
[`../scripts/check-design-system-manifests.ts`](../scripts/check-design-system-manifests.ts).

View file

@ -33,9 +33,20 @@ Design System Project folders use fixed v1 file names:
- `components.html` — optional standalone component fixture.
- `assets/` — optional brand assets.
- `preview/` — optional static preview pages.
- `USAGE.md` — optional agent-facing package guide.
- `components.manifest.json` — optional rebuildable cache derived from
`components.html` and `tokens.css`.
- `fonts/` — optional webfont files.
- `source/` — optional importer evidence (`scanned-files.json`,
`evidence.md`, `tokens.source.json`, and `snippets/INDEX.json`).
The manifest guard validates only folders that ship `manifest.json`; it
does not require the bundled catalog to migrate all at once.
does not require the bundled catalog to migrate all at once. Rich import
fields are structural in PR0: when declared, paths must be safe and present,
JSON indexes must parse, and committed `components.manifest.json` files must
match a fresh derivation from `components.html` plus `tokens.css`. Runtime
prompt composition and picker behavior are unchanged until later PRs consume
those fields.
## Four layers, two questions

View file

@ -9,6 +9,12 @@
* optional component fixtures, and optional preview/assets directories
* without guessing from folder contents.
*
* PR0 for the import-project structure also defines optional index fields
* for richer imported systems (`USAGE.md`, preview pages, source evidence,
* and a rebuildable component manifest cache). These fields are structural
* only in PR0: guards validate their paths and JSON shape, but runtime
* behavior remains unchanged until later PRs consume them.
*
* PR1 deliberately defines the contract without changing runtime
* discovery. Existing DESIGN.md-only systems stay valid; this schema is
* enforced only for folders that choose to ship `manifest.json`.
@ -54,6 +60,39 @@ export type DesignSystemProjectFiles = {
readonly components?: "components.html";
};
export type DesignSystemProjectImportMode = "normalized" | "hybrid" | "verbatim";
export type DesignSystemProjectCraft = {
readonly applies: readonly string[];
readonly suggested: readonly string[];
readonly exemptions: readonly string[];
};
export type DesignSystemProjectFont = {
readonly family: string;
readonly file: string;
readonly weight?: number | string;
readonly style?: string;
};
export type DesignSystemProjectPreviewPage = {
readonly path: string;
readonly role?: string;
readonly title?: string;
};
export type DesignSystemProjectPreview = {
readonly dir: string;
readonly pages: readonly DesignSystemProjectPreviewPage[];
};
export type DesignSystemProjectSourceFiles = {
readonly scanned?: string;
readonly evidence?: string;
readonly tokens?: string;
readonly snippets?: string;
};
export type DesignSystemProjectManifest = {
readonly schemaVersion: typeof DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION;
/** Folder slug and stable picker id. Must match /^[a-z0-9-]+$/. */
@ -65,8 +104,22 @@ export type DesignSystemProjectManifest = {
readonly files: DesignSystemProjectFiles;
/** Optional static assets root. V1 fixes the directory name. */
readonly assetsDir?: "assets";
/** Optional preview root. V1 fixes the directory name. */
/** Optional legacy preview root. V1 fixes the directory name. */
readonly previewDir?: "preview";
/** Optional agent-facing router for richer imported packages. */
readonly usage?: string;
/** Optional rebuildable cache derived from components.html + tokens.css. */
readonly componentsManifest?: string;
/** Importer mode metadata. Defaults to hybrid for imported packages. */
readonly importMode?: DesignSystemProjectImportMode;
/** Optional craft metadata consumed by prompt assembly and guard checks. */
readonly craft?: DesignSystemProjectCraft;
/** Optional webfont files copied into the package. */
readonly fonts?: readonly DesignSystemProjectFont[];
/** Optional indexed preview pages for pull-channel and human review. */
readonly preview?: DesignSystemProjectPreview;
/** Optional imported-source evidence indexes. */
readonly sourceFiles?: DesignSystemProjectSourceFiles;
};
export type DesignSystemManifestValidationResult =
@ -83,6 +136,13 @@ const ALLOWED_TOP_LEVEL_KEYS = new Set([
"files",
"assetsDir",
"previewDir",
"usage",
"componentsManifest",
"importMode",
"craft",
"fonts",
"preview",
"sourceFiles",
]);
const ALLOWED_SOURCE_KEYS: Record<DesignSystemProjectSource["type"], ReadonlySet<string>> = {
@ -92,6 +152,11 @@ const ALLOWED_SOURCE_KEYS: Record<DesignSystemProjectSource["type"], ReadonlySet
};
const ALLOWED_FILES_KEYS = new Set(["design", "tokens", "components"]);
const ALLOWED_CRAFT_KEYS = new Set(["applies", "suggested", "exemptions"]);
const ALLOWED_FONT_KEYS = new Set(["family", "file", "weight", "style"]);
const ALLOWED_PREVIEW_KEYS = new Set(["dir", "pages"]);
const ALLOWED_PREVIEW_PAGE_KEYS = new Set(["path", "role", "title"]);
const ALLOWED_SOURCE_FILES_KEYS = new Set(["scanned", "evidence", "tokens", "snippets"]);
export function parseDesignSystemProjectManifest(
raw: string,
@ -131,6 +196,15 @@ export function validateDesignSystemProjectManifest(
if (value.assetsDir !== undefined) expectLiteral(errors, "$.assetsDir", value.assetsDir, "assets");
if (value.previewDir !== undefined) expectLiteral(errors, "$.previewDir", value.previewDir, "preview");
if (value.usage !== undefined) expectSafeRelativePath(errors, "$.usage", value.usage);
if (value.componentsManifest !== undefined) {
expectSafeRelativePath(errors, "$.componentsManifest", value.componentsManifest);
}
if (value.importMode !== undefined) validateImportMode(errors, value.importMode);
if (value.craft !== undefined) validateCraft(errors, value.craft);
if (value.fonts !== undefined) validateFonts(errors, value.fonts);
if (value.preview !== undefined) validatePreview(errors, value.preview);
if (value.sourceFiles !== undefined) validateSourceFiles(errors, value.sourceFiles);
if (errors.length > 0) return { ok: false, errors };
return { ok: true, manifest: value as DesignSystemProjectManifest };
@ -181,6 +255,87 @@ function validateFiles(errors: string[], value: unknown): void {
}
}
function validateImportMode(errors: string[], value: unknown): void {
if (value !== "normalized" && value !== "hybrid" && value !== "verbatim") {
errors.push("$.importMode must be one of normalized, hybrid, verbatim");
}
}
function validateCraft(errors: string[], value: unknown): void {
if (!isRecord(value)) {
errors.push("$.craft must be an object");
return;
}
rejectUnknownKeys(errors, "$.craft", value, ALLOWED_CRAFT_KEYS);
expectSlugArray(errors, "$.craft.applies", value.applies);
expectSlugArray(errors, "$.craft.suggested", value.suggested);
expectSlugArray(errors, "$.craft.exemptions", value.exemptions);
}
function validateFonts(errors: string[], value: unknown): void {
if (!Array.isArray(value)) {
errors.push("$.fonts must be an array");
return;
}
value.forEach((font, index) => {
const pathLabel = `$.fonts[${index}]`;
if (!isRecord(font)) {
errors.push(`${pathLabel} must be an object`);
return;
}
rejectUnknownKeys(errors, pathLabel, font, ALLOWED_FONT_KEYS);
expectNonEmptyString(errors, `${pathLabel}.family`, font.family);
expectSafeRelativePath(errors, `${pathLabel}.file`, font.file);
if (font.weight !== undefined && typeof font.weight !== "number" && typeof font.weight !== "string") {
errors.push(`${pathLabel}.weight must be a number or string`);
}
if (font.style !== undefined) expectNonEmptyString(errors, `${pathLabel}.style`, font.style);
});
}
function validatePreview(errors: string[], value: unknown): void {
if (!isRecord(value)) {
errors.push("$.preview must be an object");
return;
}
rejectUnknownKeys(errors, "$.preview", value, ALLOWED_PREVIEW_KEYS);
expectSafeRelativePath(errors, "$.preview.dir", value.dir);
if (!Array.isArray(value.pages)) {
errors.push("$.preview.pages must be an array");
return;
}
value.pages.forEach((page, index) => {
const pathLabel = `$.preview.pages[${index}]`;
if (!isRecord(page)) {
errors.push(`${pathLabel} must be an object`);
return;
}
rejectUnknownKeys(errors, pathLabel, page, ALLOWED_PREVIEW_PAGE_KEYS);
expectSafeRelativePath(errors, `${pathLabel}.path`, page.path);
if (page.role !== undefined) expectNonEmptyString(errors, `${pathLabel}.role`, page.role);
if (page.title !== undefined) expectNonEmptyString(errors, `${pathLabel}.title`, page.title);
});
}
function validateSourceFiles(errors: string[], value: unknown): void {
if (!isRecord(value)) {
errors.push("$.sourceFiles must be an object");
return;
}
rejectUnknownKeys(errors, "$.sourceFiles", value, ALLOWED_SOURCE_FILES_KEYS);
for (const key of ALLOWED_SOURCE_FILES_KEYS) {
const sourcePath = value[key];
if (sourcePath !== undefined) expectSafeRelativePath(errors, `$.sourceFiles.${key}`, sourcePath);
}
}
function rejectUnknownKeys(
errors: string[],
pathLabel: string,
@ -207,6 +362,36 @@ function expectNonEmptyString(errors: string[], pathLabel: string, value: unknow
}
}
function expectSlugArray(errors: string[], pathLabel: string, value: unknown): void {
if (!Array.isArray(value)) {
errors.push(`${pathLabel} must be an array of lowercase slugs`);
return;
}
value.forEach((entry, index) => {
if (typeof entry !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry)) {
errors.push(`${pathLabel}[${index}] must be a lowercase slug matching /^[a-z0-9]+(?:-[a-z0-9]+)*$/`);
}
});
}
function expectSafeRelativePath(errors: string[], pathLabel: string, value: unknown): void {
if (typeof value !== "string" || value.trim().length === 0) {
errors.push(`${pathLabel} must be a non-empty relative path`);
return;
}
if (value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || value.includes("\\")) {
errors.push(`${pathLabel} must be a safe relative path`);
return;
}
const segments = value.split("/");
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
errors.push(`${pathLabel} must be a safe relative path without empty, "." or ".." segments`);
}
}
function expectSlug(errors: string[], pathLabel: string, value: unknown): void {
if (typeof value !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)) {
errors.push(`${pathLabel} must be a lowercase slug matching /^[a-z0-9]+(?:-[a-z0-9]+)*$/`);

View file

@ -0,0 +1,32 @@
# Neutral Modern Usage
Auto-generated package guide for Open Design agents and reviewers.
## Read Order
1. Read this file first to understand the package contract.
2. Read `DESIGN.md` for the visual intent, constraints, and anti-patterns.
3. Paste `tokens.css` into the first artifact `<style>` block before writing component CSS.
4. Use `components.manifest.json` for the compact component inventory; open `components.html` only when exact selectors or states matter.
5. Inspect `preview/` pages when a visual sanity check is useful.
## Design Highlights
- Neutral Modern is the default product-system baseline for B2B tools, dashboards, and utility pages.
- The palette is intentionally quiet: off-white background, white surfaces, dark text, restrained cobalt accent.
- The component language is compact and work-focused: 8-12px radii, clear borders, no decorative shadows by default.
- Use the normalized OD tokens as the source of truth. This bundled package is not source-repository verbatim evidence.
## Do
- Keep layout density calm and scannable.
- Use `--accent` sparingly for primary actions, links, and one focal element.
- Reuse the component shapes from the manifest before inventing new patterns.
- Preserve the token names exactly so cross-brand switching stays reliable.
## Avoid
- Avoid decorative gradients, glass effects, neumorphism, and large ornamental surfaces.
- Avoid raw hex values outside the copied `:root` token block.
- Avoid more than three type sizes on one screen unless the artifact is a true editorial layout.
- Avoid treating this package as a marketing hero style; it is a product UI baseline.

View file

@ -0,0 +1,471 @@
{
"schemaVersion": 1,
"brandId": "default",
"source": {
"componentsHtml": "components.html",
"tokensCss": "tokens.css"
},
"fixture": {
"title": "Neutral Modern — reference components",
"description": "Reference fixture for design-systems/default. Every visible value comes from tokens.css; if the agent paste-replaces the :root block and reuses the component selectors below verbatim, the artifact passes lint without further audit.",
"styleBlockCount": 1,
"selectorCount": 50,
"classCount": 25,
"elementCount": 25
},
"tokens": {
"declared": [
"--accent",
"--accent-active",
"--accent-hover",
"--accent-on",
"--bg",
"--border",
"--border-soft",
"--container-gutter-desktop",
"--container-gutter-phone",
"--container-gutter-tablet",
"--container-max",
"--danger",
"--ease-standard",
"--elev-flat",
"--elev-raised",
"--elev-ring",
"--fg",
"--fg-2",
"--focus-ring",
"--font-body",
"--font-display",
"--font-mono",
"--leading-body",
"--leading-tight",
"--meta",
"--motion-base",
"--motion-fast",
"--muted",
"--radius-lg",
"--radius-md",
"--radius-pill",
"--radius-sm",
"--section-y-desktop",
"--section-y-phone",
"--section-y-tablet",
"--space-1",
"--space-12",
"--space-2",
"--space-20",
"--space-3",
"--space-4",
"--space-5",
"--space-6",
"--space-8",
"--success",
"--surface",
"--surface-warm",
"--text-2xl",
"--text-3xl",
"--text-4xl",
"--text-base",
"--text-lg",
"--text-sm",
"--text-xl",
"--text-xs",
"--tracking-display",
"--warn"
],
"referenced": [
"--accent",
"--accent-active",
"--accent-hover",
"--accent-on",
"--bg",
"--border",
"--container-gutter-desktop",
"--container-gutter-phone",
"--container-gutter-tablet",
"--container-max",
"--ease-standard",
"--fg",
"--focus-ring",
"--font-body",
"--font-display",
"--font-mono",
"--leading-body",
"--leading-tight",
"--motion-fast",
"--muted",
"--radius-md",
"--radius-pill",
"--radius-sm",
"--section-y-desktop",
"--section-y-phone",
"--section-y-tablet",
"--space-12",
"--space-2",
"--space-3",
"--space-4",
"--space-5",
"--space-6",
"--space-8",
"--success",
"--surface",
"--text-2xl",
"--text-3xl",
"--text-base",
"--text-lg",
"--text-sm",
"--text-xs",
"--tracking-display"
],
"unusedDeclared": [
"--border-soft",
"--danger",
"--elev-flat",
"--elev-raised",
"--elev-ring",
"--fg-2",
"--meta",
"--motion-base",
"--radius-lg",
"--space-1",
"--space-20",
"--surface-warm",
"--text-4xl",
"--text-xl",
"--warn"
],
"undeclaredReferenced": []
},
"selectors": [
".badge",
".badge-dot",
".badge-muted",
".badge-success",
".body-muted",
".body-sm",
".btn",
".btn-primary",
".btn-primary:active",
".btn-primary:hover",
".btn-secondary",
".btn-secondary:hover",
".btn:active",
".btn:focus-visible",
".card",
".container",
".eyebrow",
".features-grid",
".field",
".field input",
".field input::placeholder",
".field input:focus-visible",
".field label",
".field-help",
".form",
".form-actions",
".form-row",
".hero-actions",
".hero-grid",
".hero-meta",
".icon",
".lead",
".row-between",
".stack-3 > * + *",
".stack-4 > * + *",
".stack-6 > * + *",
"*",
"*::after",
"*::before",
"a",
"a:hover",
"body",
"h1",
"h2",
"h3",
"html",
"kbd",
"p",
"section",
"section + section"
],
"classes": [
"badge",
"badge-dot",
"badge-success",
"body-muted",
"body-sm",
"btn",
"btn-primary",
"btn-secondary",
"card",
"container",
"eyebrow",
"features-grid",
"field",
"field-help",
"form",
"form-actions",
"form-row",
"hero-actions",
"hero-grid",
"hero-meta",
"icon",
"lead",
"row-between",
"stack-3",
"stack-4"
],
"elements": [
"a",
"article",
"aside",
"body",
"button",
"div",
"form",
"h1",
"h2",
"h3",
"head",
"html",
"input",
"kbd",
"label",
"main",
"meta",
"p",
"path",
"section",
"span",
"style",
"svg",
"time",
"title"
],
"groups": [
{
"id": "buttons",
"label": "Buttons and calls to action",
"present": true,
"selectors": [
".btn",
".btn-primary",
".btn-primary:active",
".btn-primary:hover",
".btn-secondary",
".btn-secondary:hover",
".btn:active",
".btn:focus-visible"
],
"classes": [
"btn",
"btn-primary",
"btn-secondary"
],
"elements": [
"button"
],
"tokenReferences": [
"--accent-hover",
"--border",
"--ease-standard",
"--fg",
"--focus-ring",
"--motion-fast",
"--radius-sm",
"--space-2",
"--text-sm"
]
},
{
"id": "inputs",
"label": "Form fields and controls",
"present": true,
"selectors": [
".field",
".field input",
".field input::placeholder",
".field input:focus-visible",
".field label",
".field-help"
],
"classes": [
"field",
"field-help",
"form",
"form-actions",
"form-row"
],
"elements": [
"form",
"input",
"label"
],
"tokenReferences": [
"--border",
"--ease-standard",
"--fg",
"--motion-fast",
"--muted",
"--radius-sm",
"--space-2",
"--surface",
"--text-sm"
]
},
{
"id": "cards",
"label": "Cards and panels",
"present": true,
"selectors": [
".card"
],
"classes": [
"card"
],
"elements": [],
"tokenReferences": [
"--border",
"--radius-md",
"--space-3",
"--space-5",
"--surface"
]
},
{
"id": "badges",
"label": "Badges, chips, and status labels",
"present": true,
"selectors": [
".badge",
".badge-dot",
".badge-muted",
".badge-success"
],
"classes": [
"badge",
"badge-dot",
"badge-success"
],
"elements": [],
"tokenReferences": [
"--success"
]
},
{
"id": "links",
"label": "Links and inline actions",
"present": true,
"selectors": [
"a",
"a:hover"
],
"classes": [],
"elements": [
"a"
],
"tokenReferences": []
},
{
"id": "keyboard",
"label": "Keyboard hints",
"present": true,
"selectors": [
"kbd"
],
"classes": [],
"elements": [
"kbd"
],
"tokenReferences": []
},
{
"id": "icons",
"label": "Icon slots",
"present": true,
"selectors": [
".icon"
],
"classes": [
"icon"
],
"elements": [
"svg"
],
"tokenReferences": []
},
{
"id": "typography",
"label": "Typography scale and text utilities",
"present": true,
"selectors": [
".body-muted",
".body-sm",
".eyebrow",
".lead",
"h1",
"h2",
"h3"
],
"classes": [
"body-muted",
"body-sm",
"eyebrow",
"lead"
],
"elements": [
"h1",
"h2",
"h3",
"p"
],
"tokenReferences": [
"--font-display",
"--leading-tight",
"--muted",
"--text-2xl",
"--text-xs",
"--tracking-display"
]
},
{
"id": "layout",
"label": "Layout primitives",
"present": true,
"selectors": [
".container",
".row-between",
".stack-3 > * + *",
".stack-4 > * + *",
".stack-6 > * + *",
"section",
"section + section"
],
"classes": [
"container",
"features-grid",
"hero-grid",
"row-between",
"stack-3",
"stack-4"
],
"elements": [
"main",
"section"
],
"tokenReferences": [
"--border",
"--container-gutter-desktop",
"--container-gutter-phone",
"--container-gutter-tablet",
"--container-max",
"--space-4"
]
}
],
"literals": {
"colorExpressions": 3,
"pixelValues": 27,
"hardcodedFontFamilies": 5
}
}

View file

@ -12,5 +12,51 @@
"design": "DESIGN.md",
"tokens": "tokens.css",
"components": "components.html"
},
"usage": "USAGE.md",
"componentsManifest": "components.manifest.json",
"importMode": "normalized",
"craft": {
"applies": [],
"suggested": [
"color",
"accessibility-baseline"
],
"exemptions": []
},
"preview": {
"dir": "preview",
"pages": [
{
"path": "preview/colors.html",
"role": "colors",
"title": "Colors"
},
{
"path": "preview/typography.html",
"role": "typography",
"title": "Typography"
},
{
"path": "preview/spacing.html",
"role": "spacing",
"title": "Spacing"
},
{
"path": "preview/components-buttons.html",
"role": "buttons",
"title": "Buttons"
},
{
"path": "preview/components-inputs.html",
"role": "inputs",
"title": "Inputs"
},
{
"path": "preview/app.html",
"role": "app",
"title": "App Preview"
}
]
}
}

View file

@ -0,0 +1,103 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Neutral Modern app preview</title>
<link rel="stylesheet" href="../tokens.css" />
<style>
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
}
main {
max-width: var(--container-max);
margin: 0 auto;
padding: var(--space-8) var(--space-6);
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-6);
}
h1 {
margin: 0;
font-family: var(--font-display);
font-size: var(--text-2xl);
line-height: var(--leading-tight);
}
.grid {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: var(--space-5);
}
.card {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
padding: var(--space-5);
}
.muted {
color: var(--muted);
}
.btn {
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
padding: 10px 16px;
background: var(--accent);
color: var(--accent-on);
font: 600 var(--text-sm) / 1 var(--font-body);
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
th,
td {
padding: var(--space-3) 0;
border-bottom: 1px solid var(--border-soft);
text-align: left;
}
th {
color: var(--muted);
font-weight: 600;
}
@media (max-width: 760px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<main>
<header>
<div>
<h1>Review workspace</h1>
<p class="muted">A quiet operational layout using the default system.</p>
</div>
<button class="btn">New review</button>
</header>
<section class="grid">
<article class="card">
<table>
<thead><tr><th>Artifact</th><th>Status</th><th>Owner</th></tr></thead>
<tbody>
<tr><td>Dashboard shell</td><td>Ready</td><td>Design</td></tr>
<tr><td>Import flow</td><td>Review</td><td>Platform</td></tr>
<tr><td>Token audit</td><td>Queued</td><td>Quality</td></tr>
</tbody>
</table>
</article>
<aside class="card">
<h2>Quality note</h2>
<p class="muted">Use borders, spacing, and restrained accent color before reaching for extra chrome.</p>
</aside>
</section>
</main>
</body>
</html>

View file

@ -0,0 +1,66 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Neutral Modern colors</title>
<link rel="stylesheet" href="../tokens.css" />
<style>
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
}
main {
max-width: 920px;
margin: 0 auto;
padding: var(--space-12) var(--space-6);
}
h1 {
margin: 0 0 var(--space-6);
font-family: var(--font-display);
font-size: var(--text-2xl);
line-height: var(--leading-tight);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--space-4);
}
.swatch {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
overflow: hidden;
}
.color {
height: 88px;
background: var(--value);
}
.meta {
padding: var(--space-3);
color: var(--muted);
font-size: var(--text-sm);
}
strong {
display: block;
margin-bottom: var(--space-1);
color: var(--fg);
}
</style>
</head>
<body>
<main>
<h1>Color roles</h1>
<section class="grid">
<article class="swatch" style="--value: var(--bg)"><div class="color"></div><div class="meta"><strong>Background</strong>--bg</div></article>
<article class="swatch" style="--value: var(--surface)"><div class="color"></div><div class="meta"><strong>Surface</strong>--surface</div></article>
<article class="swatch" style="--value: var(--fg)"><div class="color"></div><div class="meta"><strong>Foreground</strong>--fg</div></article>
<article class="swatch" style="--value: var(--muted)"><div class="color"></div><div class="meta"><strong>Muted</strong>--muted</div></article>
<article class="swatch" style="--value: var(--border)"><div class="color"></div><div class="meta"><strong>Border</strong>--border</div></article>
<article class="swatch" style="--value: var(--accent)"><div class="color"></div><div class="meta"><strong>Accent</strong>--accent</div></article>
</section>
</main>
</body>
</html>

View file

@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Neutral Modern buttons</title>
<link rel="stylesheet" href="../tokens.css" />
<style>
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
}
main {
max-width: 760px;
margin: 0 auto;
padding: var(--space-12) var(--space-6);
}
.row {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
align-items: center;
}
.btn {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 16px;
background: transparent;
color: var(--fg);
font: 600 var(--text-sm) / 1 var(--font-body);
}
.btn-primary {
border-color: var(--accent);
background: var(--accent);
color: var(--accent-on);
}
.btn-secondary {
background: var(--surface);
}
</style>
</head>
<body>
<main>
<div class="row">
<button class="btn btn-primary">Create project</button>
<button class="btn btn-secondary">Review changes</button>
<button class="btn">Cancel</button>
</div>
</main>
</body>
</html>

View file

@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Neutral Modern inputs</title>
<link rel="stylesheet" href="../tokens.css" />
<style>
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
}
main {
max-width: 520px;
margin: 0 auto;
padding: var(--space-12) var(--space-6);
}
.field {
display: grid;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
label {
color: var(--fg);
font-size: var(--text-sm);
font-weight: 600;
}
input,
textarea {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
background: var(--surface);
color: var(--fg);
font: inherit;
}
input:focus,
textarea:focus {
border-color: var(--accent);
outline: none;
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 84%);
}
</style>
</head>
<body>
<main>
<div class="field"><label for="name">Project name</label><input id="name" value="Operations dashboard" /></div>
<div class="field"><label for="notes">Notes</label><textarea id="notes" rows="4">Keep the surface calm and scannable.</textarea></div>
</main>
</body>
</html>

View file

@ -0,0 +1,54 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Neutral Modern spacing</title>
<link rel="stylesheet" href="../tokens.css" />
<style>
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
}
main {
max-width: 840px;
margin: 0 auto;
padding: var(--space-12) var(--space-6);
}
h1 {
margin: 0 0 var(--space-6);
font-size: var(--text-2xl);
}
.row {
display: grid;
grid-template-columns: 120px 1fr;
gap: var(--space-4);
align-items: center;
margin-bottom: var(--space-4);
}
.bar {
width: var(--value);
height: var(--space-4);
border-radius: var(--radius-sm);
background: var(--accent);
}
.label {
color: var(--muted);
font-size: var(--text-sm);
}
</style>
</head>
<body>
<main>
<h1>Spacing ramp</h1>
<div class="row"><div class="label">--space-2</div><div class="bar" style="--value: var(--space-2)"></div></div>
<div class="row"><div class="label">--space-4</div><div class="bar" style="--value: var(--space-4)"></div></div>
<div class="row"><div class="label">--space-6</div><div class="bar" style="--value: var(--space-6)"></div></div>
<div class="row"><div class="label">--space-8</div><div class="bar" style="--value: var(--space-8)"></div></div>
<div class="row"><div class="label">--space-12</div><div class="bar" style="--value: var(--space-12)"></div></div>
<div class="row"><div class="label">--space-20</div><div class="bar" style="--value: var(--space-20)"></div></div>
</main>
</body>
</html>

View file

@ -0,0 +1,62 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Neutral Modern typography</title>
<link rel="stylesheet" href="../tokens.css" />
<style>
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
}
main {
max-width: 860px;
margin: 0 auto;
padding: var(--space-12) var(--space-6);
}
.specimen {
display: grid;
grid-template-columns: 120px 1fr;
gap: var(--space-5);
align-items: baseline;
padding: var(--space-5) 0;
border-bottom: 1px solid var(--border-soft);
}
.label {
color: var(--muted);
font-size: var(--text-sm);
}
.display {
font-family: var(--font-display);
font-size: var(--text-3xl);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-display);
}
.heading {
font-family: var(--font-display);
font-size: var(--text-2xl);
line-height: var(--leading-tight);
}
.body {
font-size: var(--text-base);
line-height: var(--leading-body);
}
.small {
color: var(--muted);
font-size: var(--text-sm);
line-height: var(--leading-body);
}
</style>
</head>
<body>
<main>
<section class="specimen"><div class="label">Display</div><div class="display">Quiet product confidence</div></section>
<section class="specimen"><div class="label">Heading</div><div class="heading">A scannable workspace heading</div></section>
<section class="specimen"><div class="label">Body</div><div class="body">Neutral Modern keeps body copy plain, compact, and easy to scan across dense product surfaces.</div></section>
<section class="specimen"><div class="label">Small</div><div class="small">Status text, table metadata, and secondary helper copy use the muted ramp.</div></section>
</main>
</body>
</html>

View file

@ -162,6 +162,49 @@ export interface DesignSystemSummary {
export interface DesignSystemDetail extends DesignSystemSummary {
body: string;
packageInfo?: DesignSystemPackageInfo;
}
export interface DesignSystemPackageInfo {
manifest?: {
schemaVersion: string;
id: string;
name: string;
category: string;
source?: { type?: string; url?: string; path?: string; branch?: string; commit?: string; importedAt?: string };
files?: {
design?: string;
tokens?: string;
components?: string;
};
usage?: string;
componentsManifest?: string;
importMode?: string;
craft?: {
applies?: string[];
suggested?: string[];
exemptions?: string[];
};
fonts?: Array<{ family?: string; weight?: string | number; style?: string; file?: string }>;
preview?: {
dir?: string;
pages?: Array<{ path?: string; role?: string; title?: string }>;
};
sourceFiles?: {
scanned?: string;
evidence?: string;
tokens?: string;
snippets?: string;
};
assetsDir?: string;
};
sourceEvidence?: {
scannedFileCount?: number;
tokenCount?: number;
snippetCount?: number;
confidence?: Record<string, string | number>;
evidenceExcerpt?: string;
};
}
export interface DesignSystemsResponse {
@ -311,6 +354,10 @@ export interface ImportLocalDesignSystemRequest {
baseDir: string;
/** Optional display name override for the generated design-system project. */
name?: string;
/** Import structure mode. Defaults to hybrid for real project imports. */
importMode?: 'normalized' | 'hybrid' | 'verbatim';
/** Craft sections that should actively apply when this system is used. */
craftApplies?: string[];
}
export interface ImportLocalDesignSystemResponse {
@ -324,6 +371,10 @@ export interface ImportGitHubDesignSystemRequest {
branch?: string;
/** Optional display name override for the generated design-system project. */
name?: string;
/** Import structure mode. Defaults to hybrid for real project imports. */
importMode?: 'normalized' | 'hybrid' | 'verbatim';
/** Craft sections that should actively apply when this system is used. */
craftApplies?: string[];
}
export interface ImportGitHubDesignSystemResponse {

View file

@ -3,8 +3,10 @@ import test from "node:test";
import {
DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION,
type DesignSystemProjectManifest,
validateDesignSystemProjectManifest,
} from "../design-systems/_schema/manifest.schema.ts";
import { validateManifestSemantics } from "./check-design-system-manifests.ts";
test("design-system project manifest schema accepts the v1 minimum shape", () => {
const result = validateDesignSystemProjectManifest({
@ -95,3 +97,153 @@ test("design-system project manifest schema rejects path drift and unknown keys"
assert.match(errors, /\$\.extra/);
}
});
test("design-system project manifest schema accepts import-project optional indexes", () => {
const result = validateDesignSystemProjectManifest({
schemaVersion: DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION,
id: "cherry-studio",
name: "Cherry Studio",
category: "AI & LLM",
source: {
type: "github",
url: "https://github.com/cherryhq/cherry-studio",
branch: "main",
commit: "abc123",
importedAt: "2026-05-19T00:00:00.000Z",
},
files: {
design: "DESIGN.md",
tokens: "tokens.css",
components: "components.html",
},
assetsDir: "assets",
previewDir: "preview",
usage: "USAGE.md",
componentsManifest: "components.manifest.json",
importMode: "hybrid",
craft: {
applies: ["color"],
suggested: ["accessibility-baseline"],
exemptions: [],
},
fonts: [
{ family: "Ubuntu", weight: 400, file: "fonts/ubuntu/Ubuntu-Regular.ttf" },
{ family: "Ubuntu", weight: 500, style: "normal", file: "fonts/ubuntu/Ubuntu-Medium.ttf" },
],
preview: {
dir: "preview",
pages: [
{ path: "preview/colors.html", role: "colors", title: "Colors" },
{ path: "preview/app.html", role: "app" },
],
},
sourceFiles: {
scanned: "source/scanned-files.json",
evidence: "source/evidence.md",
tokens: "source/tokens.source.json",
snippets: "source/snippets/INDEX.json",
},
});
assert.equal(result.ok, true);
if (result.ok) {
assert.equal(result.manifest.usage, "USAGE.md");
assert.equal(result.manifest.componentsManifest, "components.manifest.json");
assert.equal(result.manifest.importMode, "hybrid");
assert.equal(result.manifest.preview?.pages.length, 2);
}
});
test("design-system project manifest schema requires craft slug format", () => {
const result = validateDesignSystemProjectManifest({
schemaVersion: DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION,
id: "cherry-studio",
name: "Cherry Studio",
category: "AI & LLM",
source: { type: "local", path: "/tmp/cherry-studio" },
files: {
design: "DESIGN.md",
tokens: "tokens.css",
},
craft: {
applies: ["Color"],
suggested: ["accessibility baseline"],
exemptions: [""],
},
});
assert.equal(result.ok, false);
if (!result.ok) {
const errors = result.errors.join("\n");
assert.match(errors, /\$\.craft\.applies\[0\]/);
assert.match(errors, /\$\.craft\.suggested\[0\]/);
assert.match(errors, /\$\.craft\.exemptions\[0\]/);
}
});
test("design-system manifest semantics connect craft and importMode declarations to known evidence", () => {
const manifest: DesignSystemProjectManifest = {
schemaVersion: DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION,
id: "cherry-studio",
name: "Cherry Studio",
category: "AI & LLM",
source: { type: "local", path: "/tmp/cherry-studio" },
files: {
design: "DESIGN.md",
tokens: "tokens.css",
},
importMode: "verbatim",
craft: {
applies: ["color", "missing-craft"],
suggested: [],
exemptions: ["color"],
},
sourceFiles: {
scanned: "source/scanned-files.json",
},
};
const violations: string[] = [];
validateManifestSemantics(violations, "design-systems/cherry-studio/manifest.json", manifest, new Set(["color"]));
assert.deepEqual(violations, [
'design-systems/cherry-studio/manifest.json: $.craft.applies references unknown craft "missing-craft"',
'design-systems/cherry-studio/manifest.json: craft "color" cannot be both applied and exempted',
"design-systems/cherry-studio/manifest.json: verbatim imports must declare sourceFiles.tokens",
"design-systems/cherry-studio/manifest.json: verbatim imports must declare sourceFiles.snippets",
]);
});
test("design-system project manifest schema rejects unsafe import-project paths", () => {
const result = validateDesignSystemProjectManifest({
schemaVersion: DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION,
id: "cherry-studio",
name: "Cherry Studio",
category: "AI & LLM",
source: { type: "local", path: "/tmp/cherry-studio" },
files: {
design: "DESIGN.md",
tokens: "tokens.css",
},
usage: "../USAGE.md",
componentsManifest: "/tmp/components.manifest.json",
fonts: [{ family: "Ubuntu", file: "fonts\\Ubuntu-Regular.ttf" }],
preview: {
dir: "preview",
pages: [{ path: "preview//colors.html" }],
},
sourceFiles: {
scanned: "source/../scanned-files.json",
},
});
assert.equal(result.ok, false);
if (!result.ok) {
const errors = result.errors.join("\n");
assert.match(errors, /\$\.usage/);
assert.match(errors, /\$\.componentsManifest/);
assert.match(errors, /\$\.fonts\[0\]\.file/);
assert.match(errors, /\$\.preview\.pages\[0\]\.path/);
assert.match(errors, /\$\.sourceFiles\.scanned/);
}
});

View file

@ -10,13 +10,17 @@
* */
import { access, readFile, readdir } from "node:fs/promises";
import { isDeepStrictEqual } from "node:util";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseDesignSystemProjectManifest } from "../design-systems/_schema/manifest.schema.ts";
import type { DesignSystemProjectManifest } from "../design-systems/_schema/manifest.schema.ts";
import { extractComponentsManifest } from "../packages/contracts/src/design-systems/components-manifest.ts";
const repoRoot = path.resolve(import.meta.dirname, "..");
const designSystemsRoot = path.join(repoRoot, "design-systems");
const craftRoot = path.join(repoRoot, "craft");
const SKIPPED_DIRECTORIES = new Set(["_schema"]);
function toRepositoryPath(filePath: string): string {
@ -32,6 +36,10 @@ async function exists(filePath: string): Promise<boolean> {
}
}
async function readJson(filePath: string): Promise<unknown> {
return JSON.parse(await readFile(filePath, "utf8")) as unknown;
}
async function discoverManifestPaths(): Promise<string[]> {
let entries;
try {
@ -52,6 +60,7 @@ async function discoverManifestPaths(): Promise<string[]> {
export async function checkDesignSystemManifests(): Promise<boolean> {
const manifestPaths = await discoverManifestPaths();
const craftSlugs = await discoverCraftSlugs();
const violations: string[] = [];
for (const manifestPath of manifestPaths) {
@ -69,17 +78,20 @@ export async function checkDesignSystemManifests(): Promise<boolean> {
if (manifest.id !== folderSlug) {
violations.push(`${repositoryManifestPath}: $.id must match folder slug "${folderSlug}"`);
}
validateManifestSemantics(violations, repositoryManifestPath, manifest, craftSlugs);
const requiredFiles = [
manifest.files.design,
manifest.files.tokens,
...(manifest.files.components === undefined ? [] : [manifest.files.components]),
...(manifest.usage === undefined ? [] : [manifest.usage]),
...(manifest.componentsManifest === undefined ? [] : [manifest.componentsManifest]),
...(manifest.fonts ?? []).map((font) => font.file),
...(manifest.preview?.pages ?? []).map((page) => page.path),
...Object.values(manifest.sourceFiles ?? {}),
];
for (const fileName of requiredFiles) {
const target = path.join(brandRoot, fileName);
if (!(await exists(target))) {
violations.push(`${repositoryManifestPath}: ${fileName} is declared but ${toRepositoryPath(target)} does not exist`);
}
await requireDeclaredPathExists(violations, repositoryManifestPath, brandRoot, fileName);
}
if (manifest.assetsDir !== undefined && !(await exists(path.join(brandRoot, manifest.assetsDir)))) {
@ -88,6 +100,12 @@ export async function checkDesignSystemManifests(): Promise<boolean> {
if (manifest.previewDir !== undefined && !(await exists(path.join(brandRoot, manifest.previewDir)))) {
violations.push(`${repositoryManifestPath}: previewDir is declared but ${manifest.previewDir}/ does not exist`);
}
if (manifest.preview !== undefined && !(await exists(path.join(brandRoot, manifest.preview.dir)))) {
violations.push(`${repositoryManifestPath}: preview.dir is declared but ${manifest.preview.dir}/ does not exist`);
}
await validateDeclaredJsonFiles(violations, repositoryManifestPath, brandRoot, manifest.sourceFiles);
await validateComponentsManifestCache(violations, repositoryManifestPath, brandRoot, folderSlug, manifest.componentsManifest);
}
if (violations.length > 0) {
@ -102,6 +120,131 @@ export async function checkDesignSystemManifests(): Promise<boolean> {
return true;
}
async function discoverCraftSlugs(): Promise<Set<string>> {
try {
const entries = await readdir(craftRoot, { withFileTypes: true });
return new Set(
entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".md") && entry.name !== "README.md")
.map((entry) => entry.name.slice(0, -".md".length)),
);
} catch {
return new Set();
}
}
export function validateManifestSemantics(
violations: string[],
repositoryManifestPath: string,
manifest: DesignSystemProjectManifest,
craftSlugs: ReadonlySet<string>,
): void {
const applies = manifest.craft?.applies ?? [];
const suggested = manifest.craft?.suggested ?? [];
const exemptions = manifest.craft?.exemptions ?? [];
const declaredCraft = [
...applies.map((slug) => ({ slug, field: "applies" })),
...suggested.map((slug) => ({ slug, field: "suggested" })),
...exemptions.map((slug) => ({ slug, field: "exemptions" })),
];
for (const { slug, field } of declaredCraft) {
if (!craftSlugs.has(slug)) {
violations.push(`${repositoryManifestPath}: $.craft.${field} references unknown craft "${slug}"`);
}
}
const exemptionsSet = new Set(exemptions);
for (const slug of applies) {
if (exemptionsSet.has(slug)) {
violations.push(`${repositoryManifestPath}: craft "${slug}" cannot be both applied and exempted`);
}
}
if (manifest.importMode === "hybrid" && manifest.source?.type !== "bundled" && manifest.sourceFiles === undefined) {
violations.push(`${repositoryManifestPath}: hybrid imports must declare sourceFiles evidence`);
}
if (manifest.importMode === "verbatim" && manifest.source?.type !== "bundled") {
if (manifest.sourceFiles?.tokens === undefined) {
violations.push(`${repositoryManifestPath}: verbatim imports must declare sourceFiles.tokens`);
}
if (manifest.sourceFiles?.snippets === undefined) {
violations.push(`${repositoryManifestPath}: verbatim imports must declare sourceFiles.snippets`);
}
}
}
async function requireDeclaredPathExists(
violations: string[],
repositoryManifestPath: string,
brandRoot: string,
relativePath: string,
): Promise<void> {
const target = path.join(brandRoot, relativePath);
if (!(await exists(target))) {
violations.push(`${repositoryManifestPath}: ${relativePath} is declared but ${toRepositoryPath(target)} does not exist`);
}
}
async function validateDeclaredJsonFiles(
violations: string[],
repositoryManifestPath: string,
brandRoot: string,
sourceFiles: Record<string, string | undefined> | undefined,
): Promise<void> {
const jsonPaths = [
sourceFiles?.scanned,
sourceFiles?.tokens,
sourceFiles?.snippets,
].filter((fileName): fileName is string => fileName !== undefined);
for (const fileName of jsonPaths) {
try {
await readJson(path.join(brandRoot, fileName));
} catch (error) {
violations.push(
`${repositoryManifestPath}: ${fileName} is declared as JSON but could not be parsed: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
}
async function validateComponentsManifestCache(
violations: string[],
repositoryManifestPath: string,
brandRoot: string,
folderSlug: string,
declaredComponentsManifest: string | undefined,
): Promise<void> {
const cachePath = path.join(brandRoot, declaredComponentsManifest ?? "components.manifest.json");
if (!(await exists(cachePath))) return;
try {
const [cachedManifest, fixtureHtml, tokensCss] = await Promise.all([
readJson(cachePath),
readFile(path.join(brandRoot, "components.html"), "utf8"),
readFile(path.join(brandRoot, "tokens.css"), "utf8"),
]);
const derivedManifest = extractComponentsManifest({
brandId: folderSlug,
fixtureHtml,
tokensCss,
});
if (!isDeepStrictEqual(cachedManifest, derivedManifest)) {
violations.push(
`${repositoryManifestPath}: ${toRepositoryPath(cachePath)} is stale; regenerate it from components.html + tokens.css`,
);
}
} catch (error) {
violations.push(
`${repositoryManifestPath}: failed to validate ${toRepositoryPath(cachePath)}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const ok = await checkDesignSystemManifests();
if (!ok) process.exitCode = 1;

View file

@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION } from "../design-systems/_schema/manifest.schema.ts";
import type { DesignSystemProjectManifest } from "../design-systems/_schema/manifest.schema.ts";
import { evaluateDesignSystemPackageQuality } from "./check-design-system-package-quality.ts";
const baseManifest: DesignSystemProjectManifest = {
schemaVersion: DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION,
id: "sample",
name: "Sample",
category: "Starter",
source: { type: "bundled", origin: "test" },
files: {
design: "DESIGN.md",
tokens: "tokens.css",
components: "components.html",
},
usage: "USAGE.md",
componentsManifest: "components.manifest.json",
preview: {
dir: "preview",
pages: [
{ path: "preview/colors.html", role: "colors", title: "Colors" },
{ path: "preview/typography.html", role: "typography", title: "Typography" },
{ path: "preview/spacing.html", role: "spacing", title: "Spacing" },
],
},
};
test("design-system package quality scores migrated rich packages", () => {
const result = evaluateDesignSystemPackageQuality({
id: "sample",
manifest: baseManifest,
designMd: [
"# Sample",
"## One",
"## Two",
"## Three",
"## Four",
"## Five",
"## Six",
"## Seven",
].join("\n"),
tokensCss: Array.from({ length: 26 }, (_, index) => `--token-${index}: ${index}px;`).join("\n"),
usageMd: ["## Read Order", "## Design Highlights", "## Do", "## Avoid"].join("\n\n"),
componentsHtml: `
<style>
.btn { color: var(--token-1); }
.field { color: var(--token-2); }
.card { color: var(--token-3); }
.badge { color: var(--token-4); }
.link { color: var(--token-5); }
.icon { color: var(--token-6); }
.layout { color: var(--token-7); }
h1 { color: var(--token-8); }
h2 { color: var(--token-9); }
section { color: var(--token-10); }
</style>
<button class="btn">Button</button>
<label class="field"><input /></label>
<article class="card"><span class="badge">New</span><a class="link">Link</a></article>
<span class="icon"></span><section class="layout"><h1>Title</h1><h2>Subtitle</h2></section>
`,
});
assert.equal(result.migrated, true);
assert.equal(result.score, 100);
assert.deepEqual(result.violations, []);
});
test("design-system package quality leaves minimal manifest projects alone", () => {
const result = evaluateDesignSystemPackageQuality({
id: "legacy",
manifest: {
schemaVersion: DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION,
id: "legacy",
name: "Legacy",
category: "Starter",
source: { type: "bundled", origin: "test" },
files: {
design: "DESIGN.md",
tokens: "tokens.css",
},
},
designMd: "# Legacy",
tokensCss: "",
});
assert.equal(result.migrated, false);
assert.deepEqual(result.violations, []);
});

View file

@ -0,0 +1,196 @@
import { access, readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseDesignSystemProjectManifest } from "../design-systems/_schema/manifest.schema.ts";
import type { DesignSystemProjectManifest } from "../design-systems/_schema/manifest.schema.ts";
import { extractComponentsManifest } from "../packages/contracts/src/design-systems/components-manifest.ts";
const repoRoot = path.resolve(import.meta.dirname, "..");
const designSystemsRoot = path.join(repoRoot, "design-systems");
const SKIPPED_DIRECTORIES = new Set(["_schema"]);
export type DesignSystemPackageQualityInput = {
readonly id: string;
readonly manifest: DesignSystemProjectManifest;
readonly designMd: string;
readonly tokensCss: string;
readonly componentsHtml?: string | undefined;
readonly usageMd?: string | undefined;
};
export type DesignSystemPackageQualityResult = {
readonly migrated: boolean;
readonly score: number;
readonly checks: readonly string[];
readonly violations: readonly string[];
};
export function evaluateDesignSystemPackageQuality(
input: DesignSystemPackageQualityInput,
): DesignSystemPackageQualityResult {
const checks: string[] = [];
const violations: string[] = [];
const migrated = isMigratedPackage(input.manifest);
if (!migrated) {
return { migrated: false, score: 0, checks, violations };
}
recordMinimum("DESIGN.md section coverage", countMarkdownH2(input.designMd) >= 7);
recordMinimum("tokens.css token coverage", collectCssTokenNames(input.tokensCss).size >= 26);
if (input.manifest.usage !== undefined) {
const usage = input.usageMd ?? "";
for (const heading of ["Read Order", "Design Highlights", "Do", "Avoid"]) {
recordMinimum(`USAGE.md includes ${heading}`, hasMarkdownH2(usage, heading));
}
} else {
violations.push("rich packages must declare usage");
}
if (input.manifest.componentsManifest !== undefined && input.componentsHtml !== undefined) {
const manifest = extractComponentsManifest({
brandId: input.id,
fixtureHtml: input.componentsHtml,
tokensCss: input.tokensCss,
});
recordMinimum("component fixture selectors", manifest.fixture.selectorCount >= 10);
recordMinimum("component fixture token references", manifest.tokens.referenced.length >= 8);
recordMinimum("component groups present", manifest.groups.filter((group) => group.present).length >= 4);
} else {
violations.push("rich packages must declare componentsManifest and components.html");
}
const previewPages = input.manifest.preview?.pages ?? [];
recordMinimum("preview page count", previewPages.length >= 3);
for (const role of ["colors", "typography", "spacing"]) {
recordMinimum(`preview includes ${role}`, previewPages.some((page) => page.role === role));
}
if (input.manifest.source.type !== "bundled") {
recordMinimum("imported package has source evidence", input.manifest.sourceFiles !== undefined);
recordMinimum("imported package has token evidence", input.manifest.sourceFiles?.tokens !== undefined);
}
const score = checks.length === 0 ? 0 : Math.round(((checks.length - violations.length) / checks.length) * 100);
return { migrated: true, score, checks, violations };
function recordMinimum(label: string, passed: boolean): void {
checks.push(label);
if (!passed) violations.push(label);
}
}
export async function checkDesignSystemPackageQuality(): Promise<boolean> {
const brandRoots = await discoverManifestBrandRoots();
const violations: string[] = [];
let migratedCount = 0;
let totalScore = 0;
for (const brandRoot of brandRoots) {
const manifestPath = path.join(brandRoot, "manifest.json");
const repositoryManifestPath = toRepositoryPath(manifestPath);
const parsed = parseDesignSystemProjectManifest(await readFile(manifestPath, "utf8"));
if (!parsed.ok) continue;
const manifest = parsed.manifest;
const [designMd, tokensCss, componentsHtml, usageMd] = await Promise.all([
readFile(path.join(brandRoot, manifest.files.design), "utf8"),
readFile(path.join(brandRoot, manifest.files.tokens), "utf8"),
manifest.files.components === undefined
? Promise.resolve(undefined)
: readFile(path.join(brandRoot, manifest.files.components), "utf8"),
manifest.usage === undefined
? Promise.resolve(undefined)
: readFile(path.join(brandRoot, manifest.usage), "utf8"),
]);
const result = evaluateDesignSystemPackageQuality({
id: manifest.id,
manifest,
designMd,
tokensCss,
componentsHtml,
usageMd,
});
if (!result.migrated) continue;
migratedCount += 1;
totalScore += result.score;
if (result.violations.length > 0) {
for (const violation of result.violations) {
violations.push(`${repositoryManifestPath}: ${violation}`);
}
}
}
if (violations.length > 0) {
console.error("Design system package quality violations:");
for (const violation of violations) console.error(`- ${violation}`);
return false;
}
const averageScore = migratedCount === 0 ? 0 : Math.round(totalScore / migratedCount);
console.log(
`Design system package quality passed: ${migratedCount} migrated package${migratedCount === 1 ? "" : "s"} checked; average score ${averageScore}.`,
);
return true;
}
async function discoverManifestBrandRoots(): Promise<string[]> {
const entries = await readdir(designSystemsRoot, { withFileTypes: true });
const roots: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || SKIPPED_DIRECTORIES.has(entry.name)) continue;
const brandRoot = path.join(designSystemsRoot, entry.name);
if (await exists(path.join(brandRoot, "manifest.json"))) roots.push(brandRoot);
}
return roots.sort((a, b) => a.localeCompare(b));
}
async function exists(filePath: string): Promise<boolean> {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
function isMigratedPackage(manifest: DesignSystemProjectManifest): boolean {
return (
manifest.usage !== undefined ||
manifest.componentsManifest !== undefined ||
manifest.preview !== undefined ||
manifest.sourceFiles !== undefined
);
}
function countMarkdownH2(markdown: string): number {
return markdown.split(/\r?\n/).filter((line) => /^##\s+\S/.test(line)).length;
}
function hasMarkdownH2(markdown: string, heading: string): boolean {
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`^##\\s+${escaped}\\s*$`, "m").test(markdown);
}
function collectCssTokenNames(css: string): Set<string> {
const tokens = new Set<string>();
const tokenPattern = /--[A-Za-z0-9_-]+\s*:/g;
let match: RegExpExecArray | null;
while ((match = tokenPattern.exec(css)) !== null) {
tokens.add(match[0].slice(0, -1).trim());
}
return tokens;
}
function toRepositoryPath(filePath: string): string {
return path.relative(repoRoot, filePath).split(path.sep).join("/");
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const ok = await checkDesignSystemPackageQuality();
if (!ok) process.exitCode = 1;
}

View file

@ -2,6 +2,7 @@ import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { checkDesignSystemManifests } from "./check-design-system-manifests.ts";
import { checkDesignSystemPackageQuality } from "./check-design-system-package-quality.ts";
import { checkDesignSystemComponentFixtureReport } from "./check-components-fixtures.ts";
import { checkDesignSystemFlagParity } from "./check-design-system-flag-parity.ts";
import { checkComponentsManifestExtraction } from "./check-components-manifest-extraction.ts";
@ -903,6 +904,7 @@ const checks: GuardCheck[] = [
{ name: "tools layout", run: checkToolsLayout },
{ name: "style policy", run: checkStylePolicy },
{ name: "design system manifests", run: checkDesignSystemManifests },
{ name: "design system package quality", run: checkDesignSystemPackageQuality },
{ name: "design system component fixture report", run: checkDesignSystemComponentFixtureReport },
{ name: "design system token-fixture sync", run: checkDesignSystemTokenFixtureSync },
{ name: "design system A1 required tokens", run: checkDesignSystemA1RequiredTokens },

View file

@ -0,0 +1,306 @@
# Design System Import Project Structure
## Purpose
Open Design needs imported design systems to satisfy four stakeholders at
once:
- **Push channel**: system-prompt injection must stay small, dense,
schema-aligned, and interchangeable across the bundled catalog.
- **Pull channel**: agents need richer indexed files for high-fidelity
reconstruction when the task calls for it.
- **Importer**: extraction from a real project must not discard scanned
evidence, source naming, assets, or representative component patterns.
- **Legacy fallback**: existing `DESIGN.md`-only systems must keep working
without edits.
The target structure below keeps the current runtime path intact while
adding richer optional layers for imported systems.
## Target Shape
```text
design-systems/<slug>/
│ ── Protocol layer: push channel ─────────────────────────────
├── USAGE.md
├── manifest.json
├── DESIGN.md
├── tokens.css
├── components.html
├── components.manifest.json
│ ── Expression layer: pull channel and human preview ─────────
├── assets/
├── fonts/
├── preview/
│ ├── colors.html
│ ├── typography.html
│ ├── spacing.html
│ ├── components-buttons.html
│ ├── components-inputs.html
│ └── app.html
│ ── Evaluation layer: importer evidence ─────────────────────
└── source/
├── scanned-files.json
├── evidence.md
├── tokens.source.json
└── snippets/
├── INDEX.json
├── Sidebar.tsx
└── MessageBubble.tsx
```
Only `manifest.json`, `DESIGN.md`, and `tokens.css` are mandatory for new
Design System Project folders. `components.html` remains optional in the
schema, but imported web systems should generate it by default. Legacy
folders without `manifest.json` continue to use the existing `DESIGN.md`
fallback path.
## File Roles
### `USAGE.md`
`USAGE.md` is the agent-facing router for the design-system package. It
absorbs the useful part of Claude-style `SKILL.md` files without turning a
design system into a functional skill or colliding with repository
`AGENTS.md` contributor instructions.
It should be injected before `DESIGN.md` in the push channel because it
tells the agent why and when to read each file:
```md
# Cherry Studio Usage
## Read Order
1. Read `DESIGN.md` for visual principles and product context.
2. Paste `tokens.css` into the first `<style>` block.
3. Use `components.manifest.json` for available component patterns.
4. Pull `preview/app.html` when layout fidelity matters.
5. Pull `source/snippets/*` only when verbatim source behavior matters.
## Design Highlights
- Compact desktop chat client.
- Vibrant green accent.
- Three-column chat layout.
- Ubuntu typography.
## Do
- Preserve compact controls and dense sidebars.
- Reuse brand assets when the product identity is visible.
## Avoid
- Marketing-style landing layouts for core app surfaces.
- Decorative gradients unless source evidence supports them.
```
When `USAGE.md` is absent, the daemon should inject a small default guide:
> Read `DESIGN.md` for visual principles, paste `tokens.css` verbatim into
> the first `<style>` block, and match component shapes from
> `components.html` or its manifest when available.
Importer-generated `USAGE.md` files should be marked as auto-generated and
reviewable. The importer can derive the first draft from manifest contents,
`DESIGN.md` product context, high-confidence tokens, UI kit/layout signals,
and source evidence.
### `tokens.css` and `source/tokens.source.json`
`tokens.css` is the normalized OD token contract. It must use the standard
schema names such as `--bg`, `--fg`, `--accent`, and the A1/A2/B-slot set.
This keeps cross-brand artifacts interchangeable.
Original project token names and evidence belong in `source/tokens.source.json`
rather than mixed into the normalized token file. That file can record source
variable names, source file paths, extraction strategy, confidence, and any
aliases needed by source snippets.
### `components.html` and `components.manifest.json`
`components.html` is the compact worked fixture for the push channel. It is
human-readable and prompt-efficient.
`components.manifest.json` is a rebuildable cache derived from
`components.html` plus `tokens.css`. It follows the same source/cache pattern
as `_schema/tokens.schema.ts` and `_schema/defaults.css`:
| Pair | Source | Cache / mirror | Guard |
| --- | --- | --- | --- |
| `_schema/tokens.schema.ts``_schema/defaults.css` | TS source | CSS mirror | A2 defaults parity |
| `components.html``components.manifest.json` | HTML fixture | JSON cache | component manifest drift |
Three states are valid:
| State | Guard behavior | Runtime behavior |
| --- | --- | --- |
| Missing | Pass | Daemon derives from `components.html` on demand |
| Present and fresh | Pass | Daemon may read cache directly |
| Present but drifted | Fail | Regenerate the cache |
Importer output should include `components.manifest.json` by default so PR
reviewers can inspect the exact semantic summary agents will receive.
Hand-authored systems may omit it.
### `preview/`
`preview/` is for human inspection and pull-channel exploration. Prefer
small grouped pages rather than a single catch-all page:
- `preview/colors.html`
- `preview/typography.html`
- `preview/spacing.html`
- `preview/components-buttons.html`
- `preview/components-inputs.html`
- `preview/app.html`
Avoid naming a preview file `preview/components.html`; the root
`components.html` already has protocol meaning.
### `source/`
`source/` is importer-only evidence. It keeps the extraction auditable and
prevents the importer from throwing away useful material:
- `scanned-files.json`: inventory of scanned files and why they mattered.
- `evidence.md`: human-readable extraction notes and source excerpts.
- `tokens.source.json`: original names, aliases, confidence, and source
locations for token extraction.
- `snippets/INDEX.json`: indexed source slices with roles, languages, sizes,
and original source paths.
Example snippet index entry:
```json
{
"path": "source/snippets/Sidebar.tsx",
"role": "navigation",
"language": "tsx",
"sourcePath": "src/renderer/components/Sidebar.tsx",
"bytes": 18420,
"reason": "Primary app navigation pattern"
}
```
## Manifest Additions
PR0 should extend the v1 manifest with optional index fields only. These
fields establish paths and shapes; they do not imply full runtime behavior
until later PRs wire the consumer paths.
```json
{
"usage": "USAGE.md",
"componentsManifest": "components.manifest.json",
"importMode": "hybrid",
"craft": { "applies": [], "suggested": [], "exemptions": [] },
"fonts": [],
"preview": { "dir": "preview", "pages": [] },
"sourceFiles": {
"scanned": "source/scanned-files.json",
"evidence": "source/evidence.md",
"tokens": "source/tokens.source.json",
"snippets": "source/snippets/INDEX.json"
}
}
```
`importMode` values:
- `normalized`: OD-normalized files only. This is the implicit default for
bundled and hand-authored systems when the field is absent.
- `hybrid`: normalized OD files plus source/evidence files. This is the
default for local and GitHub importers.
- `verbatim`: preserve source naming and structure as much as possible. This
should be user-selected, not the default importer behavior.
`craft` is declarative in PR0:
- `applies`: craft rules the package claims to satisfy.
- `suggested`: craft rules the agent may consult.
- `exemptions`: craft rules intentionally not claimed by the imported source.
PR0 should not make `craft` change guard or prompt behavior.
## Push And Pull Consumption
The push channel stays compact and deterministic:
```text
## How to use this design system — <brand>
[USAGE.md, or default boilerplate]
## Active design system — <brand>
[DESIGN.md]
## Active design system tokens — <brand>
[tokens.css]
## Reference fixture — <brand>
[components.manifest.json summary, or derived components manifest, or fixture fallback]
## Pull-layer files available on demand
[short list from manifest preview/source indexes]
```
The pull channel is explicit and bounded. A future
`read_design_system_file(brand_id, relative_path)` tool should only read
paths allowed by the active manifest. Agents use it for `preview/app.html`,
source snippets, original token evidence, and other rich files that are too
large or too situational for every prompt.
## PR Plan
### PR0 — Schema and Guard Shape
- Add optional manifest fields: `usage`, `componentsManifest`,
`importMode`, `craft`, `fonts`, `preview`, and `sourceFiles`.
- Validate only structure, safe relative paths, JSON readability, and declared
file/directory existence.
- Add the optional `components.manifest.json` drift guard: missing passes;
present must match the derived manifest from `components.html + tokens.css`.
- Do not require `USAGE.md` or any rich layer for legacy systems.
- Do not make `craft` or `importMode` affect runtime behavior yet.
### PR1 — Importer Preservation
- Local and GitHub importers default to `importMode: "hybrid"`.
- Importers generate `USAGE.md`, `components.manifest.json`, grouped
`preview/` pages, `source/tokens.source.json`, `source/evidence.md`,
`source/scanned-files.json`, and representative `source/snippets/`.
- Web imports generate `preview/app.html` when enough layout evidence exists.
- Image, video, and audio imports use sample/evidence files instead of
forcing a web UI kit.
### PR2 — Runtime Semantics
- Daemon reads `USAGE.md` before `DESIGN.md`, falling back to the default
boilerplate.
- Daemon prefers fresh `components.manifest.json` when present and derives
when absent.
- Prompt composer adds a short pull-layer file index.
### PR3 — Pull Tool
- Add a bounded `read_design_system_file` tool or equivalent daemon endpoint.
- Restrict reads to manifest-indexed paths.
- Surface useful labels/roles from `preview.pages` and snippet indexes.
### PR4 — Craft And Import Modes
- Wire `craft.applies`, `craft.suggested`, and `craft.exemptions` into guard
and prompt behavior.
- Make `importMode` visible in importer UI and runtime summaries.
## Out Of Scope For PR0
- Enforcing `USAGE.md` section quality or required H2s.
- Verifying that `Read Order` references exist.
- Scoring `Do` / `Avoid` quality.
- Requiring preview pages or source evidence for bundled legacy systems.
- Changing the existing `DESIGN.md` fallback behavior.