mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
b311885bee
commit
6a08dfe111
38 changed files with 4255 additions and 52 deletions
|
|
@ -4,6 +4,7 @@ import { runDaemonCliStartup } from './daemon-startup.js';
|
||||||
import { runLiveArtifactsMcpServer } from './mcp-live-artifacts-server.js';
|
import { runLiveArtifactsMcpServer } from './mcp-live-artifacts-server.js';
|
||||||
import { runArtifactsCli } from './artifacts-cli.js';
|
import { runArtifactsCli } from './artifacts-cli.js';
|
||||||
import { runConnectorsToolCli } from './tools-connectors-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 { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js';
|
||||||
import { splitResearchSubcommand } from './research/cli-args.js';
|
import { splitResearchSubcommand } from './research/cli-args.js';
|
||||||
import { resolveDaemonUrl } from './daemon-url.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.stderr.write(`${JSON.stringify({ ok: false, error: { message } })}\n`);
|
||||||
process.exitCode = 1;
|
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 {
|
} else {
|
||||||
await runDaemonCliStartup(argv, { printHelp: printRootHelp });
|
await runDaemonCliStartup(argv, { printHelp: printRootHelp });
|
||||||
}
|
}
|
||||||
|
|
@ -282,6 +293,9 @@ function printRootHelp() {
|
||||||
od tools connectors <list|execute|github-design-context> [options]
|
od tools connectors <list|execute|github-design-context> [options]
|
||||||
Discover and execute configured connectors.
|
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
|
od mcp live-artifacts
|
||||||
Start the MCP server exposing live-artifact and connector tools.
|
Start the MCP server exposing live-artifact and connector tools.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
export type GitHubDesignSystemImportOptions = Pick<
|
export type GitHubDesignSystemImportOptions = Pick<
|
||||||
LocalDesignSystemImportOptions,
|
LocalDesignSystemImportOptions,
|
||||||
'name' | 'now' | 'reservedIds'
|
'craftApplies' | 'importMode' | 'name' | 'now' | 'reservedIds'
|
||||||
> & {
|
> & {
|
||||||
branch?: string;
|
branch?: string;
|
||||||
gitBin?: string;
|
gitBin?: string;
|
||||||
|
|
@ -63,6 +63,8 @@ export async function importGitHubDesignSystemProject(
|
||||||
fallbackName: parsed.repo,
|
fallbackName: parsed.repo,
|
||||||
...(options.name ? { name: options.name } : {}),
|
...(options.name ? { name: options.name } : {}),
|
||||||
...(options.reservedIds ? { reservedIds: options.reservedIds } : {}),
|
...(options.reservedIds ? { reservedIds: options.reservedIds } : {}),
|
||||||
|
...(options.importMode ? { importMode: options.importMode } : {}),
|
||||||
|
...(options.craftApplies ? { craftApplies: options.craftApplies } : {}),
|
||||||
source: {
|
source: {
|
||||||
type: 'github',
|
type: 'github',
|
||||||
url: parsed.cloneUrl,
|
url: parsed.cloneUrl,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { copyFile, mkdir, readFile, readdir, realpath, stat, writeFile } from 'node:fs/promises';
|
import { copyFile, mkdir, readFile, readdir, realpath, stat, writeFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { extractComponentsManifest } from '@open-design/contracts';
|
||||||
|
|
||||||
export type LocalDesignSystemImportResult = {
|
export type LocalDesignSystemImportResult = {
|
||||||
id: string;
|
id: string;
|
||||||
dir: string;
|
dir: string;
|
||||||
|
|
@ -13,6 +15,8 @@ export type LocalDesignSystemImportOptions = {
|
||||||
fallbackName?: string;
|
fallbackName?: string;
|
||||||
reservedIds?: Iterable<string>;
|
reservedIds?: Iterable<string>;
|
||||||
source?: DesignSystemProjectSource;
|
source?: DesignSystemProjectSource;
|
||||||
|
importMode?: 'normalized' | 'hybrid' | 'verbatim';
|
||||||
|
craftApplies?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DesignSystemProjectSource =
|
export type DesignSystemProjectSource =
|
||||||
|
|
@ -38,7 +42,9 @@ type ProjectScan = {
|
||||||
cssVariables: CssVariable[];
|
cssVariables: CssVariable[];
|
||||||
tailwindSignals: string[];
|
tailwindSignals: string[];
|
||||||
assets: AssetCandidate[];
|
assets: AssetCandidate[];
|
||||||
|
fonts: FileCandidate[];
|
||||||
components: ComponentSignal[];
|
components: ComponentSignal[];
|
||||||
|
files: ProjectFile[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type CssVariable = {
|
type CssVariable = {
|
||||||
|
|
@ -53,9 +59,23 @@ type AssetCandidate = {
|
||||||
size: number;
|
size: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FileCandidate = {
|
||||||
|
absPath: string;
|
||||||
|
relPath: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectFile = {
|
||||||
|
absPath: string;
|
||||||
|
relPath: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
type ComponentSignal = {
|
type ComponentSignal = {
|
||||||
name: string;
|
name: string;
|
||||||
relPath: string;
|
relPath: string;
|
||||||
|
absPath: string;
|
||||||
|
size: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const IGNORED_DIRS = new Set([
|
const IGNORED_DIRS = new Set([
|
||||||
|
|
@ -76,6 +96,7 @@ const IGNORED_DIRS = new Set([
|
||||||
const STYLE_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less']);
|
const STYLE_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less']);
|
||||||
const COMPONENT_EXTENSIONS = new Set(['.tsx', '.jsx', '.vue', '.svelte']);
|
const COMPONENT_EXTENSIONS = new Set(['.tsx', '.jsx', '.vue', '.svelte']);
|
||||||
const ASSET_EXTENSIONS = new Set(['.svg', '.png', '.jpg', '.jpeg', '.webp', '.ico']);
|
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 COMPONENT_NAMES = ['Button', 'Input', 'Card', 'Nav', 'Navbar', 'Sidebar'];
|
||||||
const TOKEN_FALLBACKS = {
|
const TOKEN_FALLBACKS = {
|
||||||
bg: '#f8fafc',
|
bg: '#f8fafc',
|
||||||
|
|
@ -116,19 +137,44 @@ export async function importLocalDesignSystemProject(
|
||||||
const id = await nextAvailableSlug(userDesignSystemsRoot, slugify(displayName), options.reservedIds);
|
const id = await nextAvailableSlug(userDesignSystemsRoot, slugify(displayName), options.reservedIds);
|
||||||
const outDir = path.join(userDesignSystemsRoot, id);
|
const outDir = path.join(userDesignSystemsRoot, id);
|
||||||
await mkdir(outDir, { recursive: true });
|
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'];
|
const files = ['USAGE.md', 'DESIGN.md', 'tokens.css', 'components.html', 'components.manifest.json', 'manifest.json'];
|
||||||
await writeFile(path.join(outDir, 'DESIGN.md'), renderDesignMd(id, displayName, scan), 'utf8');
|
const designMd = renderDesignMd(id, displayName, scan);
|
||||||
await writeFile(path.join(outDir, 'tokens.css'), renderTokensCss(scan), 'utf8');
|
const tokensCss = renderTokensCss(scan);
|
||||||
await writeFile(path.join(outDir, 'components.html'), renderComponentsHtml(displayName), 'utf8');
|
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(
|
await writeFile(
|
||||||
path.join(outDir, 'manifest.json'),
|
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',
|
'utf8',
|
||||||
);
|
);
|
||||||
|
|
||||||
const copiedAssets = await copyAssets(scan.assets, outDir);
|
const copiedAssets = await copyAssets(scan.assets, outDir);
|
||||||
|
const copiedFonts = await copyFonts(scan.fonts, outDir);
|
||||||
files.push(...copiedAssets);
|
files.push(...copiedAssets);
|
||||||
|
files.push(...copiedFonts);
|
||||||
return { id, dir: outDir, files };
|
return { id, dir: outDir, files };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,7 +209,9 @@ async function scanProject(sourceRoot: string): Promise<ProjectScan> {
|
||||||
cssVariables,
|
cssVariables,
|
||||||
tailwindSignals: await readTailwindSignals(sourceRoot),
|
tailwindSignals: await readTailwindSignals(sourceRoot),
|
||||||
assets: await findAssets(sourceRoot, files),
|
assets: await findAssets(sourceRoot, files),
|
||||||
|
fonts: findFonts(sourceRoot, files),
|
||||||
components: findComponentSignals(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>();
|
const found = new Map<string, ComponentSignal>();
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (!COMPONENT_EXTENSIONS.has(path.extname(file.relPath).toLowerCase())) continue;
|
if (!COMPONENT_EXTENSIONS.has(path.extname(file.relPath).toLowerCase())) continue;
|
||||||
const basename = path.basename(file.relPath).replace(/\.[^.]+$/, '');
|
const basename = path.basename(file.relPath).replace(/\.[^.]+$/, '');
|
||||||
const component = COMPONENT_NAMES.find((name) => basename.toLowerCase().includes(name.toLowerCase()));
|
const component = COMPONENT_NAMES.find((name) => basename.toLowerCase().includes(name.toLowerCase()));
|
||||||
if (component && !found.has(component)) {
|
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);
|
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[]> {
|
async function copyAssets(assets: AssetCandidate[], outDir: string): Promise<string[]> {
|
||||||
if (assets.length === 0) return [];
|
if (assets.length === 0) return [];
|
||||||
const assetsDir = path.join(outDir, 'assets');
|
const assetsDir = path.join(outDir, 'assets');
|
||||||
|
|
@ -320,6 +379,19 @@ async function copyAssets(assets: AssetCandidate[], outDir: string): Promise<str
|
||||||
return copied;
|
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(
|
async function nextAvailableSlug(
|
||||||
root: string,
|
root: string,
|
||||||
preferred: string,
|
preferred: string,
|
||||||
|
|
@ -346,6 +418,8 @@ function renderManifest(
|
||||||
scan: ProjectScan,
|
scan: ProjectScan,
|
||||||
now: Date,
|
now: Date,
|
||||||
sourceOverride: DesignSystemProjectSource | undefined,
|
sourceOverride: DesignSystemProjectSource | undefined,
|
||||||
|
importMode: 'normalized' | 'hybrid' | 'verbatim',
|
||||||
|
craftApplies: string[],
|
||||||
) {
|
) {
|
||||||
const importedAt = now.toISOString();
|
const importedAt = now.toISOString();
|
||||||
const source = sourceOverride ?? {
|
const source = sourceOverride ?? {
|
||||||
|
|
@ -368,10 +442,61 @@ function renderManifest(
|
||||||
tokens: 'tokens.css',
|
tokens: 'tokens.css',
|
||||||
components: 'components.html',
|
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.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 {
|
function renderDesignMd(id: string, name: string, scan: ProjectScan): string {
|
||||||
const colors = tokenCandidates(scan.cssVariables, ['color', 'accent', 'primary', 'background', 'surface', 'border'])
|
const colors = tokenCandidates(scan.cssVariables, ['color', 'accent', 'primary', 'background', 'surface', 'border'])
|
||||||
.slice(0, 16)
|
.slice(0, 16)
|
||||||
|
|
@ -418,6 +543,46 @@ function renderDesignMd(id: string, name: string, scan: ProjectScan): string {
|
||||||
].join('\n');
|
].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 {
|
function renderTokensCss(scan: ProjectScan): string {
|
||||||
const picked = pickDesignTokens(scan.cssVariables);
|
const picked = pickDesignTokens(scan.cssVariables);
|
||||||
const cssVarLines = 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 {
|
function pickDesignTokens(tokens: CssVariable[]): typeof TOKEN_FALLBACKS {
|
||||||
const valueFor = (needles: string[], fallback: string, validator: (value: string) => boolean = Boolean) =>
|
const valueFor = (needles: string[], fallback: string, validator: (value: string) => boolean = Boolean) =>
|
||||||
tokenCandidates(tokens, needles).find((token) => validator(token.value))?.value ?? fallback;
|
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());
|
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 {
|
function compactMarkdown(raw: string): string {
|
||||||
return raw
|
return raw
|
||||||
.replace(/```[\s\S]*?```/g, '')
|
.replace(/```[\s\S]*?```/g, '')
|
||||||
|
|
|
||||||
104
apps/daemon/src/design-system-tool-routes.ts
Normal file
104
apps/daemon/src/design-system-tool-routes.ts
Normal 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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type ComponentsManifest,
|
||||||
extractComponentsManifest,
|
extractComponentsManifest,
|
||||||
summarizeComponentsManifestForPrompt,
|
summarizeComponentsManifestForPrompt,
|
||||||
} from '@open-design/contracts';
|
} from '@open-design/contracts';
|
||||||
|
|
@ -65,6 +66,27 @@ export type DesignSystemFileDetail = DesignSystemFileSummary & {
|
||||||
content: string;
|
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 = {
|
export type DesignSystemRevision = {
|
||||||
id: string;
|
id: string;
|
||||||
designSystemId: string;
|
designSystemId: string;
|
||||||
|
|
@ -91,6 +113,36 @@ type DesignSystemProjectManifest = {
|
||||||
tokens: 'tokens.css';
|
tokens: 'tokens.css';
|
||||||
components?: 'components.html';
|
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 = {
|
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
|
* Structured (compiled) form of a brand's design system. Optional sibling
|
||||||
* files alongside DESIGN.md that, when present, give agents a
|
* files alongside DESIGN.md that, when present, give agents a
|
||||||
|
|
@ -307,15 +377,25 @@ export async function readDesignSystem(
|
||||||
* hand-authored or derived tokens.
|
* hand-authored or derived tokens.
|
||||||
*
|
*
|
||||||
* - `tokensCss` — verbatim content of `<brand>/tokens.css`.
|
* - `tokensCss` — verbatim content of `<brand>/tokens.css`.
|
||||||
|
* - `usageMd` — optional agent-facing router for the package.
|
||||||
* - `fixtureHtml` — verbatim content of `<brand>/components.html`.
|
* - `fixtureHtml` — verbatim content of `<brand>/components.html`.
|
||||||
* - `componentsManifest` — concise summary derived from components.html
|
* - `componentsManifest` — concise summary derived from components.html
|
||||||
|
* or read from components.manifest.json cache
|
||||||
* for prompt injection; when absent, callers
|
* for prompt injection; when absent, callers
|
||||||
* can fall back to `fixtureHtml`.
|
* 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 = {
|
export type DesignSystemAssets = {
|
||||||
|
usageMd?: string | undefined;
|
||||||
tokensCss?: string | undefined;
|
tokensCss?: string | undefined;
|
||||||
fixtureHtml?: string | undefined;
|
fixtureHtml?: string | undefined;
|
||||||
componentsManifest?: string | undefined;
|
componentsManifest?: string | undefined;
|
||||||
|
pullIndex?: string | undefined;
|
||||||
|
importMode?: 'normalized' | 'hybrid' | 'verbatim' | undefined;
|
||||||
|
craftApplies?: string[] | undefined;
|
||||||
|
craftExemptions?: string[] | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function readDesignSystemAssets(
|
export async function readDesignSystemAssets(
|
||||||
|
|
@ -326,13 +406,66 @@ export async function readDesignSystemAssets(
|
||||||
if (!dirId) return {};
|
if (!dirId) return {};
|
||||||
const brandRoot = path.join(root, dirId);
|
const brandRoot = path.join(root, dirId);
|
||||||
const manifest = await readProjectManifest(brandRoot, 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')),
|
readFileOptional(path.join(brandRoot, manifest?.files.tokens ?? 'tokens.css')),
|
||||||
manifest?.files.components === undefined && manifest !== null
|
manifest?.files.components === undefined && manifest !== null
|
||||||
? Promise.resolve(undefined)
|
? Promise.resolve(undefined)
|
||||||
: readFileOptional(path.join(brandRoot, manifest?.files.components ?? 'components.html')),
|
: 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(
|
export function isDesignTokenChannelEnabled(
|
||||||
|
|
@ -348,7 +481,16 @@ export async function resolveDesignSystemAssets(
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
): Promise<DesignSystemAssets> {
|
): Promise<DesignSystemAssets> {
|
||||||
if (!isDesignTokenChannelEnabled(env)) {
|
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);
|
const builtIn = await readDesignSystemAssets(builtInRoot, designSystemId);
|
||||||
|
|
@ -358,21 +500,53 @@ export async function resolveDesignSystemAssets(
|
||||||
|
|
||||||
const userInstalled = await readDesignSystemAssets(userInstalledRoot, designSystemId);
|
const userInstalled = await readDesignSystemAssets(userInstalledRoot, designSystemId);
|
||||||
return withComponentsManifest(designSystemId, {
|
return withComponentsManifest(designSystemId, {
|
||||||
|
usageMd: builtIn.usageMd ?? userInstalled.usageMd,
|
||||||
tokensCss: builtIn.tokensCss ?? userInstalled.tokensCss,
|
tokensCss: builtIn.tokensCss ?? userInstalled.tokensCss,
|
||||||
fixtureHtml: builtIn.fixtureHtml ?? userInstalled.fixtureHtml,
|
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(
|
function withComponentsManifest(
|
||||||
designSystemId: string,
|
designSystemId: string,
|
||||||
assets: Pick<DesignSystemAssets, 'tokensCss' | 'fixtureHtml'>,
|
assets: Pick<
|
||||||
|
DesignSystemAssets,
|
||||||
|
| 'usageMd'
|
||||||
|
| 'tokensCss'
|
||||||
|
| 'fixtureHtml'
|
||||||
|
| 'componentsManifest'
|
||||||
|
| 'pullIndex'
|
||||||
|
| 'importMode'
|
||||||
|
| 'craftApplies'
|
||||||
|
| 'craftExemptions'
|
||||||
|
> & {
|
||||||
|
componentsManifestJson?: string | undefined;
|
||||||
|
},
|
||||||
): DesignSystemAssets {
|
): DesignSystemAssets {
|
||||||
const componentsManifest = buildComponentsManifestSummary(
|
const { componentsManifestJson, ...publicAssets } = assets;
|
||||||
designSystemId,
|
const componentsManifest =
|
||||||
assets.fixtureHtml,
|
publicAssets.componentsManifest
|
||||||
assets.tokensCss,
|
?? summarizeComponentsManifestCache(componentsManifestJson)
|
||||||
);
|
?? buildComponentsManifestSummary(
|
||||||
return { ...assets, componentsManifest };
|
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(
|
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(
|
export async function createUserDesignSystem(
|
||||||
root: string,
|
root: string,
|
||||||
input: UserDesignSystemInput,
|
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 {
|
function isAbsenceError(err: unknown): boolean {
|
||||||
if (typeof err !== 'object' || err === null) return false;
|
if (typeof err !== 'object' || err === null) return false;
|
||||||
const code = (err as { code?: unknown }).code;
|
const code = (err as { code?: unknown }).code;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- 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 {
|
export interface ComposeInput {
|
||||||
agentId?: string | null | undefined;
|
agentId?: string | null | undefined;
|
||||||
includeCodexImagegenOverride?: boolean | undefined;
|
includeCodexImagegenOverride?: boolean | undefined;
|
||||||
|
|
@ -197,6 +214,8 @@ export interface ComposeInput {
|
||||||
// prose still sets the high-level voice and the structured form
|
// prose still sets the high-level voice and the structured form
|
||||||
// disambiguates token names + worked component shapes.
|
// 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
|
// - `designSystemTokensCss` — verbatim `tokens.css` :root contract
|
||||||
// that the agent pastes into the
|
// that the agent pastes into the
|
||||||
// artifact's <style>.
|
// artifact's <style>.
|
||||||
|
|
@ -205,9 +224,15 @@ export interface ComposeInput {
|
||||||
// - `designSystemFixtureHtml` — verbatim `components.html`
|
// - `designSystemFixtureHtml` — verbatim `components.html`
|
||||||
// fallback when no manifest can
|
// fallback when no manifest can
|
||||||
// be derived.
|
// be derived.
|
||||||
|
// - `designSystemPullIndex` — lightweight manifest-derived
|
||||||
|
// list of richer files available
|
||||||
|
// for later pull-channel work.
|
||||||
|
designSystemUsageMd?: string | undefined;
|
||||||
designSystemTokensCss?: string | undefined;
|
designSystemTokensCss?: string | undefined;
|
||||||
designSystemComponentsManifest?: string | undefined;
|
designSystemComponentsManifest?: string | undefined;
|
||||||
designSystemFixtureHtml?: string | undefined;
|
designSystemFixtureHtml?: string | undefined;
|
||||||
|
designSystemPullIndex?: string | undefined;
|
||||||
|
designSystemImportMode?: 'normalized' | 'hybrid' | 'verbatim' | undefined;
|
||||||
// Craft references the active skill opted into via `od.craft.requires`.
|
// Craft references the active skill opted into via `od.craft.requires`.
|
||||||
// The daemon resolves the slug list to file contents and concatenates
|
// The daemon resolves the slug list to file contents and concatenates
|
||||||
// them with section headers; we inject them between the DESIGN.md and
|
// them with section headers; we inject them between the DESIGN.md and
|
||||||
|
|
@ -288,9 +313,12 @@ export function composeSystemPrompt({
|
||||||
skillMode,
|
skillMode,
|
||||||
designSystemBody,
|
designSystemBody,
|
||||||
designSystemTitle,
|
designSystemTitle,
|
||||||
|
designSystemUsageMd,
|
||||||
designSystemTokensCss,
|
designSystemTokensCss,
|
||||||
designSystemComponentsManifest,
|
designSystemComponentsManifest,
|
||||||
designSystemFixtureHtml,
|
designSystemFixtureHtml,
|
||||||
|
designSystemPullIndex,
|
||||||
|
designSystemImportMode,
|
||||||
craftBody,
|
craftBody,
|
||||||
craftSections,
|
craftSections,
|
||||||
memoryBody,
|
memoryBody,
|
||||||
|
|
@ -358,9 +386,24 @@ export function composeSystemPrompt({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeDesignSystemBody && activeDesignSystemBody.length > 0) {
|
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(
|
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}`,
|
`\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
|
// 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) {
|
if (craftBody && craftBody.trim().length > 0) {
|
||||||
const sectionLabel =
|
const sectionLabel =
|
||||||
Array.isArray(craftSections) && craftSections.length > 0
|
Array.isArray(craftSections) && craftSections.length > 0
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ import {
|
||||||
listUserDesignSystemFiles,
|
listUserDesignSystemFiles,
|
||||||
listUserDesignSystemRevisions,
|
listUserDesignSystemRevisions,
|
||||||
readDesignSystem,
|
readDesignSystem,
|
||||||
|
readDesignSystemPackageInfo,
|
||||||
readUserDesignSystemFile,
|
readUserDesignSystemFile,
|
||||||
resolveDesignSystemAssets,
|
resolveDesignSystemAssets,
|
||||||
updateUserDesignSystem,
|
updateUserDesignSystem,
|
||||||
|
|
@ -346,6 +347,7 @@ import { registerActiveContextRoutes } from './active-context-routes.js';
|
||||||
import { registerMcpRoutes } from './mcp-routes.js';
|
import { registerMcpRoutes } from './mcp-routes.js';
|
||||||
import { registerXaiRoutes } from './xai-routes.js';
|
import { registerXaiRoutes } from './xai-routes.js';
|
||||||
import { registerLiveArtifactRoutes } from './live-artifact-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 { registerDeployRoutes, registerDeploymentCheckRoutes } from './deploy-routes.js';
|
||||||
import { registerMediaRoutes } from './media-routes.js';
|
import { registerMediaRoutes } from './media-routes.js';
|
||||||
import { registerProjectRoutes, registerProjectArtifactRoutes, registerProjectFileRoutes, registerProjectUploadRoutes } from './project-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) {
|
function isProjectUsableDesignSystem(summary) {
|
||||||
return summary?.status !== 'draft';
|
return summary?.status !== 'draft';
|
||||||
}
|
}
|
||||||
|
|
@ -4199,6 +4211,12 @@ export async function startServer({
|
||||||
liveArtifacts: liveArtifactDeps,
|
liveArtifacts: liveArtifactDeps,
|
||||||
projectStore: projectStoreDeps,
|
projectStore: projectStoreDeps,
|
||||||
});
|
});
|
||||||
|
registerDesignSystemToolRoutes(app, {
|
||||||
|
auth: authDeps,
|
||||||
|
http: httpDeps,
|
||||||
|
paths: pathDeps,
|
||||||
|
projects: { getProject },
|
||||||
|
});
|
||||||
app.use('/artifacts', express.static(ARTIFACTS_DIR));
|
app.use('/artifacts', express.static(ARTIFACTS_DIR));
|
||||||
registerDeployRoutes(app, {
|
registerDeployRoutes(app, {
|
||||||
db,
|
db,
|
||||||
|
|
@ -4769,7 +4787,8 @@ export async function startServer({
|
||||||
const body = projectBody ?? await readAvailableDesignSystem(req.params.id);
|
const body = projectBody ?? await readAvailableDesignSystem(req.params.id);
|
||||||
if (body === null || !summary)
|
if (body === null || !summary)
|
||||||
return res.status(404).json({ error: 'design system not found' });
|
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 });
|
res.json({ ...detail, designSystem: detail });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: String(err) });
|
res.status(500).json({ error: String(err) });
|
||||||
|
|
@ -8317,13 +8336,6 @@ export async function startServer({
|
||||||
|
|
||||||
let craftBody;
|
let craftBody;
|
||||||
let craftSections;
|
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
|
// Personal-memory body is always recomputed at compose time so a
|
||||||
// memory the user just edited in settings shows up on the very next
|
// 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`,
|
// including the structured ones. Any other value (unset, `1`,
|
||||||
// `true`, etc.) keeps the new default. Drift on prose-only brands
|
// `true`, etc.) keeps the new default. Drift on prose-only brands
|
||||||
// is pinned by `scripts/check-design-system-flag-parity.ts`.
|
// is pinned by `scripts/check-design-system-flag-parity.ts`.
|
||||||
|
let designSystemUsageMd;
|
||||||
let designSystemTokensCss;
|
let designSystemTokensCss;
|
||||||
let designSystemComponentsManifest;
|
let designSystemComponentsManifest;
|
||||||
let designSystemFixtureHtml;
|
let designSystemFixtureHtml;
|
||||||
|
let designSystemPullIndex;
|
||||||
|
let designSystemImportMode;
|
||||||
|
let designSystemCraftApplies = [];
|
||||||
|
let designSystemCraftExemptions = [];
|
||||||
if (effectiveDesignSystemId) {
|
if (effectiveDesignSystemId) {
|
||||||
let systems = await listAllDesignSystems();
|
let systems = await listAllDesignSystems();
|
||||||
let summary = systems.find((s) => s.id === effectiveDesignSystemId);
|
let summary = systems.find((s) => s.id === effectiveDesignSystemId);
|
||||||
|
|
@ -8391,9 +8408,26 @@ export async function startServer({
|
||||||
DESIGN_SYSTEMS_DIR,
|
DESIGN_SYSTEMS_DIR,
|
||||||
USER_DESIGN_SYSTEMS_DIR,
|
USER_DESIGN_SYSTEMS_DIR,
|
||||||
);
|
);
|
||||||
|
designSystemUsageMd = assets.usageMd;
|
||||||
designSystemTokensCss = assets.tokensCss;
|
designSystemTokensCss = assets.tokensCss;
|
||||||
designSystemComponentsManifest = assets.componentsManifest;
|
designSystemComponentsManifest = assets.componentsManifest;
|
||||||
designSystemFixtureHtml = assets.fixtureHtml;
|
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,
|
skillMode,
|
||||||
designSystemBody,
|
designSystemBody,
|
||||||
designSystemTitle,
|
designSystemTitle,
|
||||||
|
designSystemUsageMd,
|
||||||
designSystemTokensCss,
|
designSystemTokensCss,
|
||||||
designSystemComponentsManifest,
|
designSystemComponentsManifest,
|
||||||
designSystemFixtureHtml,
|
designSystemFixtureHtml,
|
||||||
|
designSystemPullIndex,
|
||||||
|
designSystemImportMode,
|
||||||
craftBody,
|
craftBody,
|
||||||
craftSections,
|
craftSections,
|
||||||
memoryBody,
|
memoryBody,
|
||||||
|
|
|
||||||
|
|
@ -630,8 +630,12 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
|
||||||
}
|
}
|
||||||
|
|
||||||
const before = await listAllDesignSystems();
|
const before = await listAllDesignSystems();
|
||||||
|
const importMode = normalizeDesignSystemImportMode(body.importMode);
|
||||||
|
const craftApplies = normalizeDesignSystemCraftApplies(body.craftApplies);
|
||||||
const result = await importLocalDesignSystemProject(sourceRoot, USER_DESIGN_SYSTEMS_DIR, {
|
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),
|
reservedIds: before.map((system) => system.id),
|
||||||
});
|
});
|
||||||
const systems = await listAllDesignSystems();
|
const systems = await listAllDesignSystems();
|
||||||
|
|
@ -664,13 +668,17 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
|
||||||
? body.url
|
? body.url
|
||||||
: '';
|
: '';
|
||||||
const before = await listAllDesignSystems();
|
const before = await listAllDesignSystems();
|
||||||
|
const importMode = normalizeDesignSystemImportMode(body.importMode);
|
||||||
|
const craftApplies = normalizeDesignSystemCraftApplies(body.craftApplies);
|
||||||
const result = await importGitHubDesignSystemProject(
|
const result = await importGitHubDesignSystemProject(
|
||||||
githubUrl,
|
githubUrl,
|
||||||
path.join(PROJECT_ROOT, '.tmp'),
|
path.join(PROJECT_ROOT, '.tmp'),
|
||||||
USER_DESIGN_SYSTEMS_DIR,
|
USER_DESIGN_SYSTEMS_DIR,
|
||||||
{
|
{
|
||||||
name: typeof body.name === 'string' ? body.name : undefined,
|
...(typeof body.name === 'string' ? { name: body.name } : {}),
|
||||||
branch: typeof body.branch === 'string' ? body.branch : undefined,
|
...(typeof body.branch === 'string' ? { branch: body.branch } : {}),
|
||||||
|
...(importMode ? { importMode } : {}),
|
||||||
|
...(craftApplies ? { craftApplies } : {}),
|
||||||
reservedIds: before.map((system) => system.id),
|
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) {
|
function assembleExample(templateHtml: string, slidesHtml: string, title: string) {
|
||||||
return templateHtml
|
return templateHtml
|
||||||
.replace('<!-- SLIDES_HERE -->', slidesHtml)
|
.replace('<!-- SLIDES_HERE -->', slidesHtml)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export const CHAT_TOOL_ENDPOINTS = [
|
||||||
'/api/tools/live-artifacts/update',
|
'/api/tools/live-artifacts/update',
|
||||||
'/api/tools/connectors/list',
|
'/api/tools/connectors/list',
|
||||||
'/api/tools/connectors/execute',
|
'/api/tools/connectors/execute',
|
||||||
|
'/api/tools/design-systems/read',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const CHAT_TOOL_OPERATIONS = [
|
export const CHAT_TOOL_OPERATIONS = [
|
||||||
|
|
@ -18,6 +19,7 @@ export const CHAT_TOOL_OPERATIONS = [
|
||||||
'live-artifacts:update',
|
'live-artifacts:update',
|
||||||
'connectors:list',
|
'connectors:list',
|
||||||
'connectors:execute',
|
'connectors:execute',
|
||||||
|
'design-systems:read',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ToolEndpoint = (typeof CHAT_TOOL_ENDPOINTS)[number] | (string & {});
|
export type ToolEndpoint = (typeof CHAT_TOOL_ENDPOINTS)[number] | (string & {});
|
||||||
|
|
|
||||||
160
apps/daemon/src/tools-design-systems-cli.ts
Normal file
160
apps/daemon/src/tools-design-systems-cli.ts
Normal 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 } : {}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,8 @@ import {
|
||||||
listDesignSystems,
|
listDesignSystems,
|
||||||
readDesignSystem,
|
readDesignSystem,
|
||||||
readDesignSystemAssets,
|
readDesignSystemAssets,
|
||||||
|
readDesignSystemPackageInfo,
|
||||||
|
readDesignSystemPullFile,
|
||||||
resolveDesignSystemAssets,
|
resolveDesignSystemAssets,
|
||||||
} from '../src/design-systems.js';
|
} 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.tokensCss).toBe(':root { --accent: #2F6FEB; }');
|
||||||
expect(assets.fixtureHtml).toBeUndefined();
|
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
|
// Reviewer feedback (nettee, PR-D #1544): the parity guard at
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,8 @@ exit 1
|
||||||
{
|
{
|
||||||
gitBin: fakeGit,
|
gitBin: fakeGit,
|
||||||
now: new Date('2026-05-18T10:00:00.000Z'),
|
now: new Date('2026-05-18T10:00:00.000Z'),
|
||||||
|
importMode: 'normalized',
|
||||||
|
craftApplies: ['color'],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -119,9 +121,25 @@ exit 1
|
||||||
tokens: 'tokens.css',
|
tokens: 'tokens.css',
|
||||||
components: 'components.html',
|
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(
|
expect(fs.readFileSync(path.join(result.dir, 'DESIGN.md'), 'utf8')).toContain(
|
||||||
'A GitHub-hosted design kit.',
|
'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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ describe('importLocalDesignSystemProject', () => {
|
||||||
userDesignSystemsRoot = path.join(tempRoot, 'user-design-systems');
|
userDesignSystemsRoot = path.join(tempRoot, 'user-design-systems');
|
||||||
fs.mkdirSync(path.join(sourceRoot, 'src', 'components'), { recursive: true });
|
fs.mkdirSync(path.join(sourceRoot, 'src', 'components'), { recursive: true });
|
||||||
fs.mkdirSync(path.join(sourceRoot, 'src', 'styles'), { 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.mkdirSync(path.join(sourceRoot, 'public'), { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(sourceRoot, 'package.json'),
|
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, '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, 'public', 'logo.svg'), '<svg xmlns="http://www.w3.org/2000/svg" />');
|
||||||
|
fs.writeFileSync(path.join(sourceRoot, 'src', 'assets', 'fonts', 'AcmeSans-Regular.woff2'), 'font');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -53,7 +55,27 @@ describe('importLocalDesignSystemProject', () => {
|
||||||
|
|
||||||
expect(result.id).toBe('kami-app');
|
expect(result.id).toBe('kami-app');
|
||||||
expect(result.files).toEqual(
|
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>;
|
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',
|
tokens: 'tokens.css',
|
||||||
components: 'components.html',
|
components: 'components.html',
|
||||||
},
|
},
|
||||||
|
usage: 'USAGE.md',
|
||||||
|
componentsManifest: 'components.manifest.json',
|
||||||
|
importMode: 'hybrid',
|
||||||
assetsDir: 'assets',
|
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');
|
const design = fs.readFileSync(path.join(result.dir, 'DESIGN.md'), 'utf8');
|
||||||
expect(design).toContain('A focused workspace for AI design reviews.');
|
expect(design).toContain('A focused workspace for AI design reviews.');
|
||||||
expect(design).toContain('Button: `src/components/Button.tsx`');
|
expect(design).toContain('Button: `src/components/Button.tsx`');
|
||||||
expect(design).toContain('`--color-primary: #ff3366`');
|
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');
|
const assets = await readDesignSystemAssets(userDesignSystemsRoot, 'kami-app');
|
||||||
expect(assets.tokensCss).toContain('--accent: #ff3366;');
|
expect(assets.tokensCss).toContain('--accent: #ff3366;');
|
||||||
expect(assets.tokensCss).toContain('--bg: #101014;');
|
expect(assets.tokensCss).toContain('--bg: #101014;');
|
||||||
|
|
@ -107,4 +197,22 @@ describe('importLocalDesignSystemProject', () => {
|
||||||
expect(first.id).toBe('kami-app-2');
|
expect(first.id).toBe('kami-app-2');
|
||||||
expect(second.id).toBe('kami-app-3');
|
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: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
158
apps/daemon/tests/design-system-tool-routes.test.ts
Normal file
158
apps/daemon/tests/design-system-tool-routes.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -325,6 +325,31 @@ describe('composeSystemPrompt', () => {
|
||||||
expect(prompt).toContain('class="btn btn-primary"');
|
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', () => {
|
it('prefers the component manifest over the full fixture when both are present', () => {
|
||||||
const prompt = composeSystemPrompt({
|
const prompt = composeSystemPrompt({
|
||||||
designSystemTitle: 'default',
|
designSystemTitle: 'default',
|
||||||
|
|
@ -386,6 +411,32 @@ describe('composeSystemPrompt', () => {
|
||||||
expect(manifestOnly).toContain('## Reference component manifest — default');
|
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)', () => {
|
it('places the tokens + component manifest blocks AFTER the DESIGN.md prose block (prose sets voice, structured form binds names)', () => {
|
||||||
const prompt = composeSystemPrompt({
|
const prompt = composeSystemPrompt({
|
||||||
designSystemTitle: 'default',
|
designSystemTitle: 'default',
|
||||||
|
|
|
||||||
|
|
@ -1351,6 +1351,7 @@ export function DesignSystemDetailView({
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
<DesignSystemPackageCard system={system} />
|
||||||
<div className="ds-warning-card">
|
<div className="ds-warning-card">
|
||||||
<Icon name="help-circle" />
|
<Icon name="help-circle" />
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -1578,6 +1579,146 @@ function findWorkspaceActivityMessage(messages: ChatMessage[]): ChatMessage | nu
|
||||||
return null;
|
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({
|
function WorkspaceActivityCard({
|
||||||
message,
|
message,
|
||||||
active,
|
active,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,13 @@ interface Props {
|
||||||
setCfg: Dispatch<SetStateAction<AppConfig>>;
|
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) {
|
export function DesignSystemsSection({ cfg, setCfg }: Props) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [designSystems, setDesignSystems] = useState<DesignSystemSummary[]>([]);
|
const [designSystems, setDesignSystems] = useState<DesignSystemSummary[]>([]);
|
||||||
|
|
@ -29,7 +36,9 @@ export function DesignSystemsSection({ cfg, setCfg }: Props) {
|
||||||
const [previewBody, setPreviewBody] = useState<string | null>(null);
|
const [previewBody, setPreviewBody] = useState<string | null>(null);
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const [importPath, setImportPath] = useState('');
|
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 [importing, setImporting] = useState(false);
|
||||||
const [importMessage, setImportMessage] = useState<string | null>(null);
|
const [importMessage, setImportMessage] = useState<string | null>(null);
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
|
@ -119,10 +128,14 @@ export function DesignSystemsSection({ cfg, setCfg }: Props) {
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
setImportError(null);
|
setImportError(null);
|
||||||
setImportMessage(null);
|
setImportMessage(null);
|
||||||
|
const importOptions = {
|
||||||
|
importMode: packageImportMode,
|
||||||
|
craftApplies,
|
||||||
|
};
|
||||||
const result =
|
const result =
|
||||||
importMode === 'github'
|
importSource === 'github'
|
||||||
? await importGitHubDesignSystem({ githubUrl: importTarget })
|
? await importGitHubDesignSystem({ githubUrl: importTarget, ...importOptions })
|
||||||
: await importLocalDesignSystem({ baseDir: importTarget });
|
: await importLocalDesignSystem({ baseDir: importTarget, ...importOptions });
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
if ('error' in result) {
|
if ('error' in result) {
|
||||||
setImportError(result.error.message);
|
setImportError(result.error.message);
|
||||||
|
|
@ -145,24 +158,73 @@ export function DesignSystemsSection({ cfg, setCfg }: Props) {
|
||||||
<div className="seg-control">
|
<div className="seg-control">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={importMode === 'local' ? 'active' : ''}
|
className={importSource === 'local' ? 'active' : ''}
|
||||||
onClick={() => setImportMode('local')}
|
onClick={() => setImportSource('local')}
|
||||||
>
|
>
|
||||||
Local
|
Local
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={importMode === 'github' ? 'active' : ''}
|
className={importSource === 'github' ? 'active' : ''}
|
||||||
onClick={() => setImportMode('github')}
|
onClick={() => setImportSource('github')}
|
||||||
>
|
>
|
||||||
GitHub
|
GitHub
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="library-install-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="library-search"
|
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}
|
value={importPath}
|
||||||
onChange={(e) => setImportPath(e.target.value)}
|
onChange={(e) => setImportPath(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -19602,6 +19602,45 @@ body.desktop-pet-shell .pet-task-item {
|
||||||
width: fit-content;
|
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 {
|
.library-install-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
|
||||||
|
|
@ -593,6 +593,7 @@
|
||||||
.ds-warning-card,
|
.ds-warning-card,
|
||||||
.ds-generation-review-card,
|
.ds-generation-review-card,
|
||||||
.ds-workspace-activity-card,
|
.ds-workspace-activity-card,
|
||||||
|
.ds-package-card,
|
||||||
.ds-revision-card,
|
.ds-revision-card,
|
||||||
.ds-revision-history,
|
.ds-revision-history,
|
||||||
.ds-review-section,
|
.ds-review-section,
|
||||||
|
|
@ -791,6 +792,129 @@
|
||||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||||
background: color-mix(in srgb, var(--accent) 9%, var(--bg-panel));
|
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 {
|
.ds-warning-card span {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -1264,9 +1388,15 @@
|
||||||
}
|
}
|
||||||
.ds-resource-row,
|
.ds-resource-row,
|
||||||
.ds-files-panel,
|
.ds-files-panel,
|
||||||
.ds-publish-card {
|
.ds-publish-card,
|
||||||
|
.ds-package-grid,
|
||||||
|
.ds-package-files {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.ds-package-metrics,
|
||||||
|
.ds-evidence-stats {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
.ds-github-access-methods {
|
.ds-github-access-methods {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,12 +55,16 @@ discover the same files without guessing.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
design-systems/<slug>/
|
design-systems/<slug>/
|
||||||
├── manifest.json ← machine-readable project entry
|
├── manifest.json ← machine-readable project entry
|
||||||
├── DESIGN.md ← canonical design prose for agents
|
├── USAGE.md ← optional agent-facing package guide
|
||||||
├── tokens.css ← canonical compiled CSS custom properties
|
├── DESIGN.md ← canonical design prose for agents
|
||||||
├── components.html ← optional standalone component fixture
|
├── tokens.css ← canonical compiled CSS custom properties
|
||||||
├── assets/ ← optional brand assets
|
├── components.html ← optional standalone component fixture
|
||||||
└── preview/ ← optional static preview pages
|
├── 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
|
`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`.
|
- `assetsDir` is optional and, when declared, must be `assets`.
|
||||||
- `previewDir` is optional and, when declared, must be `preview`.
|
- `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
|
The schema source of truth lives in
|
||||||
[`_schema/manifest.schema.ts`](_schema/manifest.schema.ts). The guard 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).
|
[`../scripts/check-design-system-manifests.ts`](../scripts/check-design-system-manifests.ts).
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,20 @@ Design System Project folders use fixed v1 file names:
|
||||||
- `components.html` — optional standalone component fixture.
|
- `components.html` — optional standalone component fixture.
|
||||||
- `assets/` — optional brand assets.
|
- `assets/` — optional brand assets.
|
||||||
- `preview/` — optional static preview pages.
|
- `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
|
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
|
## Four layers, two questions
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,12 @@
|
||||||
* optional component fixtures, and optional preview/assets directories
|
* optional component fixtures, and optional preview/assets directories
|
||||||
* without guessing from folder contents.
|
* 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
|
* PR1 deliberately defines the contract without changing runtime
|
||||||
* discovery. Existing DESIGN.md-only systems stay valid; this schema is
|
* discovery. Existing DESIGN.md-only systems stay valid; this schema is
|
||||||
* enforced only for folders that choose to ship `manifest.json`.
|
* enforced only for folders that choose to ship `manifest.json`.
|
||||||
|
|
@ -54,6 +60,39 @@ export type DesignSystemProjectFiles = {
|
||||||
readonly components?: "components.html";
|
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 = {
|
export type DesignSystemProjectManifest = {
|
||||||
readonly schemaVersion: typeof DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION;
|
readonly schemaVersion: typeof DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION;
|
||||||
/** Folder slug and stable picker id. Must match /^[a-z0-9-]+$/. */
|
/** Folder slug and stable picker id. Must match /^[a-z0-9-]+$/. */
|
||||||
|
|
@ -65,8 +104,22 @@ export type DesignSystemProjectManifest = {
|
||||||
readonly files: DesignSystemProjectFiles;
|
readonly files: DesignSystemProjectFiles;
|
||||||
/** Optional static assets root. V1 fixes the directory name. */
|
/** Optional static assets root. V1 fixes the directory name. */
|
||||||
readonly assetsDir?: "assets";
|
readonly assetsDir?: "assets";
|
||||||
/** Optional preview root. V1 fixes the directory name. */
|
/** Optional legacy preview root. V1 fixes the directory name. */
|
||||||
readonly previewDir?: "preview";
|
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 =
|
export type DesignSystemManifestValidationResult =
|
||||||
|
|
@ -83,6 +136,13 @@ const ALLOWED_TOP_LEVEL_KEYS = new Set([
|
||||||
"files",
|
"files",
|
||||||
"assetsDir",
|
"assetsDir",
|
||||||
"previewDir",
|
"previewDir",
|
||||||
|
"usage",
|
||||||
|
"componentsManifest",
|
||||||
|
"importMode",
|
||||||
|
"craft",
|
||||||
|
"fonts",
|
||||||
|
"preview",
|
||||||
|
"sourceFiles",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ALLOWED_SOURCE_KEYS: Record<DesignSystemProjectSource["type"], ReadonlySet<string>> = {
|
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_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(
|
export function parseDesignSystemProjectManifest(
|
||||||
raw: string,
|
raw: string,
|
||||||
|
|
@ -131,6 +196,15 @@ export function validateDesignSystemProjectManifest(
|
||||||
|
|
||||||
if (value.assetsDir !== undefined) expectLiteral(errors, "$.assetsDir", value.assetsDir, "assets");
|
if (value.assetsDir !== undefined) expectLiteral(errors, "$.assetsDir", value.assetsDir, "assets");
|
||||||
if (value.previewDir !== undefined) expectLiteral(errors, "$.previewDir", value.previewDir, "preview");
|
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 };
|
if (errors.length > 0) return { ok: false, errors };
|
||||||
return { ok: true, manifest: value as DesignSystemProjectManifest };
|
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(
|
function rejectUnknownKeys(
|
||||||
errors: string[],
|
errors: string[],
|
||||||
pathLabel: 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 {
|
function expectSlug(errors: string[], pathLabel: string, value: unknown): void {
|
||||||
if (typeof value !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)) {
|
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]+)*$/`);
|
errors.push(`${pathLabel} must be a lowercase slug matching /^[a-z0-9]+(?:-[a-z0-9]+)*$/`);
|
||||||
|
|
|
||||||
32
design-systems/default/USAGE.md
Normal file
32
design-systems/default/USAGE.md
Normal 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.
|
||||||
471
design-systems/default/components.manifest.json
Normal file
471
design-systems/default/components.manifest.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,5 +12,51 @@
|
||||||
"design": "DESIGN.md",
|
"design": "DESIGN.md",
|
||||||
"tokens": "tokens.css",
|
"tokens": "tokens.css",
|
||||||
"components": "components.html"
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
103
design-systems/default/preview/app.html
Normal file
103
design-systems/default/preview/app.html
Normal 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>
|
||||||
66
design-systems/default/preview/colors.html
Normal file
66
design-systems/default/preview/colors.html
Normal 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>
|
||||||
53
design-systems/default/preview/components-buttons.html
Normal file
53
design-systems/default/preview/components-buttons.html
Normal 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>
|
||||||
53
design-systems/default/preview/components-inputs.html
Normal file
53
design-systems/default/preview/components-inputs.html
Normal 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>
|
||||||
54
design-systems/default/preview/spacing.html
Normal file
54
design-systems/default/preview/spacing.html
Normal 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>
|
||||||
62
design-systems/default/preview/typography.html
Normal file
62
design-systems/default/preview/typography.html
Normal 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>
|
||||||
|
|
@ -162,6 +162,49 @@ export interface DesignSystemSummary {
|
||||||
|
|
||||||
export interface DesignSystemDetail extends DesignSystemSummary {
|
export interface DesignSystemDetail extends DesignSystemSummary {
|
||||||
body: string;
|
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 {
|
export interface DesignSystemsResponse {
|
||||||
|
|
@ -311,6 +354,10 @@ export interface ImportLocalDesignSystemRequest {
|
||||||
baseDir: string;
|
baseDir: string;
|
||||||
/** Optional display name override for the generated design-system project. */
|
/** Optional display name override for the generated design-system project. */
|
||||||
name?: string;
|
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 {
|
export interface ImportLocalDesignSystemResponse {
|
||||||
|
|
@ -324,6 +371,10 @@ export interface ImportGitHubDesignSystemRequest {
|
||||||
branch?: string;
|
branch?: string;
|
||||||
/** Optional display name override for the generated design-system project. */
|
/** Optional display name override for the generated design-system project. */
|
||||||
name?: string;
|
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 {
|
export interface ImportGitHubDesignSystemResponse {
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ import test from "node:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION,
|
DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION,
|
||||||
|
type DesignSystemProjectManifest,
|
||||||
validateDesignSystemProjectManifest,
|
validateDesignSystemProjectManifest,
|
||||||
} from "../design-systems/_schema/manifest.schema.ts";
|
} 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", () => {
|
test("design-system project manifest schema accepts the v1 minimum shape", () => {
|
||||||
const result = validateDesignSystemProjectManifest({
|
const result = validateDesignSystemProjectManifest({
|
||||||
|
|
@ -95,3 +97,153 @@ test("design-system project manifest schema rejects path drift and unknown keys"
|
||||||
assert.match(errors, /\$\.extra/);
|
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/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,17 @@
|
||||||
* ─────────────────────────────────────────────────────────────────── */
|
* ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
import { access, readFile, readdir } from "node:fs/promises";
|
import { access, readFile, readdir } from "node:fs/promises";
|
||||||
|
import { isDeepStrictEqual } from "node:util";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { parseDesignSystemProjectManifest } from "../design-systems/_schema/manifest.schema.ts";
|
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 repoRoot = path.resolve(import.meta.dirname, "..");
|
||||||
const designSystemsRoot = path.join(repoRoot, "design-systems");
|
const designSystemsRoot = path.join(repoRoot, "design-systems");
|
||||||
|
const craftRoot = path.join(repoRoot, "craft");
|
||||||
const SKIPPED_DIRECTORIES = new Set(["_schema"]);
|
const SKIPPED_DIRECTORIES = new Set(["_schema"]);
|
||||||
|
|
||||||
function toRepositoryPath(filePath: string): string {
|
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[]> {
|
async function discoverManifestPaths(): Promise<string[]> {
|
||||||
let entries;
|
let entries;
|
||||||
try {
|
try {
|
||||||
|
|
@ -52,6 +60,7 @@ async function discoverManifestPaths(): Promise<string[]> {
|
||||||
|
|
||||||
export async function checkDesignSystemManifests(): Promise<boolean> {
|
export async function checkDesignSystemManifests(): Promise<boolean> {
|
||||||
const manifestPaths = await discoverManifestPaths();
|
const manifestPaths = await discoverManifestPaths();
|
||||||
|
const craftSlugs = await discoverCraftSlugs();
|
||||||
const violations: string[] = [];
|
const violations: string[] = [];
|
||||||
|
|
||||||
for (const manifestPath of manifestPaths) {
|
for (const manifestPath of manifestPaths) {
|
||||||
|
|
@ -69,17 +78,20 @@ export async function checkDesignSystemManifests(): Promise<boolean> {
|
||||||
if (manifest.id !== folderSlug) {
|
if (manifest.id !== folderSlug) {
|
||||||
violations.push(`${repositoryManifestPath}: $.id must match folder slug "${folderSlug}"`);
|
violations.push(`${repositoryManifestPath}: $.id must match folder slug "${folderSlug}"`);
|
||||||
}
|
}
|
||||||
|
validateManifestSemantics(violations, repositoryManifestPath, manifest, craftSlugs);
|
||||||
|
|
||||||
const requiredFiles = [
|
const requiredFiles = [
|
||||||
manifest.files.design,
|
manifest.files.design,
|
||||||
manifest.files.tokens,
|
manifest.files.tokens,
|
||||||
...(manifest.files.components === undefined ? [] : [manifest.files.components]),
|
...(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) {
|
for (const fileName of requiredFiles) {
|
||||||
const target = path.join(brandRoot, fileName);
|
await requireDeclaredPathExists(violations, repositoryManifestPath, brandRoot, fileName);
|
||||||
if (!(await exists(target))) {
|
|
||||||
violations.push(`${repositoryManifestPath}: ${fileName} is declared but ${toRepositoryPath(target)} does not exist`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (manifest.assetsDir !== undefined && !(await exists(path.join(brandRoot, manifest.assetsDir)))) {
|
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)))) {
|
if (manifest.previewDir !== undefined && !(await exists(path.join(brandRoot, manifest.previewDir)))) {
|
||||||
violations.push(`${repositoryManifestPath}: previewDir is declared but ${manifest.previewDir}/ does not exist`);
|
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) {
|
if (violations.length > 0) {
|
||||||
|
|
@ -102,6 +120,131 @@ export async function checkDesignSystemManifests(): Promise<boolean> {
|
||||||
return true;
|
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)) {
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||||
const ok = await checkDesignSystemManifests();
|
const ok = await checkDesignSystemManifests();
|
||||||
if (!ok) process.exitCode = 1;
|
if (!ok) process.exitCode = 1;
|
||||||
|
|
|
||||||
92
scripts/check-design-system-package-quality.test.ts
Normal file
92
scripts/check-design-system-package-quality.test.ts
Normal 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, []);
|
||||||
|
});
|
||||||
196
scripts/check-design-system-package-quality.ts
Normal file
196
scripts/check-design-system-package-quality.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { readFile, readdir } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { checkDesignSystemManifests } from "./check-design-system-manifests.ts";
|
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 { checkDesignSystemComponentFixtureReport } from "./check-components-fixtures.ts";
|
||||||
import { checkDesignSystemFlagParity } from "./check-design-system-flag-parity.ts";
|
import { checkDesignSystemFlagParity } from "./check-design-system-flag-parity.ts";
|
||||||
import { checkComponentsManifestExtraction } from "./check-components-manifest-extraction.ts";
|
import { checkComponentsManifestExtraction } from "./check-components-manifest-extraction.ts";
|
||||||
|
|
@ -903,6 +904,7 @@ const checks: GuardCheck[] = [
|
||||||
{ name: "tools layout", run: checkToolsLayout },
|
{ name: "tools layout", run: checkToolsLayout },
|
||||||
{ name: "style policy", run: checkStylePolicy },
|
{ name: "style policy", run: checkStylePolicy },
|
||||||
{ name: "design system manifests", run: checkDesignSystemManifests },
|
{ name: "design system manifests", run: checkDesignSystemManifests },
|
||||||
|
{ name: "design system package quality", run: checkDesignSystemPackageQuality },
|
||||||
{ name: "design system component fixture report", run: checkDesignSystemComponentFixtureReport },
|
{ name: "design system component fixture report", run: checkDesignSystemComponentFixtureReport },
|
||||||
{ name: "design system token-fixture sync", run: checkDesignSystemTokenFixtureSync },
|
{ name: "design system token-fixture sync", run: checkDesignSystemTokenFixtureSync },
|
||||||
{ name: "design system A1 required tokens", run: checkDesignSystemA1RequiredTokens },
|
{ name: "design system A1 required tokens", run: checkDesignSystemA1RequiredTokens },
|
||||||
|
|
|
||||||
306
specs/current/design-system-import-project.md
Normal file
306
specs/current/design-system-import-project.md
Normal 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.
|
||||||
Loading…
Reference in a new issue