diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index 112e68b91..b1e712e51 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -4,6 +4,7 @@ import { runDaemonCliStartup } from './daemon-startup.js'; import { runLiveArtifactsMcpServer } from './mcp-live-artifacts-server.js'; import { runArtifactsCli } from './artifacts-cli.js'; import { runConnectorsToolCli } from './tools-connectors-cli.js'; +import { runDesignSystemsToolCli } from './tools-design-systems-cli.js'; import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js'; import { splitResearchSubcommand } from './research/cli-args.js'; import { resolveDaemonUrl } from './daemon-url.js'; @@ -264,6 +265,16 @@ if (argv[0] === 'tools' && argv[1] === 'live-artifacts') { process.stderr.write(`${JSON.stringify({ ok: false, error: { message } })}\n`); process.exitCode = 1; }); +} else if (argv[0] === 'tools' && argv[1] === 'design-systems') { + runDesignSystemsToolCli(argv.slice(2)) + .then(({ exitCode }) => { + process.exitCode = exitCode; + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${JSON.stringify({ ok: false, error: { message } })}\n`); + process.exitCode = 1; + }); } else { await runDaemonCliStartup(argv, { printHelp: printRootHelp }); } @@ -282,6 +293,9 @@ function printRootHelp() { od tools connectors [options] Discover and execute configured connectors. + od tools design-systems read --path + Read active design-system pull-layer files through daemon wrapper commands. + od mcp live-artifacts Start the MCP server exposing live-artifact and connector tools. diff --git a/apps/daemon/src/design-system-github-import.ts b/apps/daemon/src/design-system-github-import.ts index b9616924c..ce6e890f5 100644 --- a/apps/daemon/src/design-system-github-import.ts +++ b/apps/daemon/src/design-system-github-import.ts @@ -14,7 +14,7 @@ const execFileAsync = promisify(execFile); export type GitHubDesignSystemImportOptions = Pick< LocalDesignSystemImportOptions, - 'name' | 'now' | 'reservedIds' + 'craftApplies' | 'importMode' | 'name' | 'now' | 'reservedIds' > & { branch?: string; gitBin?: string; @@ -63,6 +63,8 @@ export async function importGitHubDesignSystemProject( fallbackName: parsed.repo, ...(options.name ? { name: options.name } : {}), ...(options.reservedIds ? { reservedIds: options.reservedIds } : {}), + ...(options.importMode ? { importMode: options.importMode } : {}), + ...(options.craftApplies ? { craftApplies: options.craftApplies } : {}), source: { type: 'github', url: parsed.cloneUrl, diff --git a/apps/daemon/src/design-system-import.ts b/apps/daemon/src/design-system-import.ts index d6fbe923d..70e0cc7a1 100644 --- a/apps/daemon/src/design-system-import.ts +++ b/apps/daemon/src/design-system-import.ts @@ -1,6 +1,8 @@ import { copyFile, mkdir, readFile, readdir, realpath, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { extractComponentsManifest } from '@open-design/contracts'; + export type LocalDesignSystemImportResult = { id: string; dir: string; @@ -13,6 +15,8 @@ export type LocalDesignSystemImportOptions = { fallbackName?: string; reservedIds?: Iterable; source?: DesignSystemProjectSource; + importMode?: 'normalized' | 'hybrid' | 'verbatim'; + craftApplies?: string[]; }; export type DesignSystemProjectSource = @@ -38,7 +42,9 @@ type ProjectScan = { cssVariables: CssVariable[]; tailwindSignals: string[]; assets: AssetCandidate[]; + fonts: FileCandidate[]; components: ComponentSignal[]; + files: ProjectFile[]; }; type CssVariable = { @@ -53,9 +59,23 @@ type AssetCandidate = { size: number; }; +type FileCandidate = { + absPath: string; + relPath: string; + size: number; +}; + +type ProjectFile = { + absPath: string; + relPath: string; + size: number; +}; + type ComponentSignal = { name: string; relPath: string; + absPath: string; + size: number; }; const IGNORED_DIRS = new Set([ @@ -76,6 +96,7 @@ const IGNORED_DIRS = new Set([ const STYLE_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less']); const COMPONENT_EXTENSIONS = new Set(['.tsx', '.jsx', '.vue', '.svelte']); const ASSET_EXTENSIONS = new Set(['.svg', '.png', '.jpg', '.jpeg', '.webp', '.ico']); +const FONT_EXTENSIONS = new Set(['.woff', '.woff2', '.ttf', '.otf']); const COMPONENT_NAMES = ['Button', 'Input', 'Card', 'Nav', 'Navbar', 'Sidebar']; const TOKEN_FALLBACKS = { bg: '#f8fafc', @@ -116,19 +137,44 @@ export async function importLocalDesignSystemProject( const id = await nextAvailableSlug(userDesignSystemsRoot, slugify(displayName), options.reservedIds); const outDir = path.join(userDesignSystemsRoot, id); await mkdir(outDir, { recursive: true }); + const importMode = normalizeImportMode(options.importMode); + const craftApplies = normalizeCraftList(options.craftApplies); - const files = ['DESIGN.md', 'tokens.css', 'components.html', 'manifest.json']; - await writeFile(path.join(outDir, 'DESIGN.md'), renderDesignMd(id, displayName, scan), 'utf8'); - await writeFile(path.join(outDir, 'tokens.css'), renderTokensCss(scan), 'utf8'); - await writeFile(path.join(outDir, 'components.html'), renderComponentsHtml(displayName), 'utf8'); + const files = ['USAGE.md', 'DESIGN.md', 'tokens.css', 'components.html', 'components.manifest.json', 'manifest.json']; + const designMd = renderDesignMd(id, displayName, scan); + const tokensCss = renderTokensCss(scan); + const componentsHtml = renderComponentsHtml(displayName); + const componentsManifest = extractComponentsManifest({ + brandId: id, + fixtureHtml: componentsHtml, + tokensCss, + }); + + await writeFile(path.join(outDir, 'USAGE.md'), renderUsageMd(displayName, scan), 'utf8'); + await writeFile(path.join(outDir, 'DESIGN.md'), designMd, 'utf8'); + await writeFile(path.join(outDir, 'tokens.css'), tokensCss, 'utf8'); + await writeFile(path.join(outDir, 'components.html'), componentsHtml, 'utf8'); + await writeFile( + path.join(outDir, 'components.manifest.json'), + `${JSON.stringify(componentsManifest, null, 2)}\n`, + 'utf8', + ); + files.push(...(await writePreviewFiles(outDir, displayName, scan))); + files.push(...(await writeSourceEvidenceFiles(outDir, scan))); await writeFile( path.join(outDir, 'manifest.json'), - `${JSON.stringify(renderManifest(id, displayName, scan, options.now ?? new Date(), options.source), null, 2)}\n`, + `${JSON.stringify( + renderManifest(id, displayName, scan, options.now ?? new Date(), options.source, importMode, craftApplies), + null, + 2, + )}\n`, 'utf8', ); const copiedAssets = await copyAssets(scan.assets, outDir); + const copiedFonts = await copyFonts(scan.fonts, outDir); files.push(...copiedAssets); + files.push(...copiedFonts); return { id, dir: outDir, files }; } @@ -163,7 +209,9 @@ async function scanProject(sourceRoot: string): Promise { cssVariables, tailwindSignals: await readTailwindSignals(sourceRoot), assets: await findAssets(sourceRoot, files), + fonts: findFonts(sourceRoot, files), components: findComponentSignals(files), + files, }; } @@ -294,19 +342,30 @@ async function findAssets( })); } -function findComponentSignals(files: Array<{ absPath: string; relPath: string }>): ComponentSignal[] { +function findComponentSignals(files: ProjectFile[]): ComponentSignal[] { const found = new Map(); for (const file of files) { if (!COMPONENT_EXTENSIONS.has(path.extname(file.relPath).toLowerCase())) continue; const basename = path.basename(file.relPath).replace(/\.[^.]+$/, ''); const component = COMPONENT_NAMES.find((name) => basename.toLowerCase().includes(name.toLowerCase())); if (component && !found.has(component)) { - found.set(component, { name: component, relPath: normalizeRel(file.relPath) }); + found.set(component, { name: component, relPath: normalizeRel(file.relPath), absPath: file.absPath, size: file.size }); } } return Array.from(found.values()).slice(0, 10); } +function findFonts(sourceRoot: string, files: ProjectFile[]): FileCandidate[] { + return files + .filter((file) => FONT_EXTENSIONS.has(path.extname(file.relPath).toLowerCase()) && file.size <= 2 * 1024 * 1024) + .slice(0, 8) + .map((file) => ({ + absPath: file.absPath, + relPath: normalizeRel(path.relative(sourceRoot, file.absPath)), + size: file.size, + })); +} + async function copyAssets(assets: AssetCandidate[], outDir: string): Promise { if (assets.length === 0) return []; const assetsDir = path.join(outDir, 'assets'); @@ -320,6 +379,19 @@ async function copyAssets(assets: AssetCandidate[], outDir: string): Promise { + if (fonts.length === 0) return []; + const fontsDir = path.join(outDir, 'fonts'); + await mkdir(fontsDir, { recursive: true }); + const copied: string[] = []; + for (const font of fonts) { + const targetName = slugify(path.basename(font.relPath, path.extname(font.relPath))) + path.extname(font.relPath).toLowerCase(); + await copyFile(font.absPath, path.join(fontsDir, targetName)); + copied.push(`fonts/${targetName}`); + } + return copied; +} + async function nextAvailableSlug( root: string, preferred: string, @@ -346,6 +418,8 @@ function renderManifest( scan: ProjectScan, now: Date, sourceOverride: DesignSystemProjectSource | undefined, + importMode: 'normalized' | 'hybrid' | 'verbatim', + craftApplies: string[], ) { const importedAt = now.toISOString(); const source = sourceOverride ?? { @@ -368,10 +442,61 @@ function renderManifest( tokens: 'tokens.css', components: 'components.html', }, + usage: 'USAGE.md', + componentsManifest: 'components.manifest.json', + importMode, + craft: { + applies: craftApplies, + suggested: craftApplies.includes('color') ? [] : ['color'], + exemptions: [], + }, ...(scan.assets.length > 0 ? { assetsDir: 'assets' } : {}), + ...(scan.fonts.length > 0 + ? { + fonts: scan.fonts.map((font) => ({ + family: cleanDisplayName(path.basename(font.relPath, path.extname(font.relPath))), + file: `fonts/${slugify(path.basename(font.relPath, path.extname(font.relPath)))}${path.extname(font.relPath).toLowerCase()}`, + })), + } + : {}), + preview: { + dir: 'preview', + pages: [ + { path: 'preview/colors.html', role: 'colors', title: 'Colors' }, + { path: 'preview/typography.html', role: 'typography', title: 'Typography' }, + { path: 'preview/spacing.html', role: 'spacing', title: 'Spacing' }, + { path: 'preview/components-buttons.html', role: 'buttons', title: 'Buttons' }, + { path: 'preview/components-inputs.html', role: 'inputs', title: 'Inputs' }, + { path: 'preview/app.html', role: 'app', title: 'App Preview' }, + ], + }, + sourceFiles: { + scanned: 'source/scanned-files.json', + evidence: 'source/evidence.md', + tokens: 'source/tokens.source.json', + snippets: 'source/snippets/INDEX.json', + }, }; } +function normalizeImportMode(value: unknown): 'normalized' | 'hybrid' | 'verbatim' { + return value === 'normalized' || value === 'verbatim' || value === 'hybrid' ? value : 'hybrid'; +} + +function normalizeCraftList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const out: string[] = []; + for (const entry of value) { + if (typeof entry !== 'string') continue; + const slug = entry.trim().toLowerCase(); + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) || seen.has(slug)) continue; + seen.add(slug); + out.push(slug); + } + return out; +} + function renderDesignMd(id: string, name: string, scan: ProjectScan): string { const colors = tokenCandidates(scan.cssVariables, ['color', 'accent', 'primary', 'background', 'surface', 'border']) .slice(0, 16) @@ -418,6 +543,46 @@ function renderDesignMd(id: string, name: string, scan: ProjectScan): string { ].join('\n'); } +function renderUsageMd(name: string, scan: ProjectScan): string { + const highlights = [ + scan.packageDescription, + scan.packageTech.length > 0 ? `Detected stack: ${scan.packageTech.join(', ')}` : undefined, + scan.cssVariables.length > 0 ? `${scan.cssVariables.length} CSS custom properties found` : undefined, + scan.components.length > 0 ? `Representative source components: ${scan.components.map((component) => component.name).join(', ')}` : undefined, + ].filter((line): line is string => line !== undefined); + + return [ + `# ${name} Usage`, + '', + '> Auto-generated by Open Design importer. Review and edit before treating it as a canonical brand guide.', + '', + '## Read Order', + '', + '1. Read `DESIGN.md` for product context and visual principles.', + '2. Paste `tokens.css` into the first ` + + +
+

Generated preview

+

${escapeHtml(heading)}

+
${body}
+
+ + +`; +} + +function renderEvidenceMd(scan: ProjectScan): string { + return [ + '# Import Evidence', + '', + `- Source root: \`${scan.sourceRoot}\``, + `- Package: ${scan.packageName === undefined ? 'not declared' : `\`${scan.packageName}\``}`, + `- Description: ${scan.packageDescription ?? 'not declared'}`, + `- CSS variables: ${scan.cssVariables.length}`, + `- Tailwind signals: ${scan.tailwindSignals.length > 0 ? scan.tailwindSignals.join(', ') : 'none detected'}`, + `- Assets copied: ${scan.assets.length}`, + `- Fonts copied: ${scan.fonts.length}`, + `- Representative snippets: ${scan.components.length}`, + '', + '## Representative Components', + '', + scan.components.length > 0 + ? scan.components.map((component) => `- ${component.name}: \`${component.relPath}\``).join('\n') + : '- None detected.', + '', + '## Token Evidence', + '', + scan.cssVariables.length > 0 + ? scan.cssVariables.slice(0, 40).map((token) => `- \`${token.name}: ${token.value}\` from \`${token.source}\``).join('\n') + : '- No CSS custom properties detected; normalized tokens use fallback values.', + '', + ].join('\n'); +} + function pickDesignTokens(tokens: CssVariable[]): typeof TOKEN_FALLBACKS { const valueFor = (needles: string[], fallback: string, validator: (value: string) => boolean = Boolean) => tokenCandidates(tokens, needles).find((token) => validator(token.value))?.value ?? fallback; @@ -553,6 +940,30 @@ function isColorValue(value: string): boolean { return /^(#(?:[0-9a-f]{3,8})|rgb[a]?\(|hsl[a]?\(|oklch\(|color-mix\(|var\()/i.test(value.trim()); } +function classifyScannedFile(relPath: string): string { + const ext = path.extname(relPath).toLowerCase(); + if (STYLE_EXTENSIONS.has(ext)) return 'style'; + if (COMPONENT_EXTENSIONS.has(ext)) return 'component'; + if (ASSET_EXTENSIONS.has(ext)) return 'asset'; + if (FONT_EXTENSIONS.has(ext)) return 'font'; + if (path.basename(relPath).toLowerCase().includes('readme')) return 'readme'; + if (path.basename(relPath) === 'package.json') return 'package'; + return 'other'; +} + +function inferTokenRole(token: CssVariable): string | undefined { + const name = token.name.toLowerCase(); + if (['background', 'bg'].some((needle) => name.includes(needle))) return 'background'; + if (['surface', 'card'].some((needle) => name.includes(needle))) return 'surface'; + if (['foreground', 'text', 'fg'].some((needle) => name.includes(needle))) return 'foreground'; + if (name.includes('border')) return 'border'; + if (['accent', 'primary', 'brand'].some((needle) => name.includes(needle))) return 'accent'; + if (name.includes('radius')) return 'radius'; + if (name.includes('font')) return 'font'; + if (name.includes('space') || name.includes('gap')) return 'spacing'; + return undefined; +} + function compactMarkdown(raw: string): string { return raw .replace(/```[\s\S]*?```/g, '') diff --git a/apps/daemon/src/design-system-tool-routes.ts b/apps/daemon/src/design-system-tool-routes.ts new file mode 100644 index 000000000..d8b246966 --- /dev/null +++ b/apps/daemon/src/design-system-tool-routes.ts @@ -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, +) => 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)) + ); +} diff --git a/apps/daemon/src/design-systems.ts b/apps/daemon/src/design-systems.ts index d006c271d..7bd714757 100644 --- a/apps/daemon/src/design-systems.ts +++ b/apps/daemon/src/design-systems.ts @@ -14,6 +14,7 @@ import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import path from 'node:path'; import { + type ComponentsManifest, extractComponentsManifest, summarizeComponentsManifestForPrompt, } from '@open-design/contracts'; @@ -65,6 +66,27 @@ export type DesignSystemFileDetail = DesignSystemFileSummary & { content: string; }; +export type DesignSystemPullFileDetail = { + path: string; + name: string; + kind: DesignSystemFileKind; + size: number; + updatedAt: string; + encoding: 'utf8' | 'base64'; + content: string; +}; + +export type DesignSystemPackageInfo = { + manifest?: DesignSystemProjectManifest; + sourceEvidence?: { + scannedFileCount?: number; + tokenCount?: number; + snippetCount?: number; + confidence?: Record; + evidenceExcerpt?: string; + }; +}; + export type DesignSystemRevision = { id: string; designSystemId: string; @@ -91,6 +113,36 @@ type DesignSystemProjectManifest = { tokens: 'tokens.css'; components?: 'components.html'; }; + assetsDir?: 'assets'; + previewDir?: 'preview'; + usage?: string; + componentsManifest?: string; + fonts?: Array<{ + family: string; + file: string; + weight?: number | string; + style?: string; + }>; + preview?: { + dir: string; + pages: Array<{ + path: string; + role?: string; + title?: string; + }>; + }; + sourceFiles?: { + scanned?: string; + evidence?: string; + tokens?: string; + snippets?: string; + }; + importMode?: 'normalized' | 'hybrid' | 'verbatim'; + craft?: { + applies?: string[]; + suggested?: string[]; + exemptions?: string[]; + }; }; export type DesignSystemProvenance = { @@ -297,6 +349,24 @@ export async function readDesignSystem( } } +export async function readDesignSystemPackageInfo( + root: string, + id: string, + options: { idPrefix?: string } = {}, +): Promise { + const dirId = stripPrefixAndValidateId(id, options.idPrefix); + if (!dirId) return null; + const brandRoot = path.join(root, dirId); + const manifest = await readProjectManifest(brandRoot, dirId); + if (manifest === null) return null; + + const sourceEvidence = await readDesignSystemSourceEvidence(brandRoot, manifest); + return { + manifest, + ...(sourceEvidence ? { sourceEvidence } : {}), + }; +} + /** * Structured (compiled) form of a brand's design system. Optional sibling * files alongside DESIGN.md that, when present, give agents a @@ -307,15 +377,25 @@ export async function readDesignSystem( * hand-authored or derived tokens. * * - `tokensCss` — verbatim content of `/tokens.css`. + * - `usageMd` — optional agent-facing router for the package. * - `fixtureHtml` — verbatim content of `/components.html`. * - `componentsManifest` — concise summary derived from components.html + * or read from components.manifest.json cache * for prompt injection; when absent, callers * can fall back to `fixtureHtml`. + * - `pullIndex` — short manifest-derived file index. It lists + * richer preview/source evidence paths without + * loading those files into the push prompt. */ export type DesignSystemAssets = { + usageMd?: string | undefined; tokensCss?: string | undefined; fixtureHtml?: string | undefined; componentsManifest?: string | undefined; + pullIndex?: string | undefined; + importMode?: 'normalized' | 'hybrid' | 'verbatim' | undefined; + craftApplies?: string[] | undefined; + craftExemptions?: string[] | undefined; }; export async function readDesignSystemAssets( @@ -326,13 +406,66 @@ export async function readDesignSystemAssets( if (!dirId) return {}; const brandRoot = path.join(root, dirId); const manifest = await readProjectManifest(brandRoot, dirId); - const [tokensCss, fixtureHtml] = await Promise.all([ + const [usageMd, tokensCss, fixtureHtml, componentsManifestJson] = await Promise.all([ + readManifestFileOptional(brandRoot, manifest?.usage ?? 'USAGE.md'), readFileOptional(path.join(brandRoot, manifest?.files.tokens ?? 'tokens.css')), manifest?.files.components === undefined && manifest !== null ? Promise.resolve(undefined) : readFileOptional(path.join(brandRoot, manifest?.files.components ?? 'components.html')), + readManifestFileOptional(brandRoot, manifest?.componentsManifest ?? 'components.manifest.json'), ]); - return withComponentsManifest(id, { tokensCss, fixtureHtml }); + return withComponentsManifest(id, { + usageMd, + tokensCss, + fixtureHtml, + componentsManifestJson, + pullIndex: buildDesignSystemPullIndex(manifest), + importMode: manifest?.importMode, + craftApplies: manifest?.craft?.applies, + craftExemptions: manifest?.craft?.exemptions, + }); +} + +export async function readDesignSystemPullFile( + root: string, + id: string, + relativePath: string, +): Promise { + const dirId = stripPrefixAndValidateId(id, id.startsWith('user:') ? 'user:' : ''); + const cleanPath = sanitizeRelativeFilePath(relativePath); + if (!dirId || !cleanPath) return null; + + const brandRoot = path.join(root, dirId); + const manifest = await readProjectManifest(brandRoot, dirId); + if (manifest === null) return null; + + const allowed = await buildDesignSystemPullFileAllowlist(brandRoot, manifest); + if (!allowed.has(cleanPath)) return null; + + const resolvedRoot = path.resolve(brandRoot); + const filePath = path.resolve(brandRoot, cleanPath); + if (filePath !== resolvedRoot && !filePath.startsWith(`${resolvedRoot}${path.sep}`)) { + return null; + } + + try { + const stats = await stat(filePath); + if (!stats.isFile()) return null; + const bytes = await readFile(filePath); + const encoding = isTextDesignSystemPullFile(cleanPath) ? 'utf8' : 'base64'; + return { + path: cleanPath, + name: path.basename(cleanPath), + kind: classifyDesignSystemFile(cleanPath, false), + size: stats.size, + updatedAt: stats.mtime.toISOString(), + encoding, + content: encoding === 'utf8' ? bytes.toString('utf8') : bytes.toString('base64'), + }; + } catch (err) { + if (isAbsenceError(err)) return null; + throw err; + } } export function isDesignTokenChannelEnabled( @@ -348,7 +481,16 @@ export async function resolveDesignSystemAssets( env: NodeJS.ProcessEnv = process.env, ): Promise { if (!isDesignTokenChannelEnabled(env)) { - return { tokensCss: undefined, fixtureHtml: undefined }; + return { + usageMd: undefined, + tokensCss: undefined, + fixtureHtml: undefined, + componentsManifest: undefined, + pullIndex: undefined, + importMode: undefined, + craftApplies: undefined, + craftExemptions: undefined, + }; } const builtIn = await readDesignSystemAssets(builtInRoot, designSystemId); @@ -358,21 +500,53 @@ export async function resolveDesignSystemAssets( const userInstalled = await readDesignSystemAssets(userInstalledRoot, designSystemId); return withComponentsManifest(designSystemId, { + usageMd: builtIn.usageMd ?? userInstalled.usageMd, tokensCss: builtIn.tokensCss ?? userInstalled.tokensCss, fixtureHtml: builtIn.fixtureHtml ?? userInstalled.fixtureHtml, + componentsManifestJson: undefined, + componentsManifest: builtIn.componentsManifest ?? userInstalled.componentsManifest, + pullIndex: builtIn.pullIndex ?? userInstalled.pullIndex, + importMode: builtIn.importMode ?? userInstalled.importMode, + craftApplies: builtIn.craftApplies ?? userInstalled.craftApplies, + craftExemptions: builtIn.craftExemptions ?? userInstalled.craftExemptions, }); } function withComponentsManifest( designSystemId: string, - assets: Pick, + assets: Pick< + DesignSystemAssets, + | 'usageMd' + | 'tokensCss' + | 'fixtureHtml' + | 'componentsManifest' + | 'pullIndex' + | 'importMode' + | 'craftApplies' + | 'craftExemptions' + > & { + componentsManifestJson?: string | undefined; + }, ): DesignSystemAssets { - const componentsManifest = buildComponentsManifestSummary( - designSystemId, - assets.fixtureHtml, - assets.tokensCss, - ); - return { ...assets, componentsManifest }; + const { componentsManifestJson, ...publicAssets } = assets; + const componentsManifest = + publicAssets.componentsManifest + ?? summarizeComponentsManifestCache(componentsManifestJson) + ?? buildComponentsManifestSummary( + designSystemId, + publicAssets.fixtureHtml, + publicAssets.tokensCss, + ); + return { ...publicAssets, componentsManifest }; +} + +function summarizeComponentsManifestCache(raw: string | undefined): string | undefined { + if (raw === undefined || raw.trim().length === 0) return undefined; + try { + return summarizeComponentsManifestForPrompt(JSON.parse(raw) as ComponentsManifest); + } catch { + return undefined; + } } function buildComponentsManifestSummary( @@ -395,6 +569,196 @@ function buildComponentsManifestSummary( } } +function buildDesignSystemPullIndex( + manifest: DesignSystemProjectManifest | null, +): string | undefined { + if (manifest === null) return undefined; + const entries: string[] = []; + const add = (filePath: string | undefined, label: string): void => { + if (!filePath || !isSafeManifestPath(filePath)) return; + entries.push(`- ${filePath}: ${label}`); + }; + + if (manifest.preview?.pages) { + for (const page of manifest.preview.pages) { + if (!isSafeManifestPath(page.path)) continue; + const labelParts = [page.title, page.role].filter((part) => typeof part === 'string' && part.trim().length > 0); + entries.push(`- ${page.path}: ${labelParts.join('; ') || 'preview page'}`); + } + } else if (manifest.previewDir === 'preview') { + entries.push('- preview/: preview pages'); + } + + if (manifest.assetsDir === 'assets') entries.push('- assets/: brand assets'); + for (const font of manifest.fonts ?? []) { + add(font.file, `font: ${font.family}${font.weight ? ` ${font.weight}` : ''}${font.style ? ` ${font.style}` : ''}`); + } + + add(manifest.sourceFiles?.scanned, 'scanned source file inventory'); + add(manifest.sourceFiles?.evidence, 'import evidence notes'); + add(manifest.sourceFiles?.tokens, 'source-token evidence'); + add(manifest.sourceFiles?.snippets, 'source snippet index'); + + if (entries.length === 0) return undefined; + return ['Additional design-system files declared by manifest.json:', ...entries].join('\n'); +} + +async function buildDesignSystemPullFileAllowlist( + brandRoot: string, + manifest: DesignSystemProjectManifest, +): Promise> { + const allowed = new Set(); + 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, +): Promise { + 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, +): Promise { + 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 { + 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 = {}; + 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 = {}; + 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 { + if (!relativePath) return undefined; + const raw = await readManifestFileOptional(brandRoot, relativePath); + if (raw === undefined) return undefined; + try { + return JSON.parse(raw) as unknown; + } catch { + return undefined; + } +} + export async function createUserDesignSystem( root: string, input: UserDesignSystemInput, @@ -2305,6 +2669,21 @@ async function readFileOptional(file: string): Promise { } } +async function readManifestFileOptional( + brandRoot: string, + relativePath: string, +): Promise { + if (!isSafeManifestPath(relativePath)) return undefined; + return readFileOptional(path.join(brandRoot, relativePath)); +} + +function isSafeManifestPath(relativePath: string): boolean { + if (relativePath.trim().length === 0) return false; + if (path.isAbsolute(relativePath)) return false; + const parts = relativePath.split(/[\\/]+/); + return parts.every((part) => part.length > 0 && part !== '.' && part !== '..'); +} + function isAbsenceError(err: unknown): boolean { if (typeof err !== 'object' || err === null) return false; const code = (err as { code?: unknown }).code; diff --git a/apps/daemon/src/prompts/system.ts b/apps/daemon/src/prompts/system.ts index eefdae7e9..fed5a7856 100644 --- a/apps/daemon/src/prompts/system.ts +++ b/apps/daemon/src/prompts/system.ts @@ -171,6 +171,23 @@ Active design system exception: the active design system is the visual direction - When a downstream framework mentions "active direction" or "theme tokens", bind those fields from the active design system instead of the built-in direction library. `; +const DEFAULT_DESIGN_SYSTEM_USAGE = `Read DESIGN.md for visual principles, paste tokens.css verbatim into the first + + +
+
+
+

Review workspace

+

A quiet operational layout using the default system.

+
+ +
+
+
+ + + + + + + +
ArtifactStatusOwner
Dashboard shellReadyDesign
Import flowReviewPlatform
Token auditQueuedQuality
+
+ +
+
+ + diff --git a/design-systems/default/preview/colors.html b/design-systems/default/preview/colors.html new file mode 100644 index 000000000..9522bc324 --- /dev/null +++ b/design-systems/default/preview/colors.html @@ -0,0 +1,66 @@ + + + + + + Neutral Modern colors + + + + +
+

Color roles

+
+
Background--bg
+
Surface--surface
+
Foreground--fg
+
Muted--muted
+
Border--border
+
Accent--accent
+
+
+ + diff --git a/design-systems/default/preview/components-buttons.html b/design-systems/default/preview/components-buttons.html new file mode 100644 index 000000000..da16ef6b2 --- /dev/null +++ b/design-systems/default/preview/components-buttons.html @@ -0,0 +1,53 @@ + + + + + + Neutral Modern buttons + + + + +
+
+ + + +
+
+ + diff --git a/design-systems/default/preview/components-inputs.html b/design-systems/default/preview/components-inputs.html new file mode 100644 index 000000000..0bb3f4957 --- /dev/null +++ b/design-systems/default/preview/components-inputs.html @@ -0,0 +1,53 @@ + + + + + + Neutral Modern inputs + + + + +
+
+
+
+ + diff --git a/design-systems/default/preview/spacing.html b/design-systems/default/preview/spacing.html new file mode 100644 index 000000000..0fe021b95 --- /dev/null +++ b/design-systems/default/preview/spacing.html @@ -0,0 +1,54 @@ + + + + + + Neutral Modern spacing + + + + +
+

Spacing ramp

+
--space-2
+
--space-4
+
--space-6
+
--space-8
+
--space-12
+
--space-20
+
+ + diff --git a/design-systems/default/preview/typography.html b/design-systems/default/preview/typography.html new file mode 100644 index 000000000..3dd80cd56 --- /dev/null +++ b/design-systems/default/preview/typography.html @@ -0,0 +1,62 @@ + + + + + + Neutral Modern typography + + + + +
+
Display
Quiet product confidence
+
Heading
A scannable workspace heading
+
Body
Neutral Modern keeps body copy plain, compact, and easy to scan across dense product surfaces.
+
Small
Status text, table metadata, and secondary helper copy use the muted ramp.
+
+ + diff --git a/packages/contracts/src/api/registry.ts b/packages/contracts/src/api/registry.ts index e5ed15f1a..86584c0b5 100644 --- a/packages/contracts/src/api/registry.ts +++ b/packages/contracts/src/api/registry.ts @@ -162,6 +162,49 @@ export interface DesignSystemSummary { export interface DesignSystemDetail extends DesignSystemSummary { body: string; + packageInfo?: DesignSystemPackageInfo; +} + +export interface DesignSystemPackageInfo { + manifest?: { + schemaVersion: string; + id: string; + name: string; + category: string; + source?: { type?: string; url?: string; path?: string; branch?: string; commit?: string; importedAt?: string }; + files?: { + design?: string; + tokens?: string; + components?: string; + }; + usage?: string; + componentsManifest?: string; + importMode?: string; + craft?: { + applies?: string[]; + suggested?: string[]; + exemptions?: string[]; + }; + fonts?: Array<{ family?: string; weight?: string | number; style?: string; file?: string }>; + preview?: { + dir?: string; + pages?: Array<{ path?: string; role?: string; title?: string }>; + }; + sourceFiles?: { + scanned?: string; + evidence?: string; + tokens?: string; + snippets?: string; + }; + assetsDir?: string; + }; + sourceEvidence?: { + scannedFileCount?: number; + tokenCount?: number; + snippetCount?: number; + confidence?: Record; + evidenceExcerpt?: string; + }; } export interface DesignSystemsResponse { @@ -311,6 +354,10 @@ export interface ImportLocalDesignSystemRequest { baseDir: string; /** Optional display name override for the generated design-system project. */ name?: string; + /** Import structure mode. Defaults to hybrid for real project imports. */ + importMode?: 'normalized' | 'hybrid' | 'verbatim'; + /** Craft sections that should actively apply when this system is used. */ + craftApplies?: string[]; } export interface ImportLocalDesignSystemResponse { @@ -324,6 +371,10 @@ export interface ImportGitHubDesignSystemRequest { branch?: string; /** Optional display name override for the generated design-system project. */ name?: string; + /** Import structure mode. Defaults to hybrid for real project imports. */ + importMode?: 'normalized' | 'hybrid' | 'verbatim'; + /** Craft sections that should actively apply when this system is used. */ + craftApplies?: string[]; } export interface ImportGitHubDesignSystemResponse { diff --git a/scripts/check-design-system-manifests.test.ts b/scripts/check-design-system-manifests.test.ts index 3c663bda8..b6a0a1d94 100644 --- a/scripts/check-design-system-manifests.test.ts +++ b/scripts/check-design-system-manifests.test.ts @@ -3,8 +3,10 @@ import test from "node:test"; import { DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION, + type DesignSystemProjectManifest, validateDesignSystemProjectManifest, } from "../design-systems/_schema/manifest.schema.ts"; +import { validateManifestSemantics } from "./check-design-system-manifests.ts"; test("design-system project manifest schema accepts the v1 minimum shape", () => { const result = validateDesignSystemProjectManifest({ @@ -95,3 +97,153 @@ test("design-system project manifest schema rejects path drift and unknown keys" assert.match(errors, /\$\.extra/); } }); + +test("design-system project manifest schema accepts import-project optional indexes", () => { + const result = validateDesignSystemProjectManifest({ + schemaVersion: DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION, + id: "cherry-studio", + name: "Cherry Studio", + category: "AI & LLM", + source: { + type: "github", + url: "https://github.com/cherryhq/cherry-studio", + branch: "main", + commit: "abc123", + importedAt: "2026-05-19T00:00:00.000Z", + }, + files: { + design: "DESIGN.md", + tokens: "tokens.css", + components: "components.html", + }, + assetsDir: "assets", + previewDir: "preview", + usage: "USAGE.md", + componentsManifest: "components.manifest.json", + importMode: "hybrid", + craft: { + applies: ["color"], + suggested: ["accessibility-baseline"], + exemptions: [], + }, + fonts: [ + { family: "Ubuntu", weight: 400, file: "fonts/ubuntu/Ubuntu-Regular.ttf" }, + { family: "Ubuntu", weight: 500, style: "normal", file: "fonts/ubuntu/Ubuntu-Medium.ttf" }, + ], + preview: { + dir: "preview", + pages: [ + { path: "preview/colors.html", role: "colors", title: "Colors" }, + { path: "preview/app.html", role: "app" }, + ], + }, + sourceFiles: { + scanned: "source/scanned-files.json", + evidence: "source/evidence.md", + tokens: "source/tokens.source.json", + snippets: "source/snippets/INDEX.json", + }, + }); + + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.manifest.usage, "USAGE.md"); + assert.equal(result.manifest.componentsManifest, "components.manifest.json"); + assert.equal(result.manifest.importMode, "hybrid"); + assert.equal(result.manifest.preview?.pages.length, 2); + } +}); + +test("design-system project manifest schema requires craft slug format", () => { + const result = validateDesignSystemProjectManifest({ + schemaVersion: DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION, + id: "cherry-studio", + name: "Cherry Studio", + category: "AI & LLM", + source: { type: "local", path: "/tmp/cherry-studio" }, + files: { + design: "DESIGN.md", + tokens: "tokens.css", + }, + craft: { + applies: ["Color"], + suggested: ["accessibility baseline"], + exemptions: [""], + }, + }); + + assert.equal(result.ok, false); + if (!result.ok) { + const errors = result.errors.join("\n"); + assert.match(errors, /\$\.craft\.applies\[0\]/); + assert.match(errors, /\$\.craft\.suggested\[0\]/); + assert.match(errors, /\$\.craft\.exemptions\[0\]/); + } +}); + +test("design-system manifest semantics connect craft and importMode declarations to known evidence", () => { + const manifest: DesignSystemProjectManifest = { + schemaVersion: DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION, + id: "cherry-studio", + name: "Cherry Studio", + category: "AI & LLM", + source: { type: "local", path: "/tmp/cherry-studio" }, + files: { + design: "DESIGN.md", + tokens: "tokens.css", + }, + importMode: "verbatim", + craft: { + applies: ["color", "missing-craft"], + suggested: [], + exemptions: ["color"], + }, + sourceFiles: { + scanned: "source/scanned-files.json", + }, + }; + const violations: string[] = []; + + validateManifestSemantics(violations, "design-systems/cherry-studio/manifest.json", manifest, new Set(["color"])); + + assert.deepEqual(violations, [ + 'design-systems/cherry-studio/manifest.json: $.craft.applies references unknown craft "missing-craft"', + 'design-systems/cherry-studio/manifest.json: craft "color" cannot be both applied and exempted', + "design-systems/cherry-studio/manifest.json: verbatim imports must declare sourceFiles.tokens", + "design-systems/cherry-studio/manifest.json: verbatim imports must declare sourceFiles.snippets", + ]); +}); + +test("design-system project manifest schema rejects unsafe import-project paths", () => { + const result = validateDesignSystemProjectManifest({ + schemaVersion: DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION, + id: "cherry-studio", + name: "Cherry Studio", + category: "AI & LLM", + source: { type: "local", path: "/tmp/cherry-studio" }, + files: { + design: "DESIGN.md", + tokens: "tokens.css", + }, + usage: "../USAGE.md", + componentsManifest: "/tmp/components.manifest.json", + fonts: [{ family: "Ubuntu", file: "fonts\\Ubuntu-Regular.ttf" }], + preview: { + dir: "preview", + pages: [{ path: "preview//colors.html" }], + }, + sourceFiles: { + scanned: "source/../scanned-files.json", + }, + }); + + assert.equal(result.ok, false); + if (!result.ok) { + const errors = result.errors.join("\n"); + assert.match(errors, /\$\.usage/); + assert.match(errors, /\$\.componentsManifest/); + assert.match(errors, /\$\.fonts\[0\]\.file/); + assert.match(errors, /\$\.preview\.pages\[0\]\.path/); + assert.match(errors, /\$\.sourceFiles\.scanned/); + } +}); diff --git a/scripts/check-design-system-manifests.ts b/scripts/check-design-system-manifests.ts index e95163307..3f06072f9 100644 --- a/scripts/check-design-system-manifests.ts +++ b/scripts/check-design-system-manifests.ts @@ -10,13 +10,17 @@ * ─────────────────────────────────────────────────────────────────── */ import { access, readFile, readdir } from "node:fs/promises"; +import { isDeepStrictEqual } from "node:util"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { parseDesignSystemProjectManifest } from "../design-systems/_schema/manifest.schema.ts"; +import type { DesignSystemProjectManifest } from "../design-systems/_schema/manifest.schema.ts"; +import { extractComponentsManifest } from "../packages/contracts/src/design-systems/components-manifest.ts"; const repoRoot = path.resolve(import.meta.dirname, ".."); const designSystemsRoot = path.join(repoRoot, "design-systems"); +const craftRoot = path.join(repoRoot, "craft"); const SKIPPED_DIRECTORIES = new Set(["_schema"]); function toRepositoryPath(filePath: string): string { @@ -32,6 +36,10 @@ async function exists(filePath: string): Promise { } } +async function readJson(filePath: string): Promise { + return JSON.parse(await readFile(filePath, "utf8")) as unknown; +} + async function discoverManifestPaths(): Promise { let entries; try { @@ -52,6 +60,7 @@ async function discoverManifestPaths(): Promise { export async function checkDesignSystemManifests(): Promise { const manifestPaths = await discoverManifestPaths(); + const craftSlugs = await discoverCraftSlugs(); const violations: string[] = []; for (const manifestPath of manifestPaths) { @@ -69,17 +78,20 @@ export async function checkDesignSystemManifests(): Promise { if (manifest.id !== folderSlug) { violations.push(`${repositoryManifestPath}: $.id must match folder slug "${folderSlug}"`); } + validateManifestSemantics(violations, repositoryManifestPath, manifest, craftSlugs); const requiredFiles = [ manifest.files.design, manifest.files.tokens, ...(manifest.files.components === undefined ? [] : [manifest.files.components]), + ...(manifest.usage === undefined ? [] : [manifest.usage]), + ...(manifest.componentsManifest === undefined ? [] : [manifest.componentsManifest]), + ...(manifest.fonts ?? []).map((font) => font.file), + ...(manifest.preview?.pages ?? []).map((page) => page.path), + ...Object.values(manifest.sourceFiles ?? {}), ]; for (const fileName of requiredFiles) { - const target = path.join(brandRoot, fileName); - if (!(await exists(target))) { - violations.push(`${repositoryManifestPath}: ${fileName} is declared but ${toRepositoryPath(target)} does not exist`); - } + await requireDeclaredPathExists(violations, repositoryManifestPath, brandRoot, fileName); } if (manifest.assetsDir !== undefined && !(await exists(path.join(brandRoot, manifest.assetsDir)))) { @@ -88,6 +100,12 @@ export async function checkDesignSystemManifests(): Promise { if (manifest.previewDir !== undefined && !(await exists(path.join(brandRoot, manifest.previewDir)))) { violations.push(`${repositoryManifestPath}: previewDir is declared but ${manifest.previewDir}/ does not exist`); } + if (manifest.preview !== undefined && !(await exists(path.join(brandRoot, manifest.preview.dir)))) { + violations.push(`${repositoryManifestPath}: preview.dir is declared but ${manifest.preview.dir}/ does not exist`); + } + + await validateDeclaredJsonFiles(violations, repositoryManifestPath, brandRoot, manifest.sourceFiles); + await validateComponentsManifestCache(violations, repositoryManifestPath, brandRoot, folderSlug, manifest.componentsManifest); } if (violations.length > 0) { @@ -102,6 +120,131 @@ export async function checkDesignSystemManifests(): Promise { return true; } +async function discoverCraftSlugs(): Promise> { + 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, +): 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 { + 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 | undefined, +): Promise { + 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 { + const cachePath = path.join(brandRoot, declaredComponentsManifest ?? "components.manifest.json"); + if (!(await exists(cachePath))) return; + + try { + const [cachedManifest, fixtureHtml, tokensCss] = await Promise.all([ + readJson(cachePath), + readFile(path.join(brandRoot, "components.html"), "utf8"), + readFile(path.join(brandRoot, "tokens.css"), "utf8"), + ]); + const derivedManifest = extractComponentsManifest({ + brandId: folderSlug, + fixtureHtml, + tokensCss, + }); + if (!isDeepStrictEqual(cachedManifest, derivedManifest)) { + violations.push( + `${repositoryManifestPath}: ${toRepositoryPath(cachePath)} is stale; regenerate it from components.html + tokens.css`, + ); + } + } catch (error) { + violations.push( + `${repositoryManifestPath}: failed to validate ${toRepositoryPath(cachePath)}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +} + if (process.argv[1] === fileURLToPath(import.meta.url)) { const ok = await checkDesignSystemManifests(); if (!ok) process.exitCode = 1; diff --git a/scripts/check-design-system-package-quality.test.ts b/scripts/check-design-system-package-quality.test.ts new file mode 100644 index 000000000..eeed33f56 --- /dev/null +++ b/scripts/check-design-system-package-quality.test.ts @@ -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: ` + + + + +

Title

Subtitle

+ `, + }); + + 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, []); +}); diff --git a/scripts/check-design-system-package-quality.ts b/scripts/check-design-system-package-quality.ts new file mode 100644 index 000000000..13f758bc2 --- /dev/null +++ b/scripts/check-design-system-package-quality.ts @@ -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 { + 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 { + 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 { + 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 { + const tokens = new Set(); + 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; +} diff --git a/scripts/guard.ts b/scripts/guard.ts index 9951322e3..b6cba8e69 100644 --- a/scripts/guard.ts +++ b/scripts/guard.ts @@ -2,6 +2,7 @@ import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { checkDesignSystemManifests } from "./check-design-system-manifests.ts"; +import { checkDesignSystemPackageQuality } from "./check-design-system-package-quality.ts"; import { checkDesignSystemComponentFixtureReport } from "./check-components-fixtures.ts"; import { checkDesignSystemFlagParity } from "./check-design-system-flag-parity.ts"; import { checkComponentsManifestExtraction } from "./check-components-manifest-extraction.ts"; @@ -903,6 +904,7 @@ const checks: GuardCheck[] = [ { name: "tools layout", run: checkToolsLayout }, { name: "style policy", run: checkStylePolicy }, { name: "design system manifests", run: checkDesignSystemManifests }, + { name: "design system package quality", run: checkDesignSystemPackageQuality }, { name: "design system component fixture report", run: checkDesignSystemComponentFixtureReport }, { name: "design system token-fixture sync", run: checkDesignSystemTokenFixtureSync }, { name: "design system A1 required tokens", run: checkDesignSystemA1RequiredTokens }, diff --git a/specs/current/design-system-import-project.md b/specs/current/design-system-import-project.md new file mode 100644 index 000000000..423e554c4 --- /dev/null +++ b/specs/current/design-system-import-project.md @@ -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// +│ +│ ── 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 `