chore: import upstream snapshot with attribution
Test / test (push) Failing after 1s
Test / npm-registry-smoke-windows (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:18:57 +08:00
commit e6ed586119
131 changed files with 17114 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
LICENSE
README.md
smoke-test/
*.tgz
+67
View File
@@ -0,0 +1,67 @@
{
"name": "@google/design.md",
"version": "0.3.0",
"description": "Bridging design systems and code: a linter and exporter for the DESIGN.md format",
"keywords": [
"design.md"
],
"repository": {
"type": "git",
"url": "git+https://github.com/google-labs-code/design.md.git"
},
"bugs": {
"url": "https://github.com/google-labs-code/design.md/issues"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"private": false,
"type": "module",
"bin": {
"design.md": "./dist/index.js",
"designmd": "./dist/index.js"
},
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./linter": {
"import": "./dist/linter/index.js",
"types": "./dist/linter/index.d.ts"
}
},
"publishConfig": {
"registry": "https://wombat-dressing-room.appspot.com",
"access": "public"
},
"scripts": {
"build": "bun build src/index.ts src/linter/index.ts --outdir dist --target node && npx tsc --project tsconfig.build.json --emitDeclarationOnly --skipLibCheck && cp src/linter/spec-config.yaml dist/linter/ && cp src/linter/spec-config.yaml dist/ && cp ../../docs/spec.md dist/linter/ && cp ../../docs/spec.md dist/",
"dev": "bun run src/index.ts",
"test": "bun test",
"lint": "tsc --noEmit --skipLibCheck",
"spec:gen": "bun run src/linter/spec-gen/generate.ts",
"check-package": "bun run scripts/check-package.ts"
},
"dependencies": {
"citty": "^0.1.6",
"remark-frontmatter": "^5.0.0",
"remark-mdx": "^3.1.1",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"yaml": "^2.7.1",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/bun": "latest",
"@types/mdast": "^4.0.4",
"@types/node": "^20.11.24",
"bun-types": "^1.3.12",
"tailwindcss": "3",
"typescript": "^5.7.3"
}
}
+345
View File
@@ -0,0 +1,345 @@
#!/usr/bin/env bun
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* Package verification script for @google/design.md
*
* Runs 17 checks across 4 phases to ensure the package is correctly
* structured for npm publication. Exit code 0 = all pass, 1 = failures.
*
* Usage: bun run scripts/check-package.ts
*/
import { readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync } from 'fs';
import { join, resolve } from 'path';
import { execSync } from 'child_process';
import { Glob, $ } from 'bun';
import { tmpdir } from 'os';
// ── Helpers ────────────────────────────────────────────────────────
const ROOT = resolve(import.meta.dir, '..');
const PKG_PATH = join(ROOT, 'package.json');
let passed = 0;
let failed = 0;
function pass(label: string) {
console.log(`${label}`);
passed++;
}
function fail(label: string, detail?: string) {
console.error(`${label}`);
if (detail) console.error(`${detail}`);
failed++;
}
function check(label: string, ok: boolean, detail?: string) {
if (ok) pass(label);
else fail(label, detail);
}
function heading(title: string) {
console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 56 - title.length))}`);
}
function readPkg(): Record<string, unknown> {
return JSON.parse(readFileSync(PKG_PATH, 'utf-8'));
}
function exec(cmd: string, opts?: { cwd?: string }): { ok: boolean; stdout: string; stderr: string } {
try {
const stdout = execSync(cmd, {
cwd: opts?.cwd ?? ROOT,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${process.env.PATH ?? ''}` },
});
return { ok: true, stdout, stderr: '' };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string };
return { ok: false, stdout: err.stdout ?? '', stderr: err.stderr ?? '' };
}
}
// ── Phase 1: Pre-pack validation ───────────────────────────────────
function phase1_config() {
heading('Phase 1a: Package.json validation');
const pkg = readPkg();
// 1. files field exists
const files = pkg.files as string[] | undefined;
check('#1 `files` field exists', Array.isArray(files) && files.length > 0,
'Add a "files" array to package.json to control what ships');
// 2. exports structure
const exports = pkg.exports as Record<string, Record<string, string>> | undefined;
check('#2 `exports["."]` defined', !!exports?.['.'],
'Missing exports["."] in package.json');
if (exports?.['.']) {
check('#3 `exports["."].types` field exists', !!exports['.'].types,
'Missing types condition in exports["."]');
} else {
fail('#3 `exports["."].types` field exists', 'Skipped — no exports');
}
// 4. main field
const main = pkg.main as string | undefined;
check('#4 `main` field exists', !!main,
'Missing "main" field in package.json');
// 5. types field
const types = pkg.types as string | undefined;
check('#5 `types` field exists', !!types,
'Missing "types" field in package.json');
// 5c. bin map exposes a Windows-friendly alias (no dot in the name).
// The `design.md` bin file is unrunnable on Windows because the `.md`
// suffix collides with the Markdown file association, so PowerShell
// opens the shim in the user's Markdown editor instead of executing it.
// A dot-free alias such as `designmd` lets the npm CMD/PowerShell shims
// resolve cleanly via PATHEXT. See https://github.com/google-labs-code/design.md/issues/54.
const bin = pkg.bin as Record<string, string> | string | undefined;
const binEntries = typeof bin === 'object' && bin !== null ? Object.keys(bin) : [];
const hasDotFreeAlias = binEntries.some((name) => !name.includes('.'));
check('#5c bin map exposes a Windows-friendly alias (no dot in the name)',
hasDotFreeAlias,
`bin entries: ${binEntries.join(', ') || '(none)'} — add an alias without a dot for Windows compatibility`);
}
function phase1_paths() {
heading('Phase 1b: Path resolution (post-build)');
const pkg = readPkg();
const exports = pkg.exports as Record<string, Record<string, string>> | undefined;
if (exports?.['.']) {
const importPath = join(ROOT, exports['.'].import);
const typesPath = join(ROOT, exports['.'].types);
check('#2b `exports["."].import` resolves', existsSync(importPath),
`Missing: ${exports['.'].import}`);
check('#3b `exports["."].types` resolves', existsSync(typesPath),
`Missing: ${exports['.'].types}`);
}
const main = pkg.main as string | undefined;
check('#4b `main` resolves', !!main && existsSync(join(ROOT, main)),
`Missing: ${main}`);
const types = pkg.types as string | undefined;
check('#5b `types` resolves', !!types && existsSync(join(ROOT, types)),
`Missing: ${types}`);
}
// ── Phase 2: Clean build ───────────────────────────────────────────
function phase2() {
heading('Phase 2: Clean build');
// 6. Clean dist
const distPath = join(ROOT, 'dist');
if (existsSync(distPath)) {
rmSync(distPath, { recursive: true, force: true });
}
check('#6 Clean dist', !existsSync(distPath));
// 7. Build
const build = exec('bun run build');
check('#7 Build succeeds', build.ok, 'tsc exited with non-zero');
if (!existsSync(distPath)) {
fail('#8 No test files in dist', 'dist/ does not exist after build');
fail('#9 No fixture files in dist', 'dist/ does not exist after build');
return;
}
// 8. No test files in dist
const testGlob = new Glob('**/*.test.*');
const testFiles = Array.from(testGlob.scanSync({ cwd: distPath, absolute: false }));
check('#8 No test files in dist', testFiles.length === 0,
`Found: ${testFiles.join(', ')}`);
// 9. No fixture files in dist
const fixtureGlob = new Glob('**/fixtures/**');
const fixtureFiles = Array.from(fixtureGlob.scanSync({ cwd: distPath, absolute: false }));
check('#9 No fixture files in dist', fixtureFiles.length === 0,
`Found: ${fixtureFiles.join(', ')}`);
}
// ── Phase 3: Pack audit ────────────────────────────────────────────
function phase3() {
heading('Phase 3: Pack audit');
// 10. npm pack --dry-run
const pack = exec('npm pack --dry-run --json 2>/dev/null');
let fileList: string[] = [];
if (pack.ok) {
try {
const parsed = JSON.parse(pack.stdout);
const files = (parsed[0]?.files ?? parsed.files ?? []) as Array<{ path: string }>;
fileList = files.map((f) => f.path);
} catch {
// Fallback: parse the non-JSON dry-run output
const lines = pack.stdout.split('\n');
fileList = lines
.map((l) => l.replace(/^npm notice\s+\d+[\w.]+\s+/, '').trim())
.filter((l) => l.includes('/') || l.endsWith('.js') || l.endsWith('.json'));
}
}
check('#10 `npm pack --dry-run` succeeds', pack.ok && fileList.length > 0,
'npm pack failed or returned empty file list');
if (fileList.length === 0) {
fail('#11 No source .ts files in tarball', 'Skipped — no file list');
fail('#12 No test files in tarball', 'Skipped — no file list');
fail('#13 No config files in tarball', 'Skipped — no file list');
fail('#14 No fixtures in tarball', 'Skipped — no file list');
fail('#15 Entry point in tarball', 'Skipped — no file list');
return;
}
// 11. No source .ts files (allow .d.ts and .d.ts.map)
const rawTs = fileList.filter(
(f) => f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.d.ts.map')
);
check('#11 No source .ts files in tarball', rawTs.length === 0,
`Found: ${rawTs.join(', ')}`);
// 12. No test files
const testInPack = fileList.filter((f) => f.includes('.test.'));
check('#12 No test files in tarball', testInPack.length === 0,
`Found: ${testInPack.join(', ')}`);
// 13. No config files
const configInPack = fileList.filter((f) => f.includes('tsconfig'));
check('#13 No config files in tarball', configInPack.length === 0,
`Found: ${configInPack.join(', ')}`);
// 14. No fixtures
const fixturesInPack = fileList.filter((f) => f.includes('fixtures'));
check('#14 No fixtures in tarball', fixturesInPack.length === 0,
`Found: ${fixturesInPack.join(', ')}`);
// 15. Entry point present
const hasIndex = fileList.some((f) => f.includes('dist/index.js'));
const hasTypes = fileList.some((f) => f.includes('dist/index.d.ts'));
check('#15 Entry point present in tarball', hasIndex && hasTypes,
`index.js: ${hasIndex}, index.d.ts: ${hasTypes}`);
}
// ── Phase 4: Consumer smoke test ───────────────────────────────────
function phase4() {
heading('Phase 4: Consumer smoke test');
const distIndex = join(ROOT, 'dist', 'linter', 'index.js');
const distTypes = join(ROOT, 'dist', 'linter', 'index.d.ts');
// 16. Import resolution (ESM)
const tmpDir = mkdtempSync(join(tmpdir(), 'check-pkg-'));
const smokeFile = join(tmpDir, 'smoke.mjs');
writeFileSync(
smokeFile,
`import { lint } from '${distIndex}';\n` +
`if (typeof lint !== 'function') { process.exit(1); }\n` +
`console.log('ok');\n`
);
const importCheck = exec(`node ${smokeFile}`);
if (!importCheck.ok || !importCheck.stdout.trim().endsWith('ok')) {
console.error('Smoke test failed!');
console.error('STDOUT:', importCheck.stdout);
console.error('STDERR:', importCheck.stderr);
}
check('#16 Import resolution (ESM)', importCheck.ok && importCheck.stdout.trim().endsWith('ok'),
'Could not import lint() from dist/index.js');
// 17. Type declarations exist and export lint
const dtsContent = existsSync(distTypes) ? readFileSync(distTypes, 'utf-8') : '';
const hasLintExport = dtsContent.includes('export') && dtsContent.includes('lint');
const hasLintReportType = dtsContent.includes('LintReport');
check('#17 Type declarations valid',
hasLintExport && hasLintReportType,
`index.d.ts missing lint export or LintReport type`);
// 18. Runtime sanity
const sanityFile = join(tmpDir, 'sanity.mjs');
writeFileSync(
sanityFile,
`import { lint } from '${distIndex}';\n` +
`const result = lint('---\\nname: Test\\ncolors:\\n primary: "#ff0000"\\n---');\n` +
`const keys = Object.keys(result);\n` +
`const expected = ['designSystem','findings','summary','tailwindConfig'];\n` +
`const hasAll = expected.every(k => keys.includes(k));\n` +
`if (!hasAll) {\n` +
` console.error('Missing keys. Actual:', keys);\n` +
` process.exit(1);\n` +
`}\n` +
`if (typeof result.designSystem !== 'object') { process.exit(1); }\n` +
`if (!Array.isArray(result.findings)) { process.exit(1); }\n` +
`if (typeof result.summary.errors !== 'number') { process.exit(1); }\n` +
`console.log('ok');\n`
);
const sanityCheck = exec(`node ${sanityFile}`);
if (!sanityCheck.ok || !sanityCheck.stdout.trim().endsWith('ok')) {
console.error('Sanity check failed!');
console.error('STDOUT:', sanityCheck.stdout);
console.error('STDERR:', sanityCheck.stderr);
}
check('#18 Runtime sanity', sanityCheck.ok && sanityCheck.stdout.trim().endsWith('ok'),
'lint() did not return expected shape');
// 19. CLI entry point works
const cliIndex = join(ROOT, 'dist', 'index.js');
const cliCheck = exec(`node ${cliIndex} --help`);
check('#19 CLI entry point valid', cliCheck.ok && !cliCheck.stderr.includes('ENOENT'),
'CLI failed to run or reported missing files');
// 20. CLI spec command works
const specCheck = exec(`node ${cliIndex} spec`);
check('#20 CLI spec command valid', specCheck.ok && !specCheck.stderr.includes('Failed to load spec.md'),
'CLI spec command failed to load spec.md');
// Cleanup
try {
rmSync(tmpDir, { recursive: true, force: true });
} catch {
// best effort
}
}
// ── Main ───────────────────────────────────────────────────────────
console.log('🔍 Package verification: @google/design.md\n');
phase1_config();
phase2();
phase1_paths();
phase3();
phase4();
console.log(`\n${'═'.repeat(60)}`);
console.log(`${passed} passed ❌ ${failed} failed`);
console.log(`${'═'.repeat(60)}\n`);
if (failed > 0) {
process.exit(1);
}
@@ -0,0 +1,49 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { join } from 'node:path';
const CLI = join(import.meta.dir, '../index.ts');
function runCli(args: string[]): { code: number | null; stdout: string; stderr: string } {
const proc = Bun.spawnSync(['bun', 'run', CLI, ...args], { stdout: 'pipe', stderr: 'pipe' });
return {
code: proc.exitCode,
stdout: Buffer.from(proc.stdout).toString('utf-8'),
stderr: Buffer.from(proc.stderr).toString('utf-8'),
};
}
describe('CLI error output', () => {
it('exits 2 with a friendly error and no stack trace when the input file is missing', () => {
const { code, stdout, stderr } = runCli(['lint', 'definitely-does-not-exist-90af.md']);
expect(code).toBe(2);
expect(stdout).toBe('');
// Exactly one line on stderr — no second, stack-trace error.
const lines = stderr.trim().split('\n').filter(Boolean);
expect(lines.length).toBe(1);
expect(lines[0]).toContain('not found');
expect(lines[0]).toContain('definitely-does-not-exist-90af.md');
});
it('reports an unknown export format with a coded error envelope and exit 1', () => {
// Format is validated before any input is read, so the file path is unused.
const { code, stderr } = runCli(['export', '--format', 'bogus', 'unused.md']);
expect(code).toBe(1);
const err = JSON.parse(stderr.trim());
expect(err.error).toBe('INVALID_FORMAT');
expect(err.message).toContain('Invalid format');
});
});
+99
View File
@@ -0,0 +1,99 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { lint } from '../linter/index.js';
import { diffMaps } from '../utils.js';
import type { ComponentDef } from '../linter/model/spec.js';
function serializeComponents(components: Map<string, ComponentDef>): Map<string, Record<string, unknown>> {
const result = new Map<string, Record<string, unknown>>();
for (const [name, comp] of components) {
result.set(name, Object.fromEntries(comp.properties));
}
return result;
}
const BASE = `---
name: Base
colors:
primary: "#1A1C1E"
secondary: "#6C7278"
components:
button-primary:
backgroundColor: "{colors.primary}"
textColor: "#ffffff"
padding: 12px
---
`;
describe('diff: components', () => {
it('reports no component changes when files are identical', () => {
const before = lint(BASE);
const after = lint(BASE);
const result = diffMaps(
serializeComponents(before.designSystem.components),
serializeComponents(after.designSystem.components),
);
expect(result.added).toEqual([]);
expect(result.removed).toEqual([]);
expect(result.modified).toEqual([]);
});
it('detects an added component', () => {
const afterContent = BASE.replace(
'padding: 12px',
'padding: 12px\n button-secondary:\n backgroundColor: "{colors.secondary}"\n textColor: "#ffffff"',
);
const before = lint(BASE);
const after = lint(afterContent);
const result = diffMaps(
serializeComponents(before.designSystem.components),
serializeComponents(after.designSystem.components),
);
expect(result.added).toContain('button-secondary');
expect(result.removed).toEqual([]);
});
it('detects a removed component', () => {
const afterContent = `---
name: After
colors:
primary: "#1A1C1E"
secondary: "#6C7278"
---
`;
const before = lint(BASE);
const after = lint(afterContent);
const result = diffMaps(
serializeComponents(before.designSystem.components),
serializeComponents(after.designSystem.components),
);
expect(result.removed).toContain('button-primary');
expect(result.added).toEqual([]);
});
it('detects a modified component property', () => {
const afterContent = BASE.replace('padding: 12px', 'padding: 16px');
const before = lint(BASE);
const after = lint(afterContent);
const result = diffMaps(
serializeComponents(before.designSystem.components),
serializeComponents(after.designSystem.components),
);
expect(result.modified).toContain('button-primary');
expect(result.added).toEqual([]);
expect(result.removed).toEqual([]);
});
});
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { defineCommand } from 'citty';
import { lint } from '../linter/index.js';
import { readInput, formatOutput, diffMaps, FileReadError } from '../utils.js';
import type { ComponentDef } from '../linter/model/spec.js';
export default defineCommand({
meta: {
name: 'diff',
description: 'Compare two DESIGN.md files and report changes.',
},
args: {
before: {
type: 'positional',
description: 'Path to the "before" DESIGN.md',
required: true,
},
after: {
type: 'positional',
description: 'Path to the "after" DESIGN.md',
required: true,
},
format: {
type: 'string',
description: 'Output format: json or text',
default: 'json',
},
},
async run({ args }) {
let beforeContent: string, afterContent: string;
try {
beforeContent = await readInput(args.before);
afterContent = await readInput(args.after);
} catch (error) {
if (error instanceof FileReadError) {
process.stderr.write(`Error: ${error.friendlyMessage}\n`);
process.exitCode = 2;
return;
}
throw error;
}
const beforeReport = lint(beforeContent);
const afterReport = lint(afterContent);
const diff = {
tokens: {
colors: diffMaps(beforeReport.designSystem.colors, afterReport.designSystem.colors),
typography: diffMaps(beforeReport.designSystem.typography, afterReport.designSystem.typography),
rounded: diffMaps(beforeReport.designSystem.rounded, afterReport.designSystem.rounded),
spacing: diffMaps(beforeReport.designSystem.spacing, afterReport.designSystem.spacing),
components: diffMaps(
serializeComponents(beforeReport.designSystem.components),
serializeComponents(afterReport.designSystem.components),
),
},
findings: {
before: beforeReport.summary,
after: afterReport.summary,
delta: {
errors: afterReport.summary.errors - beforeReport.summary.errors,
warnings: afterReport.summary.warnings - beforeReport.summary.warnings,
},
},
regression: afterReport.summary.errors > beforeReport.summary.errors
|| afterReport.summary.warnings > beforeReport.summary.warnings,
};
console.log(formatOutput(diff, args));
process.exitCode = diff.regression ? 1 : 0;
},
});
function serializeComponents(components: Map<string, ComponentDef>): Map<string, Record<string, unknown>> {
const result = new Map<string, Record<string, unknown>>();
for (const [name, comp] of components) {
result.set(name, Object.fromEntries(comp.properties));
}
return result;
}
@@ -0,0 +1,45 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect, afterAll } from 'bun:test';
import { join } from 'node:path';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
const CLI = join(import.meta.dir, '../index.ts');
function run(args: string[]): { code: number | null; stdout: string } {
const proc = Bun.spawnSync(['bun', 'run', CLI, ...args], { stdout: 'pipe', stderr: 'pipe' });
return { code: proc.exitCode, stdout: Buffer.from(proc.stdout).toString('utf-8') };
}
describe('export exit code', () => {
const dir = mkdtempSync(join(tmpdir(), 'designmd-export-'));
const badFile = join(dir, 'DESIGN.md');
// An invalid color is a lint *error*, but the export itself still succeeds.
writeFileSync(badFile, '---\ncolors:\n primary: "notacolor"\n---\n## Colors\n');
afterAll(() => rmSync(dir, { recursive: true, force: true }));
it('exits 0 on a successful export even when the source has lint errors', () => {
const { code, stdout } = run(['export', '--format', 'json-tailwind', badFile]);
expect(code).toBe(0);
expect(stdout.trim().length).toBeGreaterThan(0);
});
it('lint still exits 1 on the same source (the validation gate)', () => {
const { code } = run(['lint', badFile]);
expect(code).toBe(1);
});
});
+72
View File
@@ -0,0 +1,72 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test';
import { fileURLToPath } from 'node:url';
import exportCommand from './export.js';
const FIXTURE_PATH = fileURLToPath(new URL('../linter/fixtures/DESIGN-test.md', import.meta.url));
describe('export command', () => {
let logSpy: any;
let errorSpy: any;
beforeEach(() => {
process.exitCode = undefined;
logSpy = spyOn(console, 'log').mockImplementation(() => {});
errorSpy = spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
logSpy.mockRestore();
errorSpy.mockRestore();
process.exitCode = 0;
});
it('outputs css-vars custom properties with an optional prefix', async () => {
await exportCommand.run!({
args: {
file: FIXTURE_PATH,
format: 'css-vars',
prefix: 'ds',
},
} as any);
expect(errorSpy.mock.calls.length).toBe(0);
expect(logSpy.mock.calls.length).toBe(1);
const output = logSpy.mock.calls[0][0];
expect(output).toStartWith(':root {\n');
expect(output).toContain(' --ds-color-primary: #006b5a;');
expect(output).toContain(' --ds-spacing-unit: 8px;');
expect(output).toContain(' --ds-rounded-sm: 0.25rem;');
expect(output).toEndWith('}\n');
expect(process.exitCode).toBe(0);
});
it('errors with exit code 1 for invalid export formats', async () => {
await exportCommand.run!({
args: {
file: FIXTURE_PATH,
format: 'not-a-format',
},
} as any);
expect(logSpy.mock.calls.length).toBe(0);
expect(errorSpy.mock.calls.length).toBe(1);
const error = JSON.parse(errorSpy.mock.calls[0][0]);
expect(error.error).toContain('Invalid format "not-a-format"');
expect(process.exitCode).toBe(1);
});
});
+122
View File
@@ -0,0 +1,122 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { defineCommand } from 'citty';
import { lint, TailwindEmitterHandler, TailwindV4EmitterHandler, serializeTailwindV4, CssVarsEmitterHandler, serializeCssVars } from '../linter/index.js';
import { DtcgEmitterHandler } from '../linter/dtcg/handler.js';
import { readInput, FileReadError } from '../utils.js';
const FORMATS = ['css-tailwind', 'json-tailwind', 'tailwind', 'dtcg', 'css-vars'] as const;
type ExportFormat = typeof FORMATS[number];
export default defineCommand({
meta: {
name: 'export',
description: 'Export DESIGN.md tokens to other formats. `css-tailwind` emits Tailwind v4 CSS @theme; `json-tailwind` emits Tailwind v3 theme.extend JSON; `tailwind` is an alias for `json-tailwind`; `dtcg` emits W3C Design Tokens; `css-vars` emits CSS custom properties.',
},
args: {
file: {
type: 'positional',
description: 'Path to DESIGN.md (use "-" for stdin)',
required: true,
},
format: {
type: 'string',
description: `Output format: ${FORMATS.join(', ')}`,
required: true,
},
prefix: {
type: 'string',
description: 'Optional CSS custom property prefix for css-vars output.',
required: false,
},
},
async run({ args }) {
const format = args.format as string;
const prefix = typeof args.prefix === 'string' ? args.prefix : undefined;
// Validate --format against closed enum
if (!FORMATS.includes(format as ExportFormat)) {
console.error(JSON.stringify({
error: 'INVALID_FORMAT',
message: `Invalid format "${format}". Valid formats: ${FORMATS.join(', ')}`,
}));
process.exitCode = 1;
return;
}
let content: string;
try {
content = await readInput(args.file);
} catch (error) {
if (error instanceof FileReadError) {
process.stderr.write(`Error: ${error.friendlyMessage}\n`);
process.exitCode = 2;
return;
}
throw error;
}
const report = lint(content);
if (format === 'css-tailwind') {
const handler = new TailwindV4EmitterHandler();
const result = handler.execute(report.designSystem);
if (!result.success) {
console.error(JSON.stringify({ error: result.error.code, message: result.error.message }));
process.exitCode = 1;
return;
}
process.stdout.write(serializeTailwindV4(result.data.theme));
} else if (format === 'json-tailwind' || format === 'tailwind') {
const handler = new TailwindEmitterHandler();
const result = handler.execute(report.designSystem);
if (!result.success) {
console.error(JSON.stringify({ error: result.error.code, message: result.error.message }));
process.exitCode = 1;
return;
}
console.log(JSON.stringify(result.data, null, 2));
} else if (format === 'dtcg') {
const handler = new DtcgEmitterHandler();
const result = handler.execute(report.designSystem);
if (!result.success) {
console.error(JSON.stringify({ error: result.error.code, message: result.error.message }));
process.exitCode = 1;
return;
}
console.log(JSON.stringify(result.data, null, 2));
} else if (format === 'css-vars') {
const handler = new CssVarsEmitterHandler();
const result = handler.execute(report.designSystem);
if (!result.success) {
console.error(JSON.stringify({ error: result.error.message }));
process.exitCode = 1;
return;
}
console.log(serializeCssVars(result.data.declarations, { prefix }));
}
// A successful export exits 0 even if the source has lint findings; those
// are surfaced by `lint`, not by whether the export itself produced output.
// The error branches above set a non-zero code and return before this point.
},
});
+58
View File
@@ -0,0 +1,58 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { defineCommand } from 'citty';
import { lint } from '../linter/index.js';
import { readInput, formatOutput, FileReadError } from '../utils.js';
export default defineCommand({
meta: {
name: 'lint',
description: 'Validate a DESIGN.md file for structural correctness.',
},
args: {
file: {
type: 'positional',
description: 'Path to DESIGN.md (use "-" for stdin)',
required: true,
},
format: {
type: 'string',
description: 'Output format: json or text',
default: 'json',
},
},
async run({ args }) {
let content: string;
try {
content = await readInput(args.file);
} catch (error) {
if (error instanceof FileReadError) {
process.stderr.write(`Error: ${error.friendlyMessage}\n`);
process.exitCode = 2;
return;
}
throw error;
}
const report = lint(content);
const output = {
findings: report.findings,
summary: report.summary,
};
console.log(formatOutput(output, args));
process.exitCode = report.summary.errors > 0 ? 1 : 0;
},
});
+94
View File
@@ -0,0 +1,94 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test';
import specCommand from './spec.js';
describe('spec command', () => {
let logSpy: any;
beforeEach(() => {
logSpy = spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
logSpy.mockRestore();
});
it('outputs spec markdown by default', async () => {
await specCommand.run!({
args: {},
} as any);
expect(logSpy.mock.calls.length).toBe(1);
const output = logSpy.mock.calls[0][0];
expect(output).toContain('# DESIGN.md Format');
});
it('outputs spec and rules table when --rules is passed', async () => {
await specCommand.run!({
args: {
rules: true,
},
} as any);
expect(logSpy.mock.calls.length).toBe(1);
const output = logSpy.mock.calls[0][0];
expect(output).toContain('# DESIGN.md Format');
expect(output).toContain('| Rule | Severity | What it checks |');
});
it('outputs only rules table when --rules-only is passed', async () => {
await specCommand.run!({
args: {
rulesOnly: true,
},
} as any);
expect(logSpy.mock.calls.length).toBe(1);
const output = logSpy.mock.calls[0][0];
expect(output).not.toContain('# DESIGN.md Format');
expect(output).toContain('| Rule | Severity | What it checks |');
});
it('outputs JSON when --format json is passed', async () => {
await specCommand.run!({
args: {
format: 'json',
},
} as any);
expect(logSpy.mock.calls.length).toBe(1);
const outputStr = logSpy.mock.calls[0][0];
const output = JSON.parse(outputStr);
expect(output.spec).toBeDefined();
expect(output.spec).toContain('# DESIGN.md Format');
});
it('outputs JSON with rules when --format json and --rules are passed', async () => {
await specCommand.run!({
args: {
format: 'json',
rules: true,
},
} as any);
expect(logSpy.mock.calls.length).toBe(1);
const outputStr = logSpy.mock.calls[0][0];
const output = JSON.parse(outputStr);
expect(output.spec).toBeDefined();
expect(output.rules).toBeDefined();
expect(output.rules.length).toBe(10);
});
});
+79
View File
@@ -0,0 +1,79 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { defineCommand } from 'citty';
import { getSpecContent, getRulesTable } from '../linter/spec-gen/spec-helpers.js';
import { DEFAULT_RULE_DESCRIPTORS } from '../linter/linter/rules/index.js';
export default defineCommand({
meta: {
name: 'spec',
description: 'Output the DESIGN.md format specification.',
},
args: {
rules: {
type: 'boolean',
description: 'Append the active linting rules table.',
},
rulesOnly: {
type: 'boolean',
description: 'Output only the active linting rules table.',
},
format: {
type: 'string',
description: 'Output format (markdown, json).',
default: 'markdown',
},
},
async run({ args }) {
const rulesTable = getRulesTable(DEFAULT_RULE_DESCRIPTORS);
if (args.format === 'json') {
const jsonOutput: any = {};
if (args.rulesOnly) {
jsonOutput.rules = DEFAULT_RULE_DESCRIPTORS.map(r => ({
name: r.name,
severity: r.severity,
description: r.description,
}));
} else {
jsonOutput.spec = getSpecContent();
if (args.rules) {
jsonOutput.rules = DEFAULT_RULE_DESCRIPTORS.map(r => ({
name: r.name,
severity: r.severity,
description: r.description,
}));
}
}
console.log(JSON.stringify(jsonOutput, null, 2));
return;
}
if (args.rulesOnly) {
console.log(rulesTable);
return;
}
let output = getSpecContent();
if (args.rules) {
output += '\n\n## Active Linting Rules\n\n' + rulesTable;
}
console.log(output);
},
});
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env node
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { defineCommand, runMain } from 'citty';
import { VERSION } from './version.js';
import lintCommand from './commands/lint.js';
import diffCommand from './commands/diff.js';
import exportCommand from './commands/export.js';
import specCommand from './commands/spec.js';
const main = defineCommand({
meta: {
name: 'design.md',
version: VERSION,
description: 'Agent-first CLI for DESIGN.md — the hands and eyes for design system work.',
},
subCommands: {
lint: lintCommand,
diff: diffCommand,
export: exportCommand,
spec: specCommand,
},
});
runMain(main);
@@ -0,0 +1,135 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, test, expect } from 'bun:test';
import { CssVarsEmitterHandler } from './handler.js';
import { serializeCssVars } from './serialize.js';
import type { DesignSystemState, ResolvedColor, ResolvedDimension } from '../model/spec.js';
function emptyState(overrides?: Partial<DesignSystemState>): DesignSystemState {
return {
colors: new Map(),
typography: new Map(),
rounded: new Map(),
spacing: new Map(),
components: new Map(),
symbolTable: new Map(),
...overrides,
};
}
function makeColor(hex: string, r: number, g: number, b: number): ResolvedColor {
return { type: 'color', hex, r, g, b, luminance: 0 };
}
function makeDim(value: number, unit: string): ResolvedDimension {
return { type: 'dimension', value, unit };
}
describe('CssVarsEmitterHandler', () => {
const handler = new CssVarsEmitterHandler();
test('empty state produces valid empty :root block', () => {
const result = handler.execute(emptyState());
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.declarations).toEqual([]);
expect(serializeCssVars(result.data.declarations)).toBe(':root {\n}\n');
});
test('colors emit --color-* declarations with lowercase hex values', () => {
const state = emptyState({
colors: new Map([
['primary', makeColor('#1A1C1E', 0x1A, 0x1C, 0x1E)],
['white', makeColor('#FFFFFF', 255, 255, 255)],
]),
});
const result = handler.execute(state);
expect(result.success).toBe(true);
if (!result.success) return;
expect(serializeCssVars(result.data.declarations)).toBe(
':root {\n'
+ ' --color-primary: #1a1c1e;\n'
+ ' --color-white: #ffffff;\n'
+ '}\n',
);
});
test('spacing and rounded dimensions preserve numeric values and units', () => {
const state = emptyState({
spacing: new Map([
['sm', makeDim(8, 'px')],
['md', makeDim(1, 'rem')],
]),
rounded: new Map([
['card', makeDim(12, 'px')],
]),
});
const result = handler.execute(state);
expect(result.success).toBe(true);
if (!result.success) return;
expect(serializeCssVars(result.data.declarations)).toBe(
':root {\n'
+ ' --spacing-sm: 8px;\n'
+ ' --spacing-md: 1rem;\n'
+ ' --rounded-card: 12px;\n'
+ '}\n',
);
});
test('nested token names collapse dots to hyphens for valid CSS property names', () => {
const state = emptyState({
colors: new Map([
['background.light', makeColor('#FFFFFF', 255, 255, 255)],
]),
spacing: new Map([
['gap.lg', makeDim(24, 'px')],
]),
});
const result = handler.execute(state);
expect(result.success).toBe(true);
if (!result.success) return;
expect(serializeCssVars(result.data.declarations)).toBe(
':root {\n'
+ ' --color-background-light: #ffffff;\n'
+ ' --spacing-gap-lg: 24px;\n'
+ '}\n',
);
});
test('prefix option adds a custom prefix to emitted property names', () => {
const state = emptyState({
colors: new Map([
['primary', makeColor('#1A1C1E', 0x1A, 0x1C, 0x1E)],
]),
});
const result = handler.execute(state);
expect(result.success).toBe(true);
if (!result.success) return;
expect(serializeCssVars(result.data.declarations, { prefix: 'ds' })).toBe(
':root {\n'
+ ' --ds-color-primary: #1a1c1e;\n'
+ '}\n',
);
});
});
@@ -0,0 +1,64 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { CssVarDeclaration, CssVarsEmitterSpec, CssVarsEmitterResult } from './spec.js';
import type { DesignSystemState, ResolvedDimension } from '../model/spec.js';
/**
* Pure function mapping DesignSystemState → CSS custom property declarations.
* No side effects.
*/
export class CssVarsEmitterHandler implements CssVarsEmitterSpec {
execute(state: DesignSystemState): CssVarsEmitterResult {
const declarations: CssVarDeclaration[] = [];
for (const [name, color] of state.colors) {
declarations.push({
name: `color-${this.cssSafe(name)}`,
value: color.hex.toLowerCase(),
});
}
this.mapDimensionGroup(declarations, 'spacing', state.spacing);
this.mapDimensionGroup(declarations, 'rounded', state.rounded);
return { success: true, data: { declarations } };
}
private mapDimensionGroup(
declarations: CssVarDeclaration[],
group: 'spacing' | 'rounded',
dims: Map<string, ResolvedDimension>,
): void {
for (const [name, dim] of dims) {
declarations.push({
name: `${group}-${this.cssSafe(name)}`,
value: this.dimToString(dim),
});
}
}
private dimToString(dim: ResolvedDimension): string {
return `${dim.value}${dim.unit}`;
}
/**
* Make a token name safe for a CSS custom property. Nested tokens flatten to
* dotted keys (e.g. `background.light`); a literal dot makes a browser drop
* the declaration, so collapse dots to hyphens.
*/
private cssSafe(name: string): string {
return name.replace(/\./g, '-');
}
}
@@ -0,0 +1,36 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { CssVarDeclaration } from './spec.js';
export interface SerializeCssVarsOptions {
prefix?: string | undefined;
}
/**
* Serialize declarations to a CSS `:root { ... }` block string.
* Pure function — no I/O. Values are emitted verbatim.
*/
export function serializeCssVars(
declarations: CssVarDeclaration[],
options: SerializeCssVarsOptions = {},
): string {
const variablePrefix = options.prefix ? `${options.prefix}-` : '';
const lines = declarations.map(
declaration => ` --${variablePrefix}${declaration.name}: ${declaration.value};`,
);
if (lines.length === 0) return ':root {\n}\n';
return `:root {\n${lines.join('\n')}\n}\n`;
}
+49
View File
@@ -0,0 +1,49 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { z } from 'zod';
import type { DesignSystemState } from '../model/spec.js';
export const CssVarDeclarationSchema = z.object({
name: z.string(),
value: z.string(),
});
export type CssVarDeclaration = z.infer<typeof CssVarDeclarationSchema>;
// ── Result ─────────────────────────────────────────────────────────
export const CssVarsEmitterResultSchema = z.discriminatedUnion('success', [
z.object({
success: z.literal(true),
data: z.object({
declarations: z.array(CssVarDeclarationSchema),
}),
}),
z.object({
success: z.literal(false),
error: z.object({
code: z.string(),
message: z.string(),
}),
}),
]);
export type CssVarsEmitterResult = z.infer<typeof CssVarsEmitterResultSchema>;
// ── Interface ──────────────────────────────────────────────────────
export interface CssVarsEmitterSpec {
execute(state: DesignSystemState): CssVarsEmitterResult;
}
@@ -0,0 +1,138 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, test, expect } from 'bun:test';
import { writeFileSync, mkdtempSync, existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { spawnSync } from 'child_process';
import { lint } from '../lint.js';
import { DtcgEmitterHandler } from './handler.js';
describe('DTCG Conformance', () => {
// Skipped: @terrazzo/token-types@^2.4.0 was removed from npm (404).
// This test depends on installing Terrazzo from the public registry.
// Re-enable once Terrazzo publishes a fix. See: https://github.com/google-labs-code/design.md/issues/106
test.skip('Terrazzo can parse our DTCG output and generate CSS', () => {
const fixtureContent = `---
name: Test Brand
colors:
primary: "#1A1C1E"
accent: "#4A90D9"
spacing:
sm: 8px
md: 16px
typography:
heading:
fontFamily: Inter
fontSize: 24px
fontWeight: 700
lineHeight: 1.2em
letterSpacing: 0px
---
# Spec
`;
// 1. Export to DTCG
const report = lint(fixtureContent);
const handler = new DtcgEmitterHandler();
const result = handler.execute(report.designSystem);
expect(result.success).toBe(true);
if (!result.success) return;
// 2. Create temp dir
const tmpDir = mkdtempSync(join(tmpdir(), 'dtcg-test-'));
try {
// 3. Write tokens.json
writeFileSync(join(tmpDir, 'tokens.json'), JSON.stringify(result.data, null, 2));
// 4. Write minimal terrazzo.config.js
// We use JS to avoid needing to resolve TS imports in the spawned process
writeFileSync(
join(tmpDir, 'terrazzo.config.js'),
`
import { defineConfig } from '@terrazzo/cli';
import pluginCSS from '@terrazzo/plugin-css';
export default defineConfig({
tokens: ['./tokens.json'],
outDir: './out/',
plugins: [pluginCSS()],
});
`
);
// We also need a package.json in the temp dir to make it a module,
// so we can use ES imports in terrazzo.config.js
writeFileSync(
join(tmpDir, 'package.json'),
JSON.stringify({ type: 'module' })
);
// Install dependencies in temp dir so they can be imported in config
// Using bun add should be fast if cached
const customPath = process.env.PATH || '';
const installProc = spawnSync('bun', ['add', '@terrazzo/cli', '@terrazzo/plugin-css'], {
cwd: tmpDir,
env: { ...process.env, PATH: customPath },
shell: true
});
if (installProc.status !== 0) {
console.error('Install failed:', installProc.stderr.toString());
}
// 5. Run Terrazzo build
const proc = spawnSync('npx', ['@terrazzo/cli', 'build'], {
cwd: tmpDir,
env: { ...process.env, PATH: customPath },
shell: true
});
if (proc.status !== 0) {
console.error('Terrazzo stderr:', proc.stderr.toString());
console.error('Terrazzo stdout:', proc.stdout.toString());
}
expect(proc.status).toBe(0);
// 6. Verify CSS was generated
const cssPath = join(tmpDir, 'out/index.css');
if (!existsSync(cssPath)) {
console.log('Temp dir contents:', spawnSync('ls', ['-R', tmpDir]).stdout.toString());
}
expect(existsSync(cssPath)).toBe(true);
if (existsSync(cssPath)) {
const css = readFileSync(cssPath, 'utf-8');
expect(css).toContain('--color-primary');
expect(css).toContain('--color-accent');
expect(css).toContain('--spacing-sm');
// Terrazzo might flatten typography differently, let's check for a part of it
expect(css).toContain('heading');
}
} finally {
// Cleanup
try {
spawnSync('rm', ['-rf', tmpDir]);
} catch {
// best effort
}
}
}, 30000); // Increase timeout for npx
});
@@ -0,0 +1,194 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, test, expect } from 'bun:test';
import { DtcgEmitterHandler } from './handler.js';
import type { DesignSystemState, ResolvedColor, ResolvedDimension, ResolvedTypography } from '../model/spec.js';
function emptyState(overrides?: Partial<DesignSystemState>): DesignSystemState {
return {
colors: new Map(),
typography: new Map(),
rounded: new Map(),
spacing: new Map(),
components: new Map(),
symbolTable: new Map(),
...overrides,
};
}
function makeColor(hex: string, r: number, g: number, b: number): ResolvedColor {
return { type: 'color', hex, r, g, b, luminance: 0 };
}
function makeDim(value: number, unit: string): ResolvedDimension {
return { type: 'dimension', value, unit };
}
describe('DtcgEmitterHandler', () => {
const handler = new DtcgEmitterHandler();
test('empty state produces valid DTCG file with $schema', () => {
const result = handler.execute(emptyState());
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data['$schema']).toBe('https://www.designtokens.org/schemas/2025.10/format.json');
// No groups created for empty maps
expect(result.data['color']).toBeUndefined();
expect(result.data['spacing']).toBeUndefined();
expect(result.data['typography']).toBeUndefined();
});
test('name and description → top-level $description', () => {
const result = handler.execute(emptyState({ name: 'Acme', description: 'Acme Design System' }));
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data['$description']).toBe('Acme Design System');
});
test('name without description → $description uses name', () => {
const result = handler.execute(emptyState({ name: 'Acme' }));
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data['$description']).toBe('Acme');
});
test('colors → DTCG color tokens with sRGB components in 01 range', () => {
const state = emptyState({
colors: new Map([
['primary', makeColor('#1A1C1E', 0x1A, 0x1C, 0x1E)],
['white', makeColor('#FFFFFF', 255, 255, 255)],
['black', makeColor('#000000', 0, 0, 0)],
]),
});
const result = handler.execute(state);
expect(result.success).toBe(true);
if (!result.success) return;
const colorGroup = result.data['color'] as Record<string, unknown>;
expect(colorGroup['$type']).toBe('color');
const primary = colorGroup['primary'] as Record<string, unknown>;
const primaryValue = primary['$value'] as Record<string, unknown>;
expect(primaryValue['colorSpace']).toBe('srgb');
expect(primaryValue['hex']).toBe('#1a1c1e');
const components = primaryValue['components'] as number[];
expect(components[0]).toBeCloseTo(0x1A / 255, 2);
expect(components[1]).toBeCloseTo(0x1C / 255, 2);
expect(components[2]).toBeCloseTo(0x1E / 255, 2);
// Black = [0, 0, 0]
const black = colorGroup['black'] as Record<string, unknown>;
const blackComponents = (black['$value'] as Record<string, unknown>)['components'] as number[];
expect(blackComponents).toEqual([0, 0, 0]);
// White = [1, 1, 1]
const white = colorGroup['white'] as Record<string, unknown>;
const whiteComponents = (white['$value'] as Record<string, unknown>)['components'] as number[];
expect(whiteComponents).toEqual([1, 1, 1]);
});
test('spacing → DTCG dimension tokens with { value, unit }', () => {
const state = emptyState({
spacing: new Map([
['sm', makeDim(8, 'px')],
['md', makeDim(1, 'rem')],
]),
});
const result = handler.execute(state);
expect(result.success).toBe(true);
if (!result.success) return;
const spacingGroup = result.data['spacing'] as Record<string, unknown>;
expect(spacingGroup['$type']).toBe('dimension');
const sm = spacingGroup['sm'] as Record<string, unknown>;
expect(sm['$value']).toEqual({ value: 8, unit: 'px' });
const md = spacingGroup['md'] as Record<string, unknown>;
expect(md['$value']).toEqual({ value: 1, unit: 'rem' });
});
test('rounded → DTCG dimension tokens under "rounded" group', () => {
const state = emptyState({
rounded: new Map([
['sm', makeDim(4, 'px')],
]),
});
const result = handler.execute(state);
expect(result.success).toBe(true);
if (!result.success) return;
const roundedGroup = result.data['rounded'] as Record<string, unknown>;
expect(roundedGroup['$type']).toBe('dimension');
const sm = roundedGroup['sm'] as Record<string, unknown>;
expect(sm['$value']).toEqual({ value: 4, unit: 'px' });
});
test('typography → DTCG typography composite tokens', () => {
const heading: ResolvedTypography = {
type: 'typography',
fontFamily: 'Inter',
fontSize: makeDim(24, 'px'),
fontWeight: 700,
lineHeight: makeDim(1.2, 'em'),
letterSpacing: makeDim(0.5, 'px'),
};
const state = emptyState({
typography: new Map([['heading', heading]]),
});
const result = handler.execute(state);
expect(result.success).toBe(true);
if (!result.success) return;
const typoGroup = result.data['typography'] as Record<string, unknown>;
const headingToken = typoGroup['heading'] as Record<string, unknown>;
expect(headingToken['$type']).toBe('typography');
const value = headingToken['$value'] as Record<string, unknown>;
expect(value['fontFamily']).toBe('Inter');
expect(value['fontSize']).toEqual({ value: 24, unit: 'px' });
expect(value['fontWeight']).toBe(700);
expect(value['lineHeight']).toBe(1.2);
expect(value['letterSpacing']).toEqual({ value: 0.5, unit: 'px' });
});
test('typography with missing fields omits them from $value', () => {
const minimal: ResolvedTypography = {
type: 'typography',
fontFamily: 'Roboto',
};
const state = emptyState({
typography: new Map([['body', minimal]]),
});
const result = handler.execute(state);
expect(result.success).toBe(true);
if (!result.success) return;
const value = ((result.data['typography'] as Record<string, unknown>)['body'] as Record<string, unknown>)['$value'] as Record<string, unknown>;
expect(value['fontFamily']).toBe('Roboto');
expect(value['fontSize']).toBeUndefined();
expect(value['fontWeight']).toBeUndefined();
expect(value['lineHeight']).toBeUndefined();
expect(value['letterSpacing']).toBeUndefined();
});
});
+118
View File
@@ -0,0 +1,118 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DtcgEmitterSpec, DtcgEmitterResult, DtcgTokenFile, DtcgToken, DtcgGroup, DtcgColorValue, DtcgDimensionValue, DtcgTypographyValue } from './spec.js';
import type { DesignSystemState, ResolvedColor, ResolvedDimension, ResolvedTypography } from '../model/spec.js';
const DTCG_SCHEMA_URL = 'https://www.designtokens.org/schemas/2025.10/format.json';
/**
* Pure function mapping DesignSystemState → DTCG tokens.json (W3C Design Tokens Format Module 2025.10).
* No side effects.
*/
export class DtcgEmitterHandler implements DtcgEmitterSpec {
execute(state: DesignSystemState): DtcgEmitterResult {
const file: DtcgTokenFile = {
$schema: DTCG_SCHEMA_URL,
};
if (state.name || state.description) {
file.$description = state.description || state.name;
}
const colorGroup = this.mapColors(state);
if (colorGroup) file['color'] = colorGroup;
const spacingGroup = this.mapDimensionGroup(state.spacing);
if (spacingGroup) file['spacing'] = spacingGroup;
const roundedGroup = this.mapDimensionGroup(state.rounded);
if (roundedGroup) file['rounded'] = roundedGroup;
const typographyGroup = this.mapTypography(state);
if (typographyGroup) file['typography'] = typographyGroup;
return { success: true, data: file as Record<string, unknown> };
}
private mapColors(state: DesignSystemState): DtcgGroup | null {
if (state.colors.size === 0) return null;
const group: DtcgGroup = { $type: 'color' };
for (const [name, color] of state.colors) {
group[name] = {
$value: this.colorToValue(color),
} as DtcgToken;
}
return group;
}
private colorToValue(color: ResolvedColor): DtcgColorValue {
return {
colorSpace: 'srgb',
components: [
this.round(color.r / 255),
this.round(color.g / 255),
this.round(color.b / 255),
],
hex: color.hex.toLowerCase(),
};
}
private mapDimensionGroup(dims: Map<string, ResolvedDimension>): DtcgGroup | null {
if (dims.size === 0) return null;
const group: DtcgGroup = { $type: 'dimension' };
for (const [name, dim] of dims) {
group[name] = {
$value: this.dimToValue(dim),
} as DtcgToken;
}
return group;
}
private dimToValue(dim: ResolvedDimension): DtcgDimensionValue {
return { value: dim.value, unit: dim.unit };
}
private mapTypography(state: DesignSystemState): DtcgGroup | null {
if (state.typography.size === 0) return null;
const group: DtcgGroup = {};
for (const [name, typo] of state.typography) {
group[name] = {
$type: 'typography',
$value: this.typographyToValue(typo),
} as DtcgToken;
}
return group;
}
private typographyToValue(typo: ResolvedTypography): DtcgTypographyValue {
const value: DtcgTypographyValue = {};
if (typo.fontFamily) value.fontFamily = typo.fontFamily;
if (typo.fontSize) value.fontSize = this.dimToValue(typo.fontSize);
if (typo.fontWeight !== undefined) value.fontWeight = typo.fontWeight;
if (typo.letterSpacing) value.letterSpacing = this.dimToValue(typo.letterSpacing);
if (typo.lineHeight) {
// DTCG lineHeight is a unitless multiplier of fontSize.
// Our model stores it as a ResolvedDimension. Convert if possible.
// If unit is a relative unit, just use the numeric value as a multiplier.
value.lineHeight = typo.lineHeight.value;
}
return value;
}
/** Round to 3 decimal places for clean output. */
private round(n: number): number {
return Math.round(n * 1000) / 1000;
}
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { z } from 'zod';
import type { DesignSystemState } from '../model/spec.js';
// ── DTCG Value Types (W3C Design Tokens Format Module 2025.10) ────
export interface DtcgColorValue {
colorSpace: 'srgb';
components: [number, number, number];
hex?: string;
}
export interface DtcgDimensionValue {
value: number;
unit: string;
}
export interface DtcgTypographyValue {
fontFamily?: string;
fontSize?: DtcgDimensionValue;
fontWeight?: number;
letterSpacing?: DtcgDimensionValue;
lineHeight?: number;
}
// ── DTCG Token & Group Structures ─────────────────────────────────
export interface DtcgToken {
$type?: string;
$value: DtcgColorValue | DtcgDimensionValue | DtcgTypographyValue | string | number;
$description?: string;
}
export interface DtcgGroup {
$type?: string;
$description?: string;
[key: string]: DtcgToken | DtcgGroup | string | undefined;
}
/** The complete tokens.json output file. */
export interface DtcgTokenFile extends DtcgGroup {
$schema?: string;
}
// ── Result ─────────────────────────────────────────────────────────
export const DtcgEmitterResultSchema = z.discriminatedUnion('success', [
z.object({
success: z.literal(true),
data: z.record(z.unknown()),
}),
z.object({
success: z.literal(false),
error: z.object({
code: z.string(),
message: z.string(),
}),
}),
]);
export type DtcgEmitterResult = z.infer<typeof DtcgEmitterResultSchema>;
// ── Interface ──────────────────────────────────────────────────────
export interface DtcgEmitterSpec {
execute(state: DesignSystemState): DtcgEmitterResult;
}
@@ -0,0 +1,136 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { fixSectionOrder } from './handler.js';
import type { FixerInput } from './spec.js';
describe('FixerHandler', () => {
it('should reorder sections to canonical order', () => {
const input: FixerInput = {
content: '',
sections: [
{ heading: 'Colors', content: '## Colors\ncontent' },
{ heading: 'Overview', content: '## Overview\ncontent' },
]
};
const result = fixSectionOrder(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.fixedContent).toContain('## Overview\ncontent\n## Colors\ncontent');
}
});
it('should preserve prelude at the top', () => {
const input: FixerInput = {
content: '',
sections: [
{ heading: 'Colors', content: '## Colors\ncontent' },
{ heading: '', content: 'Prelude content' },
{ heading: 'Overview', content: '## Overview\ncontent' },
]
};
const result = fixSectionOrder(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.fixedContent.startsWith('Prelude content')).toBe(true);
}
});
it('should append unknown sections at the end', () => {
const input: FixerInput = {
content: '',
sections: [
{ heading: 'Unknown', content: '## Unknown\ncontent' },
{ heading: 'Colors', content: '## Colors\ncontent' },
{ heading: 'Overview', content: '## Overview\ncontent' },
]
};
const result = fixSectionOrder(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.fixedContent.endsWith('## Unknown\ncontent')).toBe(true);
}
});
it('should recognize "Brand & Style" as a known section via alias', () => {
const input: FixerInput = {
content: '',
sections: [
{ heading: 'Colors', content: '## Colors\ncontent' },
{ heading: 'Brand & Style', content: '## Brand & Style\ncontent' },
]
};
const result = fixSectionOrder(input);
expect(result.success).toBe(true);
if (result.success) {
// Brand & Style (alias for Overview) should come before Colors
expect(result.fixedContent).toContain('## Brand & Style\ncontent\n## Colors\ncontent');
}
});
it('should recognize "Layout & Spacing" as a known section via alias', () => {
const input: FixerInput = {
content: '',
sections: [
{ heading: 'Layout & Spacing', content: '## Layout & Spacing\ncontent' },
{ heading: 'Colors', content: '## Colors\ncontent' },
]
};
const result = fixSectionOrder(input);
expect(result.success).toBe(true);
if (result.success) {
// Colors should come before Layout & Spacing
expect(result.fixedContent).toContain('## Colors\ncontent\n## Layout & Spacing\ncontent');
}
});
it('should handle a full real-world section order with aliases', () => {
const input: FixerInput = {
content: '',
sections: [
{ heading: 'Components', content: '## Components\ncontent' },
{ heading: 'Brand & Style', content: '## Brand & Style\ncontent' },
{ heading: 'Typography', content: '## Typography\ncontent' },
{ heading: 'Colors', content: '## Colors\ncontent' },
{ heading: 'Layout & Spacing', content: '## Layout & Spacing\ncontent' },
{ heading: 'Elevation & Depth', content: '## Elevation & Depth\ncontent' },
{ heading: 'Shapes', content: '## Shapes\ncontent' },
]
};
const result = fixSectionOrder(input);
expect(result.success).toBe(true);
if (result.success) {
const idx = (heading: string) => result.fixedContent.indexOf(`## ${heading}`);
expect(idx('Brand & Style')).toBeLessThan(idx('Colors'));
expect(idx('Colors')).toBeLessThan(idx('Typography'));
expect(idx('Typography')).toBeLessThan(idx('Layout & Spacing'));
expect(idx('Layout & Spacing')).toBeLessThan(idx('Elevation & Depth'));
expect(idx('Elevation & Depth')).toBeLessThan(idx('Shapes'));
expect(idx('Shapes')).toBeLessThan(idx('Components'));
}
});
});
+62
View File
@@ -0,0 +1,62 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { FixerInput, FixerResult } from './spec.js';
import { CANONICAL_ORDER, resolveAlias } from '../linter/rules/section-order.js';
export function fixSectionOrder(input: FixerInput): FixerResult {
const { sections } = input;
const prelude = sections.find(s => s.heading === '');
const known = sections.filter(s => {
if (s.heading === '') return false;
return CANONICAL_ORDER.includes(resolveAlias(s.heading));
});
const unknown = sections.filter(s => {
if (s.heading === '') return false;
return !CANONICAL_ORDER.includes(resolveAlias(s.heading));
});
// Sort known sections by canonical order
known.sort((a, b) => {
return CANONICAL_ORDER.indexOf(resolveAlias(a.heading)) - CANONICAL_ORDER.indexOf(resolveAlias(b.heading));
});
const resultSections = [];
if (prelude) resultSections.push(prelude);
resultSections.push(...known);
resultSections.push(...unknown);
// Join content with newlines.
// We might need to ensure there are enough newlines between sections.
// The parser keeps the trailing newlines if they are part of the section content.
// Let's see if we need to add a newline between them.
// If we join with '\n', and content already ends with '\n', we might get double newlines.
// Let's just join them for now and see what happens in tests!
const fixedContent = resultSections.map(s => s.content).join('\n');
const beforeOrder = sections.map(s => s.heading).filter(h => h !== '');
const afterOrder = resultSections.map(s => s.heading).filter(h => h !== '');
return {
success: true,
fixedContent,
details: {
beforeOrder,
afterOrder
}
};
}
+29
View File
@@ -0,0 +1,29 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { z } from 'zod';
export const FixerInputSchema = z.object({
content: z.string(),
sections: z.array(z.object({
heading: z.string(),
content: z.string(),
})),
});
export type FixerInput = z.infer<typeof FixerInputSchema>;
export type FixerResult =
| { success: true; fixedContent: string; details?: { beforeOrder: string[]; afterOrder: string[] } }
| { success: false; error: string };
+61
View File
@@ -0,0 +1,61 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { lint } from './index.js';
import { readFileSync } from 'fs';
import { join } from 'path';
describe('Fixture Test', () => {
it('processes DESIGN-test.md', () => {
// Use import.meta.dir to get the current directory in Bun ESM
const path = join(import.meta.dir, 'fixtures', 'DESIGN-test.md');
const content = readFileSync(path, 'utf-8');
const result = lint(content);
// Basic state assertions
expect(result.designSystem.name).toBe('Pacific Mint Dental');
expect(result.designSystem.colors.size).toBeGreaterThan(0);
expect(result.designSystem.typography.size).toBeGreaterThan(0);
// Check a specific color
const surface = result.designSystem.colors.get('surface');
expect(surface).toBeDefined();
expect(surface?.hex).toBe('#f9f9ff');
// Check a typography scale
const displayLg = result.designSystem.typography.get('display-lg');
expect(displayLg).toBeDefined();
expect(displayLg?.fontFamily).toBe('Manrope');
expect(displayLg?.fontSize?.value).toBe(48);
expect(displayLg?.fontSize?.unit).toBe('px');
// fontWeight: '700' (string) is now parsed as number
expect(displayLg?.fontWeight).toBe(700);
// letterSpacing: -0.02em is parsed (model is generous) but flagged by linter
expect(displayLg?.letterSpacing).toBeDefined();
expect(displayLg?.letterSpacing?.value).toBe(-0.02);
expect(displayLg?.letterSpacing?.unit).toBe('em');
// Check lint results — should have no errors for em units now
const unitErrors = result.findings.filter(
(d: { severity: string; message: string }) => d.severity === 'error' && d.message.includes('invalid unit')
);
expect(unitErrors.length).toBe(0);
// We expect at least the summary info
expect(result.summary.infos).toBeGreaterThan(0);
});
});
@@ -0,0 +1,132 @@
---
name: The Alpine Observatory
colors:
surface: '#0a1325'
surface-dim: '#0a1325'
surface-bright: '#30394d'
surface-container-lowest: '#050e20'
surface-container-low: '#131b2e'
surface-container: '#171f32'
surface-container-high: '#212a3d'
surface-container-highest: '#2c3548'
on-surface: '#dae2fc'
on-surface-variant: '#d5c3b6'
inverse-surface: '#dae2fc'
inverse-on-surface: '#283044'
outline: '#9d8e81'
outline-variant: '#50453a'
surface-tint: '#f6bb81'
primary: '#f6bb81'
on-primary: '#4a2800'
primary-container: '#c58f59'
on-primary-container: '#4c2a00'
inverse-primary: '#815524'
secondary: '#c9c6c1'
on-secondary: '#31312d'
secondary-container: '#474743'
on-secondary-container: '#b7b5af'
tertiary: '#b7c9d8'
on-tertiary: '#22323e'
tertiary-container: '#8b9caa'
on-tertiary-container: '#233440'
error: '#ffb4ab'
on-error: '#690005'
error-container: '#93000a'
on-error-container: '#ffdad6'
primary-fixed: '#ffdcbe'
primary-fixed-dim: '#f6bb81'
on-primary-fixed: '#2c1600'
on-primary-fixed-variant: '#663d0e'
secondary-fixed: '#e5e2dc'
secondary-fixed-dim: '#c9c6c1'
on-secondary-fixed: '#1c1c18'
on-secondary-fixed-variant: '#474743'
tertiary-fixed: '#d3e5f4'
tertiary-fixed-dim: '#b7c9d8'
on-tertiary-fixed: '#0c1d28'
on-tertiary-fixed-variant: '#384955'
background: '#0a1325'
on-background: '#dae2fc'
surface-variant: '#2c3548'
typography:
heading-display:
fontFamily: Marcellus
fontSize: 48px
fontWeight: '400'
lineHeight: '1.1'
letterSpacing: 0.02em
heading-section:
fontFamily: Marcellus
fontSize: 24px
fontWeight: '400'
lineHeight: '1.3'
letterSpacing: 0.05em
body-journal:
fontFamily: newsreader
fontSize: 18px
fontWeight: '400'
lineHeight: '1.6'
letterSpacing: 0em
telemetry-data:
fontFamily: IBM Plex Mono
fontSize: 12px
fontWeight: '500'
lineHeight: '1.5'
letterSpacing: 0.15em
telemetry-label:
fontFamily: IBM Plex Mono
fontSize: 10px
fontWeight: '400'
lineHeight: '1.2'
letterSpacing: 0.2em
spacing:
panel-gap: 1rem
margin-edge: 2rem
gutter: 1rem
unit: 4px
---
## Brand & Style
This design system channels the rigorous spirit of 19th-century exploration, blending the intellectual weight of a Royal Geographical Society with the technical precision required for high-altitude survival. The aesthetic is defined as **Scientific Alpinism**—a hybrid of tactile, historic materials and cold, celestial data.
The UI should evoke a sense of "The Sublime"—a mixture of awe and peril found at extreme elevations. It utilizes a **Modern-Tactile** approach where information is treated like artifacts on a cartographers desk or telemetry viewed through a brass sextant. It avoids all modern softness, favoring the rigid structures of physical instruments and the starkness of the night sky above the treeline.
## Colors
The palette is anchored by **The Void**, a deep observatory navy that serves as the infinite backdrop of the high-altitude atmosphere. **The Lens** (Parchment) provides a high-contrast surface for intensive reading, mimicking the hand-drawn maps of early expeditions.
**The Instrument** (Antique Brass) is used exclusively for interactive elements and critical focus points, representing the physical tools of navigation. **The Hardware** (Glacial Steel) provides the structural framework, acting as the thin, cold line between the observer and the environment. Use parchment sparingly for primary content containers to create a "magnified" effect against the dark canvas.
## Typography
Typography functions as both narrative and data. **Primary Markings** (Marcellus) lend an air of classical authority and historical permanence to headers.
**The Journal** text uses Newsreader (as a proxy for the requested EB Garamond style) to facilitate long-form reading of expedition logs and alpine surveys. It should feel literary and intentional.
**The Telemetry** (IBM Plex Mono) is the voice of the machine. It must always be presented in uppercase with wide tracking, simulating the etched labels on brass equipment or the printed output of a barometer. This font is used for navigation, coordinates, and metadata.
## Layout & Spacing
The design system utilizes a **Fixed Grid** philosophy inspired by technical drafting sheets. The layout is composed of rigid panels separated by a mandatory **1rem gap**, ensuring that every module feels like a distinct instrument housed within a larger kit.
Structure is reinforced by 1px Glacial Steel borders. Layouts should be symmetrical where possible, mimicking the balanced lens of a telescope. Use white space not for "breathability," but to isolate specific data points, much like a star map isolates celestial bodies. Alignment should be strictly mathematical, with no rounded corners to break the geometry.
## Elevation & Depth
Depth is achieved through **Tonal Layering** and structural framing rather than shadows. The global canvas is the deepest level (The Void). Information panels (The Lens) sit on top as flat, non-elevated surfaces.
To indicate hierarchy, use "crosshair" intersection points where 1px borders meet. Subtle 1px insets can be used to suggest that a piece of glass has been "mounted" into a frame. There are no ambient shadows; the "light" in this system is binary—either an element is illuminated by the brass accent color or it remains in the cold steel of the background.
## Shapes
The shape language is strictly **Linear and Sharp**. A 0px border radius is enforced across all components, from buttons to large containers. This communicates precision, danger, and the uncompromising nature of high-altitude environments. Decorative elements are limited to 45-degree angled corners (chamfers) and compass-inspired iconography.
## Components
- **Action Orreries (Buttons):** Rectangular with 0px radius. Default state features a 1px Navy border and transparent background. On hover, the background fills with Antique Brass, and text shifts to Navy.
- **The Ledger (Lists):** Rows are separated by 1px Glacial Steel rules. Each row starts with a Telemetry-style timestamp or coordinate.
- **The Sextant (Inputs):** Input fields are underlined only or fully boxed in Glacial Steel. Focus state changes the border to Antique Brass with a small crosshair icon appearing in the top-right corner.
- **Specimen Cards:** Containers using the Parchment background. They must feature a 1px Steel border and often include small "latitude/longitude" telemetry in the four corners to frame the content.
- **Navigation:** Top-level navigation is centered and tracked wide using IBM Plex Mono. Active links are underlined with an Antique Brass 1px line.
- **Celestial Markers:** Use thin plus-signs (+) at the corners of main sections to act as registration marks for the UI "lens."
@@ -0,0 +1,151 @@
---
name: The Cartographer's Atlas
colors:
surface: '#0f131c'
surface-dim: '#0f131c'
surface-bright: '#353942'
surface-container-lowest: '#0a0e16'
surface-container-low: '#181c24'
surface-container: '#1c2028'
surface-container-high: '#262a33'
surface-container-highest: '#31353e'
on-surface: '#dfe2ee'
on-surface-variant: '#c7c6cc'
inverse-surface: '#dfe2ee'
inverse-on-surface: '#2c3039'
outline: '#909096'
outline-variant: '#46464c'
surface-tint: '#c3c6d7'
primary: '#c3c6d7'
on-primary: '#2c303d'
primary-container: '#0a0e1a'
on-primary-container: '#777b8a'
inverse-primary: '#5a5e6d'
secondary: '#b9c8dc'
on-secondary: '#233241'
secondary-container: '#3c4a5b'
on-secondary-container: '#abbacd'
tertiary: '#ecc246'
on-tertiary: '#3d2e00'
tertiary-container: '#150e00'
on-tertiary-container: '#987700'
error: '#ffb4ab'
on-error: '#690005'
error-container: '#93000a'
on-error-container: '#ffdad6'
primary-fixed: '#dfe2f3'
primary-fixed-dim: '#c3c6d7'
on-primary-fixed: '#171b28'
on-primary-fixed-variant: '#434654'
secondary-fixed: '#d5e4f9'
secondary-fixed-dim: '#b9c8dc'
on-secondary-fixed: '#0e1d2b'
on-secondary-fixed-variant: '#3a4858'
tertiary-fixed: '#ffe08e'
tertiary-fixed-dim: '#ecc246'
on-tertiary-fixed: '#241a00'
on-tertiary-fixed-variant: '#584400'
background: '#0f131c'
on-background: '#dfe2ee'
surface-variant: '#31353e'
typography:
display-hero:
fontFamily: Newsreader
fontSize: 84px
fontWeight: '300'
lineHeight: '1.1'
letterSpacing: 0.05em
headline-xl:
fontFamily: Newsreader
fontSize: 48px
fontWeight: '400'
lineHeight: '1.2'
letterSpacing: 0.02em
headline-md:
fontFamily: Newsreader
fontSize: 32px
fontWeight: '400'
lineHeight: '1.3'
letterSpacing: 0.02em
body-lg:
fontFamily: Manrope
fontSize: 18px
fontWeight: '400'
lineHeight: '1.7'
letterSpacing: 0.01em
body-md:
fontFamily: Manrope
fontSize: 16px
fontWeight: '400'
lineHeight: '1.7'
letterSpacing: 0.01em
label-caps:
fontFamily: Work Sans
fontSize: 12px
fontWeight: '600'
lineHeight: '1.0'
letterSpacing: 0.25em
coordinate:
fontFamily: Work Sans
fontSize: 10px
fontWeight: '400'
lineHeight: '1.0'
letterSpacing: 0.1em
spacing:
unit: 4px
gutter: 24px
margin: 64px
section-gap: 128px
---
## Brand & Style
This design system establishes a high-end editorial atmosphere that bridges the gap between 18th-century maritime navigation and 21st-century data visualization. The brand personality is authoritative, mysterious, and precise. It targets an intellectually curious audience—scholars, analysts, and enthusiasts of long-form digital storytelling.
The design style is a hybrid of **Minimalism** and **Modern Editorial**. It relies on monumental typography and extreme tonal shifts rather than decorative chrome. The aesthetic response should feel like unfolding a rare, heavy-paper map in a dimly lit study: quiet, intentional, and vast.
## Colors
The palette is anchored in "Obsidian Canvas," a near-black that provides infinite depth.
- **Neutral (#080C14):** Used for the primary background/void.
- **Primary (#0A0E1A):** Used for structural panels, cards, and inset surfaces. The contrast between Neutral and Primary is subtle, creating depth without harsh lines.
- **Secondary (#2C3A4A):** Reserved for ultra-thin dividers or structural guides when tonal contrast is insufficient. Use with extreme restraint.
- **Tertiary/Accent (#C9A227):** This vivid gold is the "Compass Rose" of the UI. It is restricted to exactly one occurrence per view—typically the primary action or a singular focal point of data.
## Typography
Typography is the primary vehicle for the "Cartographer" aesthetic.
- **Headlines:** Use **Newsreader** for its high-contrast, traditional serif quality. Display sizes should use light weights with generous tracking to feel monumental and airy.
- **Body:** Use **Manrope** for readability. The 1.7 line height is mandatory to maintain an "open" editorial feel against the dark background.
- **Labels & Annotations:** Use **Work Sans** in all-caps with wide tracking. These mimic the technical coordinates found on nautical charts.
## Layout & Spacing
This design system utilizes a **Fixed Grid** model within a full-bleed canvas. While images and background panels may stretch from edge to edge, the typographic content adheres to a strict 12-column grid with wide margins.
The rhythm is "sparse." High-density information is discouraged. Use massive vertical gaps (section-gaps) to separate narrative beats. Elements should feel like islands in a dark ocean.
## Elevation & Depth
There are no shadows in this design system. Depth is achieved through **Tonal Layering** and **Negative Space**:
1. **Level 0 (Background):** #080C14 (The base canvas).
2. **Level 1 (Panels):** #0A0E1A (Used for content blocks or "floating" map segments).
3. **Level 2 (Interaction):** Hover states use slight shifts in background color or the introduction of a #2C3A4A hairline border.
Avoid stacking more than two levels of depth. The interface should feel flat and planar, like a physical map spread across a table.
## Shapes
The shape language is strictly **Sharp**. A 0px border radius is applied to every element—buttons, cards, input fields, and images. This reinforces the precision of cartography and the "cut" feel of archival paper.
## Components
- **Buttons:** Large, rectangular, 0px radius. The primary CTA is the only element allowed to use the Gold (#C9A227) background with dark text. Secondary buttons are transparent with a thin #2C3A4A border.
- **Cards:** Defined by tonal change (#0A0E1A) against the background. No borders unless necessary for accessibility.
- **Inputs:** Minimalist bottom-border only, or a solid Primary color block. Use Label-caps for field headers.
- **Lists:** Clean rows with wide vertical padding. Use "Coordinate" style typography for indices (e.g., 001, 002).
- **The Compass Rose:** A bespoke icon or navigational element that serves as the single gold accent in a composition, used to return to "North" (the home screen) or trigger the primary narrative flow.
- **Data Points:** Small, sharp squares or crosshair glyphs (using Secondary color) for map annotations.
@@ -0,0 +1,183 @@
---
name: Pacific Mint Dental
colors:
surface: '#f9f9ff'
surface-dim: '#cfdaf1'
surface-bright: '#f9f9ff'
surface-container-lowest: '#ffffff'
surface-container-low: '#f0f3ff'
surface-container: '#e7eeff'
surface-container-high: '#dee8ff'
surface-container-highest: '#d8e3fa'
on-surface: '#111c2c'
on-surface-variant: '#3d4945'
inverse-surface: '#263142'
inverse-on-surface: '#ebf1ff'
outline: '#6d7a75'
outline-variant: '#bcc9c4'
surface-tint: '#006b5a'
primary: '#006b5a'
on-primary: '#ffffff'
primary-container: '#4cbfa6'
on-primary-container: '#004a3d'
inverse-primary: '#69dabf'
secondary: '#075fab'
on-secondary: '#ffffff'
secondary-container: '#70aeff'
on-secondary-container: '#004077'
tertiary: '#59605e'
on-tertiary: '#ffffff'
tertiary-container: '#a7aeac'
on-tertiary-container: '#3b4240'
error: '#ba1a1a'
on-error: '#ffffff'
error-container: '#ffdad6'
on-error-container: '#93000a'
primary-fixed: '#87f6db'
primary-fixed-dim: '#69dabf'
on-primary-fixed: '#00201a'
on-primary-fixed-variant: '#005143'
secondary-fixed: '#d4e3ff'
secondary-fixed-dim: '#a4c9ff'
on-secondary-fixed: '#001c39'
on-secondary-fixed-variant: '#004884'
tertiary-fixed: '#dde4e1'
tertiary-fixed-dim: '#c1c8c5'
on-tertiary-fixed: '#161d1b'
on-tertiary-fixed-variant: '#414846'
background: '#f9f9ff'
on-background: '#111c2c'
surface-variant: '#d8e3fa'
typography:
display-lg:
fontFamily: Manrope
fontSize: 48px
fontWeight: '700'
lineHeight: 56px
letterSpacing: -0.02em
headline-lg:
fontFamily: Manrope
fontSize: 32px
fontWeight: '600'
lineHeight: 40px
letterSpacing: -0.01em
headline-md:
fontFamily: Manrope
fontSize: 24px
fontWeight: '600'
lineHeight: 32px
body-lg:
fontFamily: Inter
fontSize: 18px
fontWeight: '400'
lineHeight: 28px
body-md:
fontFamily: Inter
fontSize: 16px
fontWeight: '400'
lineHeight: 24px
label-lg:
fontFamily: Inter
fontSize: 14px
fontWeight: '600'
lineHeight: 20px
letterSpacing: 0.01em
label-sm:
fontFamily: Inter
fontSize: 12px
fontWeight: '500'
lineHeight: 16px
rounded:
sm: 0.25rem
DEFAULT: 0.5rem
md: 0.75rem
lg: 1rem
xl: 1.5rem
full: 9999px
spacing:
unit: 8px
container-max: 1200px
gutter: 24px
margin-mobile: 16px
margin-desktop: 40px
---
## Brand & Style
The design system is anchored in the concept of "Clinical Serenity." Aimed at urban professionals in Seattle, the aesthetic balances the precision of modern dentistry with the calming atmosphere of a high-end wellness studio. The personality is approachable yet authoritative, designed to reduce patient anxiety through visual clarity and soft interactions.
The design style utilizes a **Modern Corporate** foundation with **Soft-Minimalist** overlays. It avoids the harsh sterility of traditional medical interfaces by using generous whitespace, translucent layers, and a palette inspired by the Pacific Northwests natural light and water. The UI should feel airy and breathable, prioritizing ease of navigation and a sense of cleanliness.
## Colors
The palette is centered on a "Calming Mint" primary tone that signals health and freshness. This is paired with a "Soft Sky Blue" secondary to reinforce feelings of trust and stability.
- **Primary:** A vibrant yet desaturated mint green used for calls to action and key brand indicators.
- **Secondary:** A soft blue used for supportive information, secondary actions, and navigational accents.
- **Surface:** Crisp white is the dominant background color to maintain a clinical standard of cleanliness.
- **Accents:** Tertiary mint-whites are used for large background sections to soften the contrast against pure white.
- **Typography:** Deep slate grays replace pure black to ensure the interface remains soft and legible without being aggressive.
## Typography
This design system utilizes a dual-font strategy to balance character with utility.
**Manrope** is used for headlines. Its geometric yet slightly rounded apertures provide a contemporary, friendly look that mirrors the "roundedness" of the brand's shape language.
**Inter** is used for all body copy and functional labels. Chosen for its exceptional legibility in clinical contexts, it ensures that medical information and appointment details are communicated with absolute clarity. Hierarchy is established through weight shifts (Medium to Semibold) rather than dramatic size changes to maintain a calm, steady rhythm.
## Layout & Spacing
The layout follows a **Fixed Grid** philosophy for desktop views, centering content within a 1200px container to create an organized, professional feel. On smaller screens, the system transitions to a fluid model with generous margins.
The rhythm is built on a strictly enforced 8px base unit.
- Use **24px (3 units)** for standard gutters and element spacing.
- Use **48px-64px (6-8 units)** for vertical section spacing to maintain an "airy" and unhurried feel.
- Elements should be aligned to a 12-column grid to ensure information-heavy pages (like dental history or treatment plans) remain structured and digestible.
## Elevation & Depth
To convey a sense of modern care, the design system utilizes **Ambient Shadows** and **Tonal Layers**. Depth is used sparingly to signify interactivity and importance.
- **Level 1 (Base):** Crisp white or tertiary-mint backgrounds.
- **Level 2 (Cards/Widgets):** Subtle 1px borders in a light gray-blue or a very soft, diffused shadow (15% opacity primary color tint) to lift the element slightly from the background.
- **Level 3 (Modals/Popovers):** Higher diffusion shadows with no blur-offset, creating a "glow" effect that feels more like light than a physical shadow.
Avoid heavy blacks or harsh dropshadows. All depth should feel "feathered" and light, as if diffused through a soft-box.
## Shapes
The shape language is defined by **Moderate Roundedness**. This approach softens the "sharpness" associated with dental tools and clinical environments, replacing it with a comforting, organic feel.
- **Primary containers:** Use a 12px to 16px corner radius.
- **Buttons and Inputs:** Use a consistent 8px radius to feel modern but structured.
- **Small elements (Tags/Chips):** Can utilize pill-shapes (fully rounded) to differentiate them from functional inputs.
Consistent radii across all components ensure the interface feels cohesive and intentionally designed.
## Components
### Buttons
Primary buttons are solid Mint Green with white text and 8px rounded corners. Secondary buttons use a "Soft Blue" outline or ghost style. Hover states should involve a subtle scale-up (1.02x) rather than a dramatic color change to keep the interaction gentle.
### Input Fields
Inputs feature a light gray-blue border that transitions to the Primary Mint color on focus. Labels are always positioned above the field for maximum accessibility. Validation states (error/success) should use soft, desaturated versions of red and green to avoid alarming the user.
### Calendar Widget
The signature component of this design system. It utilizes a soft-shadowed card (Level 2 elevation) with high-contrast dates. Selected dates are highlighted with a Primary Mint circle. The header navigation (Month/Year) uses the secondary Sky Blue to provide clear visual separation from the functional grid.
### Cards & Appointment Summaries
Cards use a Level 1 elevation (subtle border) and generous internal padding (24px). They are used to group treatment steps or upcoming appointments, creating a "tiled" look that organizes the user's health journey.
### Progress Indicators
Thin, horizontal bars using a Mint Green fill on a light mint track, used to show treatment plan completion or booking steps.
@@ -0,0 +1,176 @@
---
name: Heritage
colors:
surface: '#fbf9f6'
surface-dim: '#dbdad7'
surface-bright: '#fbf9f6'
surface-container-lowest: '#ffffff'
surface-container-low: '#f5f3f0'
surface-container: '#efeeeb'
surface-container-high: '#eae8e5'
surface-container-highest: '#e4e2df'
on-surface: '#1b1c1a'
on-surface-variant: '#44474a'
inverse-surface: '#30312f'
inverse-on-surface: '#f2f0ed'
outline: '#75777a'
outline-variant: '#c5c6ca'
surface-tint: '#5d5e61'
primary: '#000101'
on-primary: '#ffffff'
primary-container: '#1a1c1e'
on-primary-container: '#838486'
inverse-primary: '#c6c6c9'
secondary: '#595f65'
on-secondary: '#ffffff'
secondary-container: '#dde3ea'
on-secondary-container: '#5f656b'
tertiary: '#040000'
on-tertiary: '#ffffff'
tertiary-container: '#400300'
on-tertiary-container: '#db5b45'
error: '#ba1a1a'
on-error: '#ffffff'
error-container: '#ffdad6'
on-error-container: '#93000a'
primary-fixed: '#e2e2e5'
primary-fixed-dim: '#c6c6c9'
on-primary-fixed: '#1a1c1e'
on-primary-fixed-variant: '#454749'
secondary-fixed: '#dde3ea'
secondary-fixed-dim: '#c1c7ce'
on-secondary-fixed: '#161c21'
on-secondary-fixed-variant: '#41474d'
tertiary-fixed: '#ffdad3'
tertiary-fixed-dim: '#ffb4a6'
on-tertiary-fixed: '#3f0300'
on-tertiary-fixed-variant: '#881f0f'
background: '#fbf9f6'
on-background: '#1b1c1a'
surface-variant: '#e4e2df'
typography:
h1:
fontFamily: Public Sans
fontSize: 48px
fontWeight: '600'
lineHeight: '1.1'
letterSpacing: -0.02em
h2:
fontFamily: Public Sans
fontSize: 32px
fontWeight: '600'
lineHeight: '1.2'
letterSpacing: -0.01em
h3:
fontFamily: Public Sans
fontSize: 24px
fontWeight: '600'
lineHeight: '1.3'
body-lg:
fontFamily: Public Sans
fontSize: 18px
fontWeight: '400'
lineHeight: '1.6'
body-md:
fontFamily: Public Sans
fontSize: 16px
fontWeight: '400'
lineHeight: '1.6'
label-caps:
fontFamily: Space Grotesk
fontSize: 12px
fontWeight: '500'
lineHeight: '1.0'
letterSpacing: 0.1em
label-numeral:
fontFamily: Space Grotesk
fontSize: 14px
fontWeight: '500'
lineHeight: '1.0'
rounded:
sm: 0.125rem
DEFAULT: 0.25rem
md: 0.375rem
lg: 0.5rem
xl: 0.75rem
full: 9999px
spacing:
base: 16px
xs: 4px
sm: 8px
md: 16px
lg: 32px
xl: 64px
gutter: 24px
margin: 32px
---
## Brand & Style
This design system is built upon a philosophy of **Architectural Minimalism** mixed with **Journalistic Gravitas**. It is designed for high-performance athletic heritage brands, marathon organizers, and prestigious sporting publications. The aesthetic targets an audience that values discipline, endurance, and historical prestige.
The UI evokes a premium matte finish, avoiding glossy gradients or excessive shadows in favor of structural clarity and "Color Stacking." The emotional response is one of calm authority—resembling a high-end broadsheet newspaper or a contemporary gallery exhibition. It prioritizes legibility, precision timing, and editorial flow.
## Colors
The palette is rooted in high-contrast neutrals and a single, evocative accent color.
- **Primary (#1A1C1E):** A deep ink used for headlines and core text to provide maximum readability and a sense of permanence.
- **Secondary (#6C7278):** A sophisticated slate used primarily for utilitarian elements like borders, captions, and metadata.
- **Tertiary (#B8422E):** Known as "Boston Clay," this vibrant earthy red is the sole driver for interaction, used exclusively for primary actions and critical highlights.
- **Neutral (#F7F5F2):** A warm limestone that serves as the foundation for all pages, providing a softer, more organic feel than pure white.
- **Surface (#FFFFFF):** Pure white is reserved for foreground cards and content sections to create a "stacked" physical appearance against the neutral canvas.
## Typography
The typography strategy leverages two distinct weights of **Public Sans** for the narrative and **Space Grotesk** for technical data.
- **Headlines:** Set in Public Sans Semi-Bold to establish an institutional and trustworthy voice.
- **Body:** Public Sans Regular at 16px ensures contemporary professionalism and long-form readability.
- **Labels:** Space Grotesk is used for all technical data, time-stamps, and metadata. Its geometric construction evokes the precision of a digital stopwatch or race clock. Labels are strictly uppercase with generous letter spacing to enhance their "technical" feel.
## Layout & Spacing
This design system utilizes a **Relaxed Fixed Grid** approach. Content is organized within a standard 12-column grid to maintain structural alignment, but the spacing between sections is generous to allow for "breathability."
The 16px base unit dictates all padding and margins. Vertical rhythm is strictly enforced in multiples of 8px or 16px. Margins on the outer edges of the viewport should be substantial (32px+) to reinforce the editorial, "magazine-style" layout.
## Elevation & Depth
Depth in this system is achieved through **Color Stacking** and **Architectural Outlines** rather than shadows.
1. **Base Layer:** The Neutral (#F7F5F2) background serves as the ground.
2. **Surface Layer:** White (#FFFFFF) cards or sections sit directly on the ground.
3. **Definition:** Every surface layer is defined by a 1px solid Secondary (#6C7278) border.
Shadows should be avoided entirely to maintain the matte, premium finish. The hierarchy is established purely through the contrast between the limestone background and the pure white foreground containers.
## Shapes
The shape language is defined by **Architectural Sharpness**. All interactive elements, containers, and inputs utilize a minimal **4px corner radius**. This provides just enough softness to feel modern while maintaining a rigid, engineered aesthetic that reflects the precision of the marathon theme.
## Components
- **Buttons:** Primary buttons are solid "Boston Clay" (#B8422E) with white text. They use a 4px radius and 16px horizontal padding. No shadows; hover states should darken the background color slightly.
- **Inputs:** Text fields use a White background with a 1px Secondary border. On focus, the border increases to 2px and changes to Tertiary (#B8422E). Label text should use the Space Grotesk label style positioned above the field.
- **Cards:** Cards are pure white with a 1px Secondary border. They should never have shadows. Use generous internal padding (24px or 32px) to maintain the relaxed editorial feel.
- **Chips/Badges:** Use a transparent background with a 1px Secondary border and Space Grotesk labels. If used for status, the border can take on the Tertiary color.
- **Lists:** Items are separated by 1px Secondary horizontal rules. Ensure high vertical padding (16px) between items to prevent visual clutter.
- **Data Displays:** For timing and race results, use Space Grotesk numerals in Primary ink to emphasize the precision and "clock" aesthetic.
## Do's and Don'ts
### Do:
- **Do** use asymmetrical margins. If the left margin is `16 (5.5rem)`, try making the right margin `24 (8.5rem)` to create an editorial layout.
- **Do** use `Space Grotesk` for anything that feels like "data" or "process."
- **Do** lean into the "Limestone" warmth. Pure grey (#808080) is too cold; always use the `Slate Gray` (#6C7278) which has a hint of blue-gold.
### Don't:
- **Don't** use 100% black. Always use `Deep Ink` (#1A1C1E).
- **Don't** use "pill" buttons. The `4px` radius is a strict rule to maintain architectural discipline.
- **Don't** use dividers. If two pieces of content need separation, increase the spacing token (e.g., move from `4` to `6`) or change the background tone.
@@ -0,0 +1,151 @@
---
name: The Cartographer's Atlas
colors:
surface: '#0f131c'
surface-dim: '#0f131c'
surface-bright: '#353942'
surface-container-lowest: '#0a0e16'
surface-container-low: '#181c24'
surface-container: '#1c2028'
surface-container-high: '#262a33'
surface-container-highest: '#31353e'
on-surface: '#dfe2ee'
on-surface-variant: '#c7c6cc'
inverse-surface: '#dfe2ee'
inverse-on-surface: '#2c3039'
outline: '#909096'
outline-variant: '#46464c'
surface-tint: '#c3c6d7'
primary: '#c3c6d7'
on-primary: '#2c303d'
primary-container: '#0a0e1a'
on-primary-container: '#777b8a'
inverse-primary: '#5a5e6d'
secondary: '#b9c8dc'
on-secondary: '#233241'
secondary-container: '#3c4a5b'
on-secondary-container: '#abbacd'
tertiary: '#ecc246'
on-tertiary: '#3d2e00'
tertiary-container: '#150e00'
on-tertiary-container: '#987700'
error: '#ffb4ab'
on-error: '#690005'
error-container: '#93000a'
on-error-container: '#ffdad6'
primary-fixed: '#dfe2f3'
primary-fixed-dim: '#c3c6d7'
on-primary-fixed: '#171b28'
on-primary-fixed-variant: '#434654'
secondary-fixed: '#d5e4f9'
secondary-fixed-dim: '#b9c8dc'
on-secondary-fixed: '#0e1d2b'
on-secondary-fixed-variant: '#3a4858'
tertiary-fixed: '#ffe08e'
tertiary-fixed-dim: '#ecc246'
on-tertiary-fixed: '#241a00'
on-tertiary-fixed-variant: '#584400'
background: '#0f131c'
on-background: '#dfe2ee'
surface-variant: '#31353e'
typography:
display-xl:
fontFamily: Newsreader
fontSize: 84px
fontWeight: '700'
lineHeight: '1.1'
letterSpacing: 0.05em
headline-lg:
fontFamily: Newsreader
fontSize: 48px
fontWeight: '600'
lineHeight: '1.2'
letterSpacing: 0.02em
headline-md:
fontFamily: Newsreader
fontSize: 32px
fontWeight: '500'
lineHeight: '1.3'
body-lg:
fontFamily: Noto Serif
fontSize: 20px
fontWeight: '400'
lineHeight: '1.7'
body-md:
fontFamily: Noto Serif
fontSize: 17px
fontWeight: '400'
lineHeight: '1.7'
label-caps:
fontFamily: Space Grotesk
fontSize: 12px
fontWeight: '500'
lineHeight: '1.5'
letterSpacing: 0.2em
quote-editorial:
fontFamily: Newsreader
fontSize: 28px
fontWeight: '400'
lineHeight: '1.4'
spacing:
unit: 8px
gutter: 24px
margin: 64px
panel-padding: 120px
---
## Brand & Style
This design system establishes an atmosphere of intellectual authority and discovery. It targets a sophisticated audience that values long-form investigative journalism, historical context, and precision data. The brand personality is scholarly yet avant-garde, blending the archival feel of physical parchment and ink with the crispness of modern digital mapping.
The aesthetic follows a **High-Contrast / Minimalist** approach. It rejects modern trends of soft shadows and rounded corners in favor of a rigid, monumental structure. The emotional response is intended to be one of quiet focus, evoking the feeling of a researcher in a darkened library illuminated by a single high-intensity lamp.
## Colors
The palette is restricted to four core tones to maintain an editorial rigor.
- **Obsidian Canvas (#080C14):** The foundational ground. It provides a deep, non-distracting void that allows content to emerge.
- **Ink Navy (#0A0E1A):** Used for primary content containers and headings to create a subtle shift from the background without losing the dark-mode immersion.
- **Slate Structure (#2C3A4A):** The color of technicality. Used for hair-line borders, grid lines, and utilitarian UI elements.
- **Antique Gold (#C9A227):** A singular, high-intensity accent. It must be used sparingly—ideally only once per screen view—to act as a beacon for the most important action or data point.
## Typography
The typography system relies on the interplay between traditional literary serifs and technical geometric sans-serifs.
- **Headlines:** Use **Newsreader** at large scales. Its high-contrast strokes and sharp serifs command attention. Increased tracking on display sizes enhances the "monumental" feel.
- **Body:** **Noto Serif** is utilized for its warmth and legibility over long periods. The 1.7 line height is mandatory to prevent text blocks from feeling dense or unapproachable.
- **Labels & UI:** **Space Grotesk** serves as the annotation layer. It is used in all-caps with wide tracking to mimic the coordinate labels found on topographical charts.
## Layout & Spacing
This design system employs a **Full-Bleed Panel Grid** with scroll-snap functionality. Each panel represents a "chapter" or "map sheet" in the experience.
- **Offset Text Blocks:** Avoid centering text. Content should be offset to the left or right of the vertical center line to create a dynamic, editorial rhythm.
- **Alternating Panels:** Visual weight should shift between panels (e.g., a text-heavy slate panel followed by a full-screen image/data visualization on the obsidian canvas).
- **Margins:** Generous margins (64px+) ensure that content never feels crowded, maintaining the "Atlas" feel of vast, explored territory.
## Elevation & Depth
In accordance with the flat, cartographic nature of the design system, **shadows are strictly prohibited**. Depth is created exclusively through:
- **Tonal Stepping:** Layering the Primary Ink Navy (#0A0E1A) over the Neutral Obsidian (#080C14).
- **Hairline Borders:** Using 1px solid Slate (#2C3A4A) to define boundaries between panels or components.
- **Z-Index Layering:** Elements like fixed navigation or labels float over content with 100% opacity, relying on color contrast rather than blur or shadow to stand out.
## Shapes
The shape language is defined by the **0px border radius**. All containers, buttons, and decorative elements must utilize sharp, 90-degree angles. This reflects the precision of a mapmaker's tools and the rigid lines of architectural drafting. No exceptions are made for circular profile images or icons; these should be framed in square or rectangular containers.
## Components
### Buttons
Primary buttons use the Antique Gold (#C9A227) fill with Navy (#0A0E1A) text. They are rectangular (0px radius) and lack any hover shadow; hover states are indicated by a 1px Slate (#2C3A4A) outline or a slight color shift in the gold.
### Pull Quotes
Quotes are treated with high editorial importance. They feature the large Newsreader italic typeface and are anchored by a 2px vertical gold border on the left. They should often be placed in the "offset" layout area to break the body text flow.
### Lists & Annotations
Lists use the Label-style font (Space Grotesk) for bullets or numbers. Items are separated by subtle horizontal hair-lines in Slate (#2C3A4A).
### Input Fields
Inputs are simple 1px Slate outlines against the Navy surface. Focus states are indicated by the border changing to Gold. Labeling always sits above the field in uppercase, wide-tracked Space Grotesk.
### Data Panels
A unique component for this design system is the "Coordinate Panel"—a small, fixed UI element in the corner of the viewport that displays progress or metadata in the Label font style, mimicking the legend of a map.
@@ -0,0 +1,132 @@
---
name: Neo-Esoteric Monument
colors:
surface: '#fbf9f4'
surface-dim: '#dbdad5'
surface-bright: '#fbf9f4'
surface-container-lowest: '#ffffff'
surface-container-low: '#f5f4ef'
surface-container: '#efeee9'
surface-container-high: '#e9e8e3'
surface-container-highest: '#e3e2de'
on-surface: '#1b1c19'
on-surface-variant: '#4d4545'
inverse-surface: '#30312e'
inverse-on-surface: '#f2f1ec'
outline: '#7f7575'
outline-variant: '#d0c4c4'
surface-tint: '#615d5d'
primary: '#000000'
on-primary: '#ffffff'
primary-container: '#1d1b1b'
on-primary-container: '#878382'
inverse-primary: '#cbc5c5'
secondary: '#745b1d'
on-secondary: '#ffffff'
secondary-container: '#fedc91'
on-secondary-container: '#785f21'
tertiary: '#000000'
on-tertiary: '#ffffff'
tertiary-container: '#410004'
on-tertiary-container: '#dd5853'
error: '#ba1a1a'
on-error: '#ffffff'
error-container: '#ffdad6'
on-error-container: '#93000a'
primary-fixed: '#e7e1e1'
primary-fixed-dim: '#cbc5c5'
on-primary-fixed: '#1d1b1b'
on-primary-fixed-variant: '#494646'
secondary-fixed: '#ffdf9a'
secondary-fixed-dim: '#e3c37a'
on-secondary-fixed: '#251a00'
on-secondary-fixed-variant: '#5a4305'
tertiary-fixed: '#ffdad7'
tertiary-fixed-dim: '#ffb3ad'
on-tertiary-fixed: '#410004'
on-tertiary-fixed-variant: '#8a1b1d'
background: '#fbf9f4'
on-background: '#1b1c19'
surface-variant: '#e3e2de'
typography:
display-serif:
fontFamily: Newsreader
fontSize: 48px
fontWeight: '400'
lineHeight: '1.1'
letterSpacing: -0.02em
quote-editorial:
fontFamily: Newsreader
fontSize: 24px
fontWeight: '400'
lineHeight: '1.4'
body-main:
fontFamily: Inter
fontSize: 16px
fontWeight: '400'
lineHeight: '1.6'
letterSpacing: 0.01em
metadata-caps:
fontFamily: Inter
fontSize: 11px
fontWeight: '700'
lineHeight: '1.2'
letterSpacing: 0.15em
label-small:
fontFamily: Inter
fontSize: 13px
fontWeight: '500'
lineHeight: '1'
letterSpacing: 0.05em
spacing:
unit: 4px
margin-page: 64px
gutter: 32px
block-gap: 48px
element-gap: 16px
---
## Brand & Style
The design system is rooted in the "Minimalist Neo-Esoteric" aesthetic—a fusion of ancient monumentalism and modern editorial precision. It evokes the feeling of a rare, physical manuscript or a stone-carved archive rather than a digital interface. The audience is intellectual and discerning, seeking a "grave" and quiet space for high-signal discourse.
The style is **Tactile and Minimalist**, rejecting standard web conventions (like heavy gradients or standard blue links) in favor of a craftsmen-focused approach. It utilizes physical metaphors—heavy cardstock, etched metal highlights, and hard-edged shadows—to create a sense of permanence and weight. The emotional response is one of reverence, focused intensity, and analog tactile satisfaction.
## Colors
The palette is anchored by the **Warm Alabaster** base, which acts as a physical canvas. **Rich Charcoal** provides the weight for primary text and structural boundaries, creating a sense of "monumental" grounding.
**Matte Brass** is reserved for the highest tier of editorial importance and interactive states, mimicking the look of inlaid metal. **Deep Crimson** is used exclusively as a structural "bloodline"—a 1px offset shadow or a hair-thin line—to suggest depth and history without resorting to digital blurs. Avoid vibrant colors; the palette must remain muted, antique, and serious.
## Typography
This design system uses a high-contrast typographic pairing. **Newsreader** (serving the Serif requirement) is used for headlines and blockquotes, appearing authoritative and literary. **Inter** handles all functional data and body text, providing a clean, utilitarian counterpoint.
Metadata and labels must utilize **heavy tracking** (letter-spacing) and uppercase styling to evoke the feeling of architectural engravings. Body text should maintain generous line height to ensure the "cardstock" canvas feels breathable and premium.
## Layout & Spacing
The layout follows a **Fixed Grid** philosophy, centered on the screen like an open book. It uses a rigorous 12-column grid with wide gutters to emphasize the "monumental" nature of the content.
Whitespace is not "empty" but is treated as "physical margin." Elements are spaced with a mathematical rhythm based on a 4px baseline, but large-scale components (like sections or articles) use exaggerated vertical gaps to slow the reader's pace and demand attention.
## Elevation & Depth
Depth in this design system is achieved through **Physical Offsets** rather than ambient blurs.
1. **The Etch:** Instead of a drop shadow, interactive cards use a 1px or 2px solid offset in **Deep Crimson (#9F2B2A)**. This creates a "cut" or "stamped" look.
2. **The Inlay:** Interactive elements in their active state may shift 1px down and to the right, simulating a physical button press.
3. **Tonal Stacking:** Surfaces remain flat on the Alabaster background, using thin Charcoal borders (0.5pt to 1pt) to define boundaries. No soft shadows are permitted. Hierarchy is communicated through typographic scale and the presence of the Crimson offset.
## Shapes
The shape language is strictly **Sharp (0px)**. Any curvature would betray the "monumental" and "architectural" intent of the system. Rectangles represent stone slabs and cut paper. All buttons, cards, and input fields must feature perfectly square corners. Decorative elements like dividers should be 1px solid lines, occasionally interrupted by a small diamond or square glyph to mark a center point.
## Components
* **Buttons:** Rectangular with a 1px Charcoal border. On hover, the background fills with Matte Brass and the text remains Charcoal. Use the "Etch" depth (Deep Crimson offset) only for primary actions.
* **Cards (Feed Items):** Flat containers with a thin bottom border. The "upvote" or "rank" number is displayed in high-contrast Serif, while metadata (author, time) is in spaced-out Sans-serif caps.
* **Chips/Tags:** Small, all-caps labels with no background, separated by a vertical pipe `|` or a simple 1px border.
* **Lists:** Items are separated by generous whitespace and thin horizontal rules. No bullet points; use typographic hierarchy or numerical indicators in Matte Brass.
* **Input Fields:** A single bottom border in Charcoal. Labels sit above in metadata-style caps. The cursor should be a solid Charcoal block.
* **The "Artifact" (Special Component):** A featured quote or key post encased in a double-border frame (1px Charcoal outer, 1px Brass inner) to signify its "esoteric" value.
@@ -0,0 +1,40 @@
The oval is a monument. Every city has one — worn terracotta, chalked lanes, the same quarter-mile run ten thousand times by ten thousand different people at ten thousand different speeds. This is a publication about Washington DC's running tracks: where they are, what they feel like underfoot, who has run them, and why the oval endures as the purest form of athletic space.
The aesthetic draws from the physical materials of the track itself and the tradition of serious amateur athletics — collegiate, unhurried, earned. The palette is the track: terracotta surface, forest green infield, cream of an old race programme, white of a lane marking. The typography is authoritative and editorial, like the annals of an athletics club that has been meeting on the same oval since 1923. Every design decision references something real and physical, not digital.
# Design System
## Colors
- **Primary** (#1B3026): Deep midnight forest green — near-black, the infield at first light. All text, headings, core content.
- **Secondary** (#7A6A5A): Warm weathered slate — aged concrete, worn rubber track. Captions, metadata, supporting structure.
- **Tertiary** (#B5491C): Track terracotta — the surface itself. The single vivid accent. Used once per panel for CTAs and emphasis only.
- **Neutral** (#F2ECE2): Warm cream — archival paper, a race programme from 1948, linen. The canvas.
## Typography
- **Headline Font**: High-contrast collegiate editorial serif — authoritative, slightly condensed, the kind found on the cover of a century-old athletics club annual. Large sizes use tight tracking. Headlines are left-aligned, never centered.
- **Body Font**: A refined humanist serif or classic editorial sans for extended reading. Generous line height (1.7). Warm and legible. The prose of a serious running magazine.
- **Label Font**: Monospace, all uppercase, wide tracking — like a race timing display, a lane marker, a split time on a stopwatch. Section eyebrows in muted warm gray.
## Elevation
No shadows. Depth through tonal surface variation only — warm cream panels giving way to slightly deeper cream containers. The page reads like a printed object, not a glowing screen. No drop shadows anywhere.
## Shape
Roundedness: 0px. The track is geometry — precise arcs, exact distances, chalk-white right angles. No softness.
## Components
- **Buttons**: 0px radius. Tertiary terracotta fill. Dark forest green text. One per panel — the lane marker, the single instruction. Use an outer ring at forest-green / 10% opacity instead of a solid border.
- **Pull quotes**: Large editorial serif, left-bordered with a 2px terracotta rule. The voice of a runner, a coach, a timekeeper.
- **Track labels**: Monospace eyebrow style — track name, surface type, distance, ward. Like the data panel beside a race course map.
- **No icons on buttons.** No centered layouts. No solid borders alongside shadows.
## Do's and Don'ts
- Do use terracotta exactly once per panel — it is the lane marker, not the paint.
- Do left-align all heroes with a split layout: large headline left (~3/5), description and CTA right (~2/5), both top-aligned.
- Do let photography and typography share equal weight — this is an editorial publication, not a product page.
- Don't use warm backgrounds other than the cream canvas — warmth lives in the terracotta accent only.
- Don't center section headings — inline heading + subheading on the same line, heading dark bold, subheading in warm gray medium weight.
- Don't use solid borders alongside shadows — outer ring at 10% opacity only.
- Don't use sidebars or headers. Minimal to no chrome.
---
Layout: Lookbook / Editorial Spread — Full-bleed imagery dominates each panel. Text overlays or sits adjacent in generous margins. The layout reads vertically like turning pages in a physical catalogue. Each panel is its own composition — a full-width photograph of a specific track paired with sparse editorial text and a single terracotta CTA. Scrolling feels deliberate. Scroll-snap behavior.
Decoration: A subtle canvas grid of thin ruled lines between panels — horizontal lines at full viewport width, vertical lines within the page container. Like the lane markings of the track extended onto the page. Low contrast, architectural, not decorative.
Build the home page for "The Tracks of Washington DC" — a prestige editorial publication cataloguing every running track in the city. The page has 6 full-viewport panels:
1. **Hero**: Full-bleed aerial photograph of a terracotta running track at dawn. Magazine title "The Tracks of Washington DC" in large left-aligned collegiate serif. Subheading right: "A field guide to the city's ovals — their surfaces, their histories, their regulars." Single terracotta CTA: "Begin the Circuit".
2. **Georgetown Waterfront Track**: Full-bleed photograph. Editorial left panel with track name as monospace eyebrow, headline about the track's character, body prose (23 sentences), distance and surface specs as data labels.
3. **A runner mid-stride on the back straight**: Cinematic, wide. Pull quote left-bordered in terracotta — a single line about what the oval teaches. No CTA. The image is the content.
4. **Anacostia Park Track**: Mirror of panel 2 — image right, text left. Different character, different story.
5. **The Washington Circuit map**: A simple graphic showing all tracks across the city as points on a minimal street map. Monospace labels. Distances. Surface types. Forest green and terracotta on cream.
6. **The Guide**: Newsletter / field guide sign-up. Forest green surface container. Cream text. "Know every oval." Single terracotta CTA: "Get the Guide".
@@ -0,0 +1,10 @@
# Test System
## Colors
```yaml
colors:
primary: "#647D66"
```
## Overview
Description here.
@@ -0,0 +1,134 @@
---
name: The Tracks of Washington DC
colors:
surface: '#fff9ee'
surface-dim: '#dfd9d0'
surface-bright: '#fff9ee'
surface-container-lowest: '#ffffff'
surface-container-low: '#f9f3e9'
surface-container: '#f3ede3'
surface-container-high: '#ede7dd'
surface-container-highest: '#e8e2d8'
on-surface: '#1d1b16'
on-surface-variant: '#424844'
inverse-surface: '#33302a'
inverse-on-surface: '#f6f0e6'
outline: '#727874'
outline-variant: '#c2c8c2'
surface-tint: '#4d6357'
primary: '#061b12'
on-primary: '#ffffff'
primary-container: '#1b3026'
on-primary-container: '#81988b'
inverse-primary: '#b4ccbe'
secondary: '#6b5c4c'
on-secondary: '#ffffff'
secondary-container: '#f4dfcb'
on-secondary-container: '#716252'
tertiary: '#300a00'
on-tertiary: '#ffffff'
tertiary-container: '#531700'
on-tertiary-container: '#e96f3f'
error: '#ba1a1a'
on-error: '#ffffff'
error-container: '#ffdad6'
on-error-container: '#93000a'
primary-fixed: '#d0e8d9'
primary-fixed-dim: '#b4ccbe'
on-primary-fixed: '#0a1f16'
on-primary-fixed-variant: '#364b40'
secondary-fixed: '#f4dfcb'
secondary-fixed-dim: '#d7c3b0'
on-secondary-fixed: '#241a0e'
on-secondary-fixed-variant: '#524436'
tertiary-fixed: '#ffdbcf'
tertiary-fixed-dim: '#ffb59b'
on-tertiary-fixed: '#380d00'
on-tertiary-fixed-variant: '#812900'
background: '#fff9ee'
on-background: '#1d1b16'
surface-variant: '#e8e2d8'
typography:
headline-display:
fontFamily: Newsreader
fontSize: 72px
fontWeight: '700'
lineHeight: '1.1'
letterSpacing: -0.02em
headline-lg:
fontFamily: Newsreader
fontSize: 48px
fontWeight: '600'
lineHeight: '1.2'
letterSpacing: -0.01em
body-main:
fontFamily: Noto Serif
fontSize: 18px
fontWeight: '400'
lineHeight: '1.7'
letterSpacing: 0.01em
body-sm:
fontFamily: Noto Serif
fontSize: 14px
fontWeight: '400'
lineHeight: '1.6'
letterSpacing: 0px
label-mono:
fontFamily: Space Grotesk
fontSize: 12px
fontWeight: '500'
lineHeight: '1.0'
letterSpacing: 0.2em
spacing:
grid-columns: '5'
headline-span: '3'
content-span: '2'
gutter: 24px
margin: 40px
unit: 8px
---
## Brand & Style
This design system establishes a high-prestige editorial aesthetic that blends collegiate tradition with modern architectural precision. The brand identity is authoritative and quiet, evoking the intellectual rigor of a historical archive and the physical presence of iron and stone.
The design style is **Brutalist-Minimalism**. It utilizes hard edges, raw structural lines, and a deliberate absence of decorative embellishments like shadows or gradients. The visual language favors high-contrast layouts and generous negative space to elevate the subject matter—the transit and architecture of the capital—to a level of fine art.
## Colors
The palette is rooted in a "Warm Cream" canvas that provides a softer, more sophisticated background than pure white.
- **Deep Midnight Forest Green** serves as the primary ink, used for all headlines and foundational text to ensure maximum authority.
- **Warm Weathered Slate** is reserved for secondary information, metadata, and captions, providing a softer visual hierarchy.
- **Track Terracotta** is used sparingly as a singular accent for calls to action, drawing the eye to the primary interaction point on each panel.
## Typography
The typographic system relies on a sharp contrast between three distinct voices:
- **Headlines:** Set in a condensed, high-contrast serif (Newsreader). These must always be left-aligned to maintain a rigid vertical axis.
- **Body:** A humanist serif (Noto Serif) designed for long-form legibility. The generous 1.7 line height ensures a luxurious, breathable reading experience.
- **Labels:** A monospace font (Space Grotesk) used for administrative data, timestamps, and "eyebrow" text. The wide tracking is inspired by mechanical stopwatches and archival stamps.
## Layout & Spacing
The layout follows a strict asymmetrical split-grid model based on a 5-column system.
- **Split Ratio:** Content is divided into a 3/5 width for headlines and primary imagery, and a 2/5 width for descriptions, metadata, and CTAs.
- **The Grid:** A subtle canvas grid of thin ruled lines (0.5px Forest Green at 10% opacity) should be visible or implied, aligning all elements to a rigid structure.
- **Alignment:** Center-alignment is strictly prohibited. All elements must anchor to the left margin or the internal 3/5 split line.
- **Imagery:** Use full-bleed imagery that breaks through the margins to create a lookbook feel.
## Elevation & Depth
This design system avoids all drop shadows and blurs. Depth is achieved exclusively through **Tonal Variation** and layering.
Elements are perceived as being on different planes through the use of solid color blocks and ruled lines. To separate sections, use thin 1px horizontal rules in Primary Forest Green. High-priority panels may use a subtle shift in background color from the Neutral Cream to a slightly darker variant of the Secondary Slate at extremely low opacity (3-5%).
## Shapes
The shape language is defined by **Hard Right Angles**.
- **Border Radius:** All containers, buttons, and decorative elements must have a 0px radius.
- **Geometry:** Arcs and circles may be used for specific technical diagrams or "Track" iconography, but they must be precise, geometric, and never "organic" or "hand-drawn."
## Components
- **Buttons:** Rectangular with a 0px radius. Fill is Track Terracotta; text is Primary Forest Green. Each button is framed by a 1px outer ring in Primary Forest Green at 10% opacity, spaced 4px from the button edge. No icons are permitted within buttons.
- **Pull Quotes:** Set in the editorial headline serif. These feature a 2px solid vertical border in Track Terracotta on the left side only.
- **Track Labels:** Monospace "eyebrow" labels used above headlines. These should always be uppercase with 0.2em tracking.
- **Metadata Lists:** Key-value pairs (e.g., "STATION: UNION") set in the Secondary Slate color, using a mix of Monospace for keys and Humanist Serif for values.
- **Dividers:** 1px solid ruled lines. Use sparingly to define the 3/5 - 2/5 split or to separate editorial sections.
+166
View File
@@ -0,0 +1,166 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { lint } from './index.js';
describe('Integration: full pipeline', () => {
it('processes a frontmatter DESIGN.md through the complete pipeline', () => {
const content = `---
name: Kindred Spirit
description: Describes the design system for a pet care assistant.
colors:
primary: "#647D66"
secondary: "#A3B8A5"
typography:
headline-lg:
fontFamily: Google Sans Display
fontSize: 42px
fontWeight: 500
lineHeight: 50px
letterSpacing: 1.2px
body-lg:
fontFamily: Roboto
fontSize: 14px
fontWeight: 400
lineHeight: 20px
letterSpacing: 1.2px
rounded:
regular: 4px
lg: 8px
xl: 12px
full: 9999px
spacing:
gutter-s: 8px
gutter-l: 16px
---
# Kindred Spirit Design System
The palette uses a deep "Evergreen" primary for health-sector credibility.
`;
const result = lint(content);
// ── State assertions ────────────────────────────────────────────
expect(result.designSystem.colors.size).toBe(2);
expect(result.designSystem.typography.size).toBe(2);
expect(result.designSystem.rounded.size).toBe(4);
expect(result.designSystem.spacing.size).toBe(2);
expect(result.designSystem.name).toBe('Kindred Spirit');
// ── Lint assertions ─────────────────────────────────────────────
expect(result.summary.errors).toBe(0);
// Should have at least the info summary
expect(result.summary.infos).toBeGreaterThan(0);
// ── Tailwind assertions ─────────────────────────────────────────
expect(result.tailwindConfig.success).toBe(true);
if (result.tailwindConfig.success) {
const config = result.tailwindConfig.data;
expect(config.theme?.extend?.colors?.['primary']).toBe('#647d66');
expect(config.theme?.extend?.fontFamily?.['headline-lg']).toContain('Google Sans Display');
expect(config.theme?.extend?.borderRadius?.['full']).toBe('9999px');
expect(config.theme?.extend?.spacing?.['gutter-s']).toBe('8px');
}
});
it('processes a code-block DESIGN.md with components', () => {
const content = `# Kindred Spirit
\`\`\`yaml
colors:
primary: "#647D66"
white: "#ffffff"
\`\`\`
\`\`\`yaml
components:
button-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.white}"
rounded: 8px
padding: 12px
\`\`\`
`;
const result = lint(content);
expect(result.designSystem.colors.size).toBe(2);
expect(result.designSystem.components.size).toBe(1);
expect(result.summary.errors).toBe(0);
// The component should have resolved backgroundColor to the primary color
const btn = result.designSystem.components.get('button-primary');
expect(btn).toBeDefined();
const bg = btn?.properties.get('backgroundColor');
expect(typeof bg === 'object' && bg !== null && 'type' in bg && bg.type === 'color').toBe(true);
});
it('correctly detects errors in a broken DESIGN.md', () => {
const content = `---
colors:
primary: "#647D66"
bad-color: not-a-color
components:
card:
backgroundColor: "{colors.nonexistent}"
textColor: "#ffffff"
---`;
const result = lint(content);
// Should have errors: invalid color + broken reference
expect(result.summary.errors).toBeGreaterThanOrEqual(2);
});
it('warns on a misspelled top-level key via the default rule set', () => {
const content = `---
name: Example
colours:
primary: "#647D66"
---`;
const result = lint(content);
const finding = result.findings.find(
f => f.message === 'Unknown key "colours" — did you mean "colors"?'
);
expect(finding).toBeDefined();
expect(finding!.severity).toBe('warning');
expect(finding!.path).toBe('colours');
});
it('stays silent for custom extension keys that are not close to any known key', () => {
const content = `---
name: Example
icons:
search: "search-icon.svg"
motion:
fast: "100ms"
---`;
const result = lint(content);
const unknownKeyFindings = result.findings.filter(f =>
f.message.startsWith('Unknown key ')
);
expect(unknownKeyFindings).toEqual([]);
});
});
+59
View File
@@ -0,0 +1,59 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ── Primary API ────────────────────────────────────────────────────
export { lint } from './lint.js';
export type { LintReport, LintOptions } from './lint.js';
// ── Result types ───────────────────────────────────────────────────
export type {
DesignSystemState,
ResolvedColor,
ResolvedDimension,
ResolvedTypography,
ResolvedValue,
ComponentDef,
} from './model/spec.js';
export type { Finding, Severity } from './linter/spec.js';
export type { TailwindEmitterResult, TailwindThemeExtend } from './tailwind/spec.js';
export type { TailwindV4EmitterResult, TailwindV4ThemeData } from './tailwind/v4/spec.js';
export type { DtcgEmitterResult, DtcgTokenFile } from './dtcg/spec.js';
export type { CssVarsEmitterResult, CssVarDeclaration } from './css-vars/spec.js';
// ── Advanced linting ───────────────────────────────────────────────
export { runLinter, preEvaluate } from './linter/runner.js';
export { DEFAULT_RULES } from './linter/rules/index.js';
export type { LintRule } from './linter/rules/types.js';
export type { GradedTokenEdits, TokenEditEntry } from './linter/spec.js';
export {
brokenRef,
missingPrimary,
contrastCheck,
orphanedTokens,
tokenSummary,
missingSections,
missingTypography,
unknownKey,
tokenLikeIgnored,
} from './linter/rules/index.js';
export { contrastRatio } from './model/handler.js';
export { TailwindEmitterHandler } from './tailwind/handler.js';
export { TailwindV4EmitterHandler } from './tailwind/v4/handler.js';
export { serializeToCss as serializeTailwindV4 } from './tailwind/v4/serialize.js';
export { DtcgEmitterHandler } from './dtcg/handler.js';
export { CssVarsEmitterHandler } from './css-vars/handler.js';
export { serializeCssVars } from './css-vars/serialize.js';
export { fixSectionOrder } from './fixer/handler.js';
export type { FixerInput, FixerResult } from './fixer/spec.js';
+150
View File
@@ -0,0 +1,150 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { ParserHandler } from './parser/handler.js';
import type { ParsedDesignSystem } from './parser/spec.js';
import { ModelHandler } from './model/handler.js';
import { runLinter } from './linter/runner.js';
import { TailwindEmitterHandler } from './tailwind/handler.js';
import type { DesignSystemState } from './model/spec.js';
import type { Finding } from './linter/spec.js';
import type { LintRule } from './linter/rules/types.js';
import type { TailwindEmitterResult } from './tailwind/spec.js';
export interface LintOptions {
/** Custom lint rules. Defaults to DEFAULT_RULES if omitted. */
rules?: LintRule[];
}
export interface LintReport {
/** The fully resolved design system model. */
designSystem: DesignSystemState;
/** All findings from the linter. */
findings: Finding[];
/** Aggregate counts by severity. */
summary: { errors: number; warnings: number; infos: number };
/** Generated Tailwind CSS theme configuration. */
tailwindConfig: TailwindEmitterResult;
/** Markdown heading names found in the document. */
sections: string[];
/** The partitioned document sections. */
documentSections: Array<{ heading: string; content: string }>;
}
/**
* Lint a DESIGN.md document.
*
* Parses the markdown, resolves all design tokens into a typed model,
* runs lint rules, and generates a Tailwind CSS theme configuration.
*
* @param content - Raw DESIGN.md content (markdown with YAML frontmatter or code blocks)
* @param options - Optional configuration (custom rules, etc.)
* @returns A LintReport with the resolved design system, findings, and Tailwind config
* @throws If parsing or model resolution fails unrecoverably
*/
export function lint(content: string, options?: LintOptions): LintReport {
const parser = new ParserHandler();
const model = new ModelHandler();
const tailwind = new TailwindEmitterHandler();
const parseResult = parser.execute({ content });
// Handle parse failures gracefully
if (!parseResult.success) {
// For recoverable errors (e.g. no YAML found), return a report
// with an empty design system and a finding instead of throwing.
if (parseResult.error.recoverable) {
const emptyParsed: ParsedDesignSystem = { sourceMap: new Map() };
const { designSystem } = model.execute(emptyParsed);
// Still extract sections from the raw content even without YAML
const sections = extractSectionsFromContent(content);
return {
designSystem,
findings: [{
severity: 'warning',
message: parseResult.error.message,
}],
summary: { errors: 0, warnings: 1, infos: 0 },
tailwindConfig: tailwind.execute(designSystem),
sections: sections.map(s => s.heading).filter(Boolean),
documentSections: sections,
};
}
// Non-recoverable errors are still fatal
throw new Error(`Parse failed: ${parseResult.error.message}`);
}
const { designSystem, findings: modelFindings } = model.execute(parseResult.data);
const lintResult = runLinter(designSystem, options?.rules);
const tailwindConfig = tailwind.execute(designSystem);
const findings = [...modelFindings, ...lintResult.findings];
const summary = {
errors: modelFindings.filter((d) => d.severity === 'error').length + lintResult.summary.errors,
warnings: modelFindings.filter((d) => d.severity === 'warning').length + lintResult.summary.warnings,
infos: modelFindings.filter((d) => d.severity === 'info').length + lintResult.summary.infos,
};
return {
designSystem,
findings,
summary,
tailwindConfig,
sections: parseResult.data.sections ?? [],
documentSections: parseResult.data.documentSections ?? [],
};
}
/**
* Extract document sections from raw markdown content by finding H2 headings.
* Used as a fallback when the parser cannot extract YAML.
*/
function extractSectionsFromContent(content: string): Array<{ heading: string; content: string }> {
const lines = content.split('\n');
const sections: Array<{ heading: string; content: string }> = [];
const headingPattern = /^## (.+)$/;
let currentStart = 0;
let currentHeading = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
const match = headingPattern.exec(line);
if (match) {
// Push previous section
if (i > 0) {
sections.push({
heading: currentHeading,
content: lines.slice(currentStart, i).join('\n'),
});
}
currentHeading = match[1] ?? '';
currentStart = i;
}
}
// Push final section
sections.push({
heading: currentHeading,
content: lines.slice(currentStart).join('\n'),
});
return sections;
}
@@ -0,0 +1,222 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { LinterHandler } from './handler.js';
import { ModelHandler } from '../model/handler.js';
import type { ParsedDesignSystem } from '../parser/spec.js';
import type { DesignSystemState } from '../model/spec.js';
import type { Finding } from './spec.js';
const linter = new LinterHandler();
const modelHandler = new ModelHandler();
/** Helper: parse → build model → return state */
function buildState(overrides: Partial<ParsedDesignSystem> = {}): DesignSystemState {
const parsed: ParsedDesignSystem = { sourceMap: new Map(), ...overrides };
const result = modelHandler.execute(parsed);
const hasErrors = result.findings.some(d => d.severity === 'error');
if (hasErrors) {
throw new Error(`Model build failed: ${result.findings.map(d => d.message).join(', ')}`);
}
return result.designSystem;
}
describe('LinterHandler', () => {
// ── Cycle 15: E3 — Broken reference emits error ──────────────────
describe('E3: broken token reference', () => {
it('emits error when a component references a non-existent token', () => {
const state = buildState({
colors: { primary: '#ff0000' },
components: {
'button': {
backgroundColor: '{colors.nonexistent}',
},
},
});
const result = linter.lint(state);
const errors = result.findings.filter((d: Finding) => d.severity === 'error');
expect(errors.some((d: Finding) => d.message.includes('does not resolve'))).toBe(true);
});
});
// ── Cycle 16: E4 — Circular reference emits error ────────────────
describe('E4: circular reference', () => {
it('emits error when circular references are detected', () => {
const state = buildState({
colors: {
'a': '{colors.b}' as string,
'b': '{colors.a}' as string,
},
components: {
'card': {
backgroundColor: '{colors.a}',
},
},
});
const result = linter.lint(state);
const errors = result.findings.filter((d: Finding) => d.severity === 'error');
expect(errors.some((d: Finding) => d.message.toLowerCase().includes('unresolved') || d.message.toLowerCase().includes('resolve'))).toBe(true);
});
});
// ── Cycle 17: W1 — Missing primary emits warning ─────────────────
describe('W1: missing primary color', () => {
it('emits warning when no primary color is defined', () => {
const state = buildState({
colors: { accent: '#ff0000' },
});
const result = linter.lint(state);
const warnings = result.findings.filter((d: Finding) => d.severity === 'warning');
expect(warnings.some((d: Finding) => d.message.includes('primary'))).toBe(true);
});
it('does NOT emit warning when primary color IS defined', () => {
const state = buildState({
colors: { primary: '#ff0000' },
});
const result = linter.lint(state);
const warnings = result.findings.filter((d: Finding) => d.severity === 'warning' && d.message.includes('primary'));
expect(warnings.length).toBe(0);
});
});
// ── Cycle 18: W2 — Low contrast ratio emits warning ──────────────
describe('W2: WCAG contrast failure', () => {
it('emits warning for low contrast backgroundColor/textColor pair', () => {
const state = buildState({
colors: {
'yellow': '#ffff00',
'white': '#ffffff',
},
components: {
'button-bad': {
backgroundColor: '{colors.yellow}',
textColor: '{colors.white}',
},
},
});
const result = linter.lint(state);
const warnings = result.findings.filter((d: Finding) => d.severity === 'warning');
expect(warnings.some((d: Finding) => d.message.includes('contrast'))).toBe(true);
});
it('does NOT emit warning for high contrast pair', () => {
const state = buildState({
colors: {
'black': '#000000',
'white': '#ffffff',
},
components: {
'button-good': {
backgroundColor: '{colors.black}',
textColor: '{colors.white}',
},
},
});
const result = linter.lint(state);
const contrastWarnings = result.findings.filter(
(d: Finding) => d.severity === 'warning' && d.message.includes('contrast')
);
expect(contrastWarnings.length).toBe(0);
});
});
// ── Cycle 19: I1 — Token count summary emits info ────────────────
describe('I1: token count summary', () => {
it('emits an info diagnostic summarizing the token counts', () => {
const state = buildState({
colors: { primary: '#ff0000', secondary: '#00ff00' },
typography: {
'headline-lg': { fontFamily: 'Roboto', fontSize: '42px', fontWeight: 500 },
},
rounded: { regular: '4px' },
spacing: { 'gutter-s': '8px' },
});
const result = linter.lint(state);
const infos = result.findings.filter((d: Finding) => d.severity === 'info');
expect(infos.some((d: Finding) => d.message.includes('2 color') && d.message.includes('1 typography'))).toBe(true);
});
});
// ── Cycle 20: Clean document produces zero errors ─────────────────
describe('clean document', () => {
it('produces zero errors for a valid design system', () => {
const state = buildState({
colors: { primary: '#647D66', secondary: '#ff0000' },
typography: {
'headline-lg': { fontFamily: 'Roboto', fontSize: '42px', fontWeight: 500, lineHeight: '50px', letterSpacing: '1.2px' },
},
rounded: { regular: '4px', lg: '8px' },
spacing: { 'gutter-s': '8px', 'gutter-l': '16px' },
components: {
'button-primary': {
backgroundColor: '{colors.primary}',
textColor: '#ffffff',
},
},
});
const result = linter.lint(state);
const errors = result.findings.filter((d: Finding) => d.severity === 'error');
expect(errors.length).toBe(0);
});
});
// ── Cycle 21: preEvaluate graded menu ─────────────────────────────
describe('preEvaluate graded menu', () => {
it('groups findings into fixes, improvements, and suggestions', () => {
const state = buildState({
colors: {
primary: '#647D66',
secondary: '#ffff00',
white: '#ffffff',
},
components: {
'button-bad': {
backgroundColor: '{colors.secondary}',
textColor: '{colors.white}',
},
'button-broken': {
backgroundColor: '{colors.nonexistent}',
textColor: '{colors.white}',
}
},
});
const graded = linter.preEvaluate(state);
expect(graded.fixes.length).toBeGreaterThan(0);
expect(graded.improvements.length).toBeGreaterThan(0);
expect(graded.suggestions.length).toBeGreaterThan(0);
});
});
// ── Summary counts ───────────────────────────────────────────────
describe('summary counts', () => {
it('correctly counts errors, warnings, and infos', () => {
const state = buildState({
colors: { primary: '#ff0000' },
components: {
'card': { backgroundColor: '{colors.nonexistent}' }
}
});
const result = linter.lint(state);
expect(result.summary.errors).toBe(result.findings.filter((d: Finding) => d.severity === 'error').length);
expect(result.summary.warnings).toBe(result.findings.filter((d: Finding) => d.severity === 'warning').length);
expect(result.summary.infos).toBe(result.findings.filter((d: Finding) => d.severity === 'info').length);
});
});
});
+35
View File
@@ -0,0 +1,35 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type {
LinterSpec,
LintResult,
GradedTokenEdits,
} from './spec.js';
import type { DesignSystemState } from '../model/spec.js';
import { runLinter, preEvaluate } from './runner.js';
/**
* @deprecated Use `runLinter()` and `preEvaluate()` from './runner.js' directly.
* This class exists only for backward compatibility.
*/
export class LinterHandler implements LinterSpec {
lint(state: DesignSystemState): LintResult {
return runLinter(state);
}
preEvaluate(state: DesignSystemState): GradedTokenEdits {
return preEvaluate(state);
}
}
@@ -0,0 +1,55 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { brokenRef, brokenRefRule } from './broken-ref.js';
import { buildState } from './test-helpers.js';
describe('brokenRef', () => {
it('emits error for unresolved token reference', () => {
const state = buildState({
colors: { primary: '#ff0000' },
components: { button: { backgroundColor: '{colors.nonexistent}' } },
});
const findings = brokenRef(state);
expect(findings.some(d => d.message.includes('does not resolve'))).toBe(true);
});
it('returns empty when all references resolve', () => {
const state = buildState({
colors: { primary: '#ff0000' },
components: { button: { backgroundColor: '{colors.primary}' } },
});
const errors = brokenRef(state).filter(d => d.message.includes('does not resolve'));
expect(errors.length).toBe(0);
});
it('emits warning (not error) for unknown component sub-tokens', () => {
const state = buildState({
colors: { primary: '#ff0000' },
components: { button: { borderColor: '#ff0000' } },
});
const findings = brokenRef(state);
const subTokenDiag = findings.find(d => d.message.includes('not a recognized'));
expect(subTokenDiag).toBeDefined();
expect(subTokenDiag!.severity).toBe('warning');
});
it('has a valid rule descriptor', () => {
expect(brokenRefRule.name).toBe('broken-ref');
expect(brokenRefRule.severity).toBe('error');
expect(brokenRefRule.description).toBeTruthy();
expect(brokenRefRule.run).toBe(brokenRef);
});
});
@@ -0,0 +1,52 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../../model/spec.js';
import { VALID_COMPONENT_SUB_TOKENS } from '../../model/spec.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
/**
* Broken/circular references and unknown component sub-tokens.
*/
export function brokenRef(state: DesignSystemState): RuleFinding[] {
const findings: RuleFinding[] = [];
for (const [compName, comp] of state.components) {
// Unresolved references
for (const ref of comp.unresolvedRefs) {
findings.push({
path: `components.${compName}`,
message: `Reference ${ref} does not resolve to any defined token.`,
});
}
// Unknown component sub-tokens (lower severity override)
for (const [propName] of comp.properties) {
if (!(VALID_COMPONENT_SUB_TOKENS as readonly string[]).includes(propName)) {
findings.push({
severity: 'warning',
path: `components.${compName}.${propName}`,
message: `'${propName}' is not a recognized component sub-token. Valid sub-tokens: ${VALID_COMPONENT_SUB_TOKENS.join(', ')}.`,
});
}
}
}
return findings;
}
export const brokenRefRule: RuleDescriptor = {
name: 'broken-ref',
severity: 'error',
description: 'Broken/circular references and unknown component sub-tokens.',
run: brokenRef,
};
@@ -0,0 +1,42 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { contrastCheck } from './contrast-ratio.js';
import { buildState } from './test-helpers.js';
describe('contrastCheck', () => {
it('emits warning for low contrast pair', () => {
const state = buildState({
colors: { yellow: '#ffff00', white: '#ffffff' },
components: {
'button-bad': { backgroundColor: '{colors.yellow}', textColor: '{colors.white}' },
},
});
const findings = contrastCheck(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toMatch(/contrast/);
});
it('returns empty for high contrast pair', () => {
const state = buildState({
colors: { black: '#000000', white: '#ffffff' },
components: {
'button-good': { backgroundColor: '{colors.black}', textColor: '{colors.white}' },
},
});
const contrastWarnings = contrastCheck(state).filter(d => d.message.includes('contrast'));
expect(contrastWarnings.length).toBe(0);
});
});
@@ -0,0 +1,59 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState, ResolvedColor, ResolvedValue } from '../../model/spec.js';
import { contrastRatio } from '../../model/handler.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
const WCAG_AA_MINIMUM = 4.5;
/**
* WCAG contrast ratio — warns when component backgroundColor/textColor pairs
* fall below the AA minimum of 4.5:1.
*/
export function contrastCheck(state: DesignSystemState): RuleFinding[] {
const findings: RuleFinding[] = [];
for (const [compName, comp] of state.components) {
const bgValue = comp.properties.get('backgroundColor');
const textValue = comp.properties.get('textColor');
if (!bgValue || !textValue) continue;
const bgColor = resolveToColor(bgValue);
const textColor = resolveToColor(textValue);
if (!bgColor || !textColor) continue;
const ratio = contrastRatio(bgColor, textColor);
if (ratio < WCAG_AA_MINIMUM) {
findings.push({
path: `components.${compName}`,
message: `textColor (${textColor.hex}) on backgroundColor (${bgColor.hex}) has contrast ratio ${ratio.toFixed(2)}:1, below WCAG AA minimum of ${WCAG_AA_MINIMUM}:1.`,
});
}
}
return findings;
}
function resolveToColor(value: ResolvedValue): ResolvedColor | null {
if (typeof value === 'object' && value !== null && 'type' in value && value.type === 'color') {
return value as ResolvedColor;
}
return null;
}
export const contrastCheckRule: RuleDescriptor = {
name: 'contrast-ratio',
severity: 'warning',
description: 'WCAG contrast ratio — warns when component backgroundColor/textColor pairs fall below the AA minimum of 4.5:1.',
run: contrastCheck,
};
@@ -0,0 +1,67 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { LintRule, RuleDescriptor } from './types.js';
import type { DesignSystemState } from '../../model/spec.js';
import type { Finding } from '../spec.js';
import { brokenRefRule } from './broken-ref.js';
import { missingPrimaryRule } from './missing-primary.js';
import { contrastCheckRule } from './contrast-ratio.js';
import { orphanedTokensRule } from './orphaned-tokens.js';
import { tokenSummaryRule } from './token-summary.js';
import { missingSectionsRule } from './missing-sections.js';
import { sectionOrderRule } from './section-order.js';
import { missingTypographyRule } from './missing-typography.js';
import { unknownKeyRule } from './unknown-key.js';
import { tokenLikeIgnoredRule } from './token-like-ignored.js';
/** The default set of lint rule descriptors, in order. */
export const DEFAULT_RULE_DESCRIPTORS: RuleDescriptor[] = [
brokenRefRule,
missingPrimaryRule,
contrastCheckRule,
orphanedTokensRule,
tokenSummaryRule,
missingSectionsRule,
missingTypographyRule,
sectionOrderRule,
unknownKeyRule,
tokenLikeIgnoredRule,
];
/** Converts a RuleDescriptor into a LintRule by injecting severity into findings. */
function toLintRule(descriptor: RuleDescriptor): LintRule {
return (state: DesignSystemState): Finding[] =>
descriptor.run(state).map(finding => ({
severity: finding.severity ?? descriptor.severity,
path: finding.path,
message: finding.message,
}));
}
/** The default set of lint rules, executed in order. */
export const DEFAULT_RULES: LintRule[] = DEFAULT_RULE_DESCRIPTORS.map(toLintRule);
// Re-export individual rules for selective composition
export { brokenRef } from './broken-ref.js';
export { missingPrimary } from './missing-primary.js';
export { contrastCheck } from './contrast-ratio.js';
export { orphanedTokens } from './orphaned-tokens.js';
export { tokenSummary } from './token-summary.js';
export { missingSections } from './missing-sections.js';
export { missingTypography } from './missing-typography.js';
export { unknownKey } from './unknown-key.js';
export { sectionOrder } from './section-order.js';
export { tokenLikeIgnored } from './token-like-ignored.js';
export type { LintRule } from './types.js';
@@ -0,0 +1,60 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { levenshtein } from './levenshtein.js';
describe('levenshtein', () => {
it('returns 0 for identical strings', () => {
expect(levenshtein('colors', 'colors')).toBe(0);
});
it('returns 0 when both strings are empty', () => {
expect(levenshtein('', '')).toBe(0);
});
it('returns the length of the other string when one is empty', () => {
expect(levenshtein('', 'colors')).toBe(6);
expect(levenshtein('colors', '')).toBe(6);
});
it('counts a single substitution as distance 1', () => {
expect(levenshtein('cat', 'bat')).toBe(1);
});
it('counts a single insertion as distance 1', () => {
expect(levenshtein('cat', 'cats')).toBe(1);
});
it('counts a single deletion as distance 1', () => {
expect(levenshtein('cats', 'cat')).toBe(1);
});
it('is symmetric: levenshtein(a, b) === levenshtein(b, a)', () => {
expect(levenshtein('typografy', 'typography')).toBe(levenshtein('typography', 'typografy'));
expect(levenshtein('kitten', 'sitting')).toBe(levenshtein('sitting', 'kitten'));
});
it('matches the classic kitten/sitting example (distance 3)', () => {
expect(levenshtein('kitten', 'sitting')).toBe(3);
});
it('computes distance for the schema-key typo cases used by unknown-key', () => {
expect(levenshtein('colours', 'colors')).toBe(1);
expect(levenshtein('typografy', 'typography')).toBe(2);
expect(levenshtein('nam', 'name')).toBe(1);
expect(levenshtein('rounding', 'rounded')).toBe(3);
expect(levenshtein('icons', 'colors')).toBe(4);
});
});
@@ -0,0 +1,30 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/** Levenshtein edit distance between two strings. */
export function levenshtein(a: string, b: string): number {
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, (_, i) =>
Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0))
);
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i]![j] = a[i - 1] === b[j - 1]
? dp[i - 1]![j - 1]!
: 1 + Math.min(dp[i - 1]![j]!, dp[i]![j - 1]!, dp[i - 1]![j - 1]!);
}
}
return dp[m]![n]!;
}
@@ -0,0 +1,36 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { missingPrimary } from './missing-primary.js';
import { buildState } from './test-helpers.js';
describe('missingPrimary', () => {
it('emits warning when colors exist but no primary', () => {
const state = buildState({ colors: { accent: '#ff0000' } });
const findings = missingPrimary(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toMatch(/primary/);
});
it('returns empty when primary IS defined', () => {
const state = buildState({ colors: { primary: '#ff0000' } });
expect(missingPrimary(state)).toEqual([]);
});
it('returns empty when no colors defined', () => {
const state = buildState({});
expect(missingPrimary(state)).toEqual([]);
});
});
@@ -0,0 +1,36 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../../model/spec.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
/**
* Missing primary color — warns when colors are defined but no 'primary' exists.
*/
export function missingPrimary(state: DesignSystemState): RuleFinding[] {
if (state.colors.size > 0 && !state.colors.has('primary')) {
return [{
path: 'colors',
message: "No 'primary' color defined. The agent will auto-generate key colors, reducing your control over the palette.",
}];
}
return [];
}
export const missingPrimaryRule: RuleDescriptor = {
name: 'missing-primary',
severity: 'warning',
description: "Missing primary color — warns when colors are defined but no 'primary' exists.",
run: missingPrimary,
};
@@ -0,0 +1,45 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { missingSections } from './missing-sections.js';
import { buildState } from './test-helpers.js';
describe('missingSections', () => {
it('emits info when spacing is missing but colors exist', () => {
const state = buildState({
colors: { primary: '#ff0000' },
rounded: { regular: '4px' },
// no spacing
});
const findings = missingSections(state);
const spacingNote = findings.find(d => d.path === 'spacing');
expect(spacingNote).toBeDefined();
expect(spacingNote!.message).toMatch(/spacing/);
});
it('returns empty when all sections present', () => {
const state = buildState({
colors: { primary: '#ff0000' },
rounded: { regular: '4px' },
spacing: { unit: '8px' },
});
expect(missingSections(state)).toEqual([]);
});
it('returns empty when no colors exist (nothing to compare against)', () => {
const state = buildState({});
expect(missingSections(state)).toEqual([]);
});
});
@@ -0,0 +1,44 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../../model/spec.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
/**
* Missing sections — notes when optional sections (spacing, rounded) are absent.
*/
export function missingSections(state: DesignSystemState): RuleFinding[] {
const findings: RuleFinding[] = [];
const sections = [
{ map: state.spacing, name: 'spacing', fallback: 'Layout spacing will fall back to agent defaults.' },
{ map: state.rounded, name: 'rounded', fallback: 'Corner rounding will fall back to agent defaults.' },
];
for (const { map, name, fallback } of sections) {
if (map.size === 0 && state.colors.size > 0) {
findings.push({
path: name,
message: `No '${name}' section defined. ${fallback}`,
});
}
}
return findings;
}
export const missingSectionsRule: RuleDescriptor = {
name: 'missing-sections',
severity: 'info',
description: 'Missing sections — notes when optional sections (spacing, rounded) are absent.',
run: missingSections,
};
@@ -0,0 +1,48 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { missingTypography } from './missing-typography.js';
import { buildState } from './test-helpers.js';
describe('missingTypography', () => {
it('emits warning when colors exist but no typography defined', () => {
const state = buildState({
colors: { primary: '#ff0000' },
// no typography
});
const findings = missingTypography(state);
expect(findings.length).toBe(1);
expect(findings[0]!.path).toBe('typography');
expect(findings[0]!.message).toMatch(/typography/i);
});
it('returns empty when typography IS defined', () => {
const state = buildState({
colors: { primary: '#ff0000' },
typography: {
'body-md': {
fontFamily: 'Inter',
fontSize: '16px',
},
},
});
expect(missingTypography(state)).toEqual([]);
});
it('returns empty when no colors defined (nothing to compare against)', () => {
const state = buildState({});
expect(missingTypography(state)).toEqual([]);
});
});
@@ -0,0 +1,38 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../../model/spec.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
/**
* Missing typography — warns when colors are defined but no typography tokens exist.
* Without typography tokens, agents will fall back to their own font choices,
* reducing the author's control over the design system's typographic identity.
*/
export function missingTypography(state: DesignSystemState): RuleFinding[] {
if (state.typography.size === 0 && state.colors.size > 0) {
return [{
path: 'typography',
message: "No typography tokens defined. Agents will use default font choices, reducing your control over the design system's typographic identity.",
}];
}
return [];
}
export const missingTypographyRule: RuleDescriptor = {
name: 'missing-typography',
severity: 'warning',
description: "Missing typography — warns when colors are defined but no typography tokens exist.",
run: missingTypography,
};
@@ -0,0 +1,135 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { orphanedTokens } from './orphaned-tokens.js';
import { buildState } from './test-helpers.js';
describe('orphanedTokens', () => {
it('emits warning for color not referenced by any component', () => {
const state = buildState({
colors: { primary: '#ff0000', unused: '#00ff00' },
components: {
button: { backgroundColor: '{colors.primary}' },
},
});
const findings = orphanedTokens(state);
const orphan = findings.find(d => d.message.includes('unused'));
expect(orphan).toBeDefined();
});
it('returns empty when no components exist', () => {
const state = buildState({ colors: { primary: '#ff0000' } });
expect(orphanedTokens(state)).toEqual([]);
});
it('does not flag MD3 paired tokens when the family is referenced (issue #46)', () => {
// When a component references `primary`, the rest of the MD3 primary
// family (`on-primary`, `primary-container`, `on-primary-container`,
// `primary-fixed`, `primary-fixed-dim`, `on-primary-fixed`,
// `on-primary-fixed-variant`, `inverse-primary`) is part of the same
// semantic group and should not be flagged as orphaned.
const state = buildState({
colors: {
primary: '#1A1C1E',
'on-primary': '#ffffff',
'primary-container': '#e2e2e2',
'on-primary-container': '#636565',
'primary-fixed': '#e2e2e2',
'primary-fixed-dim': '#c6c6c7',
'on-primary-fixed': '#1a1c1c',
'on-primary-fixed-variant': '#454747',
'inverse-primary': '#5d5f5f',
},
components: {
button: { backgroundColor: '{colors.primary}' },
},
});
const findings = orphanedTokens(state);
expect(findings).toEqual([]);
});
it('does not flag MD3 surface family when one surface token is referenced', () => {
const state = buildState({
colors: {
surface: '#0b1326',
'surface-dim': '#0b1326',
'surface-bright': '#31394d',
'surface-container': '#171f33',
'surface-container-lowest': '#060e20',
'surface-container-low': '#131b2e',
'surface-container-high': '#222a3d',
'surface-container-highest': '#2d3449',
'on-surface': '#dae2fd',
'on-surface-variant': '#c4c7c8',
'inverse-surface': '#dae2fd',
'inverse-on-surface': '#283044',
'surface-tint': '#c6c6c7',
'surface-variant': '#2d3449',
},
components: {
card: { backgroundColor: '{colors.surface-container}' },
},
});
const findings = orphanedTokens(state);
expect(findings).toEqual([]);
});
it('still flags genuinely-orphaned custom tokens outside any referenced family', () => {
// `brand-blue` is not part of the MD3 primary family, so referencing
// `primary` does not save it.
const state = buildState({
colors: {
primary: '#1A1C1E',
'on-primary': '#ffffff',
'brand-blue': '#0000ff',
},
components: {
button: { backgroundColor: '{colors.primary}' },
},
});
const findings = orphanedTokens(state);
const orphan = findings.find(d => d.path === 'colors.brand-blue');
expect(orphan).toBeDefined();
// And confirms `on-primary` does not get flagged just because it's not
// directly referenced.
expect(findings.find(d => d.path === 'colors.on-primary')).toBeUndefined();
});
it('does not flag MD3 baseline families even when no component references them', () => {
// The MD3 baseline colors (primary, secondary, tertiary, error, surface,
// background, outline) are part of the standard contract. A design system
// that ships them should not get warned for components that happen to
// not exercise the full palette in their canonical examples.
const state = buildState({
colors: {
primary: '#1A1C1E',
secondary: '#6C7278',
tertiary: '#B8422E',
error: '#B3261E',
'on-error': '#ffffff',
'error-container': '#F9DEDC',
background: '#fffbfe',
'on-background': '#1c1b1f',
outline: '#79747e',
'outline-variant': '#cac4d0',
},
components: {
button: { backgroundColor: '{colors.primary}' },
},
});
const findings = orphanedTokens(state);
expect(findings).toEqual([]);
});
});
@@ -0,0 +1,107 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../../model/spec.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
/**
* Reduce a Material Design 3 color token name to its family root.
*
* Strips MD3 prefixes (`on-`, `inverse-`) and suffixes (`-container*`,
* `-fixed*`, `-dim`, `-bright`, `-tint`, `-variant`). Tokens that don't match
* any MD3 pattern collapse to their own name, which means custom tokens like
* `brand-blue` keep getting flagged when truly unused.
*/
function colorFamily(name: string): string {
let n = name;
// Prefixes. `inverse-on-surface` needs both passes of `on-` removal.
n = n.replace(/^on-/, '');
n = n.replace(/^inverse-/, '');
n = n.replace(/^on-/, '');
// Suffixes. Order matters: `-container-low` must collapse before `-low`
// becomes a candidate suffix.
n = n.replace(/-container.*$/, '');
n = n.replace(/-fixed.*$/, '');
n = n.replace(/-(dim|bright|tint|variant)$/, '');
return n;
}
/**
* Material Design 3 baseline color families. Tokens belonging to these
* families are part of the MD3 standard contract and are never flagged as
* orphaned, even if a given component set doesn't reference them. Custom
* tokens (e.g. `brand-blue`, `accent-magenta`) still get flagged when unused.
*/
const MD3_STANDARD_FAMILIES = new Set([
'primary',
'secondary',
'tertiary',
'error',
'surface',
'background',
'outline',
]);
/**
* Orphaned tokens — tokens defined but never referenced by any component or
* any sibling token in the same MD3 family.
*/
export function orphanedTokens(state: DesignSystemState): RuleFinding[] {
if (state.components.size === 0) return [];
const referencedPaths = new Set<string>();
for (const [, comp] of state.components) {
for (const [, value] of comp.properties) {
if (typeof value === 'object' && value !== null && 'type' in value) {
for (const [key, symValue] of state.symbolTable) {
if (symValue === value) {
referencedPaths.add(key);
}
}
}
}
}
// A component referencing one MD3 token implies its semantic siblings are
// part of the same in-use group (e.g. `primary` brings `on-primary`,
// `primary-container`, `inverse-primary`, etc.). Compute the set of
// referenced families so siblings don't get flagged as orphaned.
const referencedFamilies = new Set<string>();
for (const path of referencedPaths) {
if (path.startsWith('colors.')) {
referencedFamilies.add(colorFamily(path.slice('colors.'.length)));
}
}
const findings: RuleFinding[] = [];
for (const [name] of state.colors) {
const path = `colors.${name}`;
if (referencedPaths.has(path)) continue;
const family = colorFamily(name);
if (referencedFamilies.has(family)) continue;
if (MD3_STANDARD_FAMILIES.has(family)) continue;
findings.push({
path,
message: `'${name}' is defined but never referenced by any component.`,
});
}
return findings;
}
export const orphanedTokensRule: RuleDescriptor = {
name: 'orphaned-tokens',
severity: 'warning',
description: 'Orphaned tokens — tokens defined but never referenced by any component.',
run: orphanedTokens,
};
@@ -0,0 +1,119 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { sectionOrder, resolveAlias, SECTION_ALIASES } from './section-order.js';
import type { DesignSystemState } from '../../model/spec.js';
describe('sectionOrder', () => {
it('should warn when sections are out of order', () => {
const state = {
sections: ['Colors', 'Overview'], // Out of order!
} as unknown as DesignSystemState;
const findings = sectionOrder(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toContain('out of order');
});
it('should not warn when sections are in order', () => {
const state = {
sections: ['Overview', 'Colors'], // In order!
} as unknown as DesignSystemState;
const findings = sectionOrder(state);
expect(findings.length).toBe(0);
});
it('should ignore unknown sections', () => {
const state = {
sections: ['Overview', 'Unknown', 'Colors'], // Unknown section in between
} as unknown as DesignSystemState;
const findings = sectionOrder(state);
expect(findings.length).toBe(0);
});
it('should resolve "Brand & Style" as "Overview"', () => {
const state = {
sections: ['Brand & Style', 'Colors', 'Typography'],
} as unknown as DesignSystemState;
const findings = sectionOrder(state);
expect(findings.length).toBe(0);
});
it('should resolve "Layout & Spacing" as "Layout"', () => {
const state = {
sections: ['Overview', 'Colors', 'Typography', 'Layout & Spacing'],
} as unknown as DesignSystemState;
const findings = sectionOrder(state);
expect(findings.length).toBe(0);
});
it('should resolve "Elevation" as "Elevation & Depth"', () => {
const state = {
sections: ['Layout', 'Elevation'],
} as unknown as DesignSystemState;
const findings = sectionOrder(state);
expect(findings.length).toBe(0);
});
it('should detect out-of-order aliased sections', () => {
const state = {
sections: ['Colors', 'Brand & Style'], // Out of order via alias!
} as unknown as DesignSystemState;
const findings = sectionOrder(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toContain('out of order');
});
it('should handle mixed aliases and canonical names', () => {
const state = {
sections: ['Brand & Style', 'Colors', 'Typography', 'Layout & Spacing', 'Elevation & Depth', 'Shapes', 'Components'],
} as unknown as DesignSystemState;
const findings = sectionOrder(state);
expect(findings.length).toBe(0);
});
});
describe('resolveAlias', () => {
it('should resolve known aliases', () => {
expect(resolveAlias('Brand & Style')).toBe('Overview');
expect(resolveAlias('Layout & Spacing')).toBe('Layout');
expect(resolveAlias('Elevation')).toBe('Elevation & Depth');
});
it('should pass through canonical names unchanged', () => {
expect(resolveAlias('Overview')).toBe('Overview');
expect(resolveAlias('Colors')).toBe('Colors');
expect(resolveAlias('Elevation & Depth')).toBe('Elevation & Depth');
});
it('should pass through unknown names unchanged', () => {
expect(resolveAlias('Iconography')).toBe('Iconography');
});
});
@@ -0,0 +1,66 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../../model/spec.js';
import {
CANONICAL_ORDER,
SECTION_ALIASES,
resolveAlias,
} from '../../spec-config.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
// Re-export for consumers
export { CANONICAL_ORDER, SECTION_ALIASES, resolveAlias };
const ORDER_MAP = new Map(CANONICAL_ORDER.map((s, i) => [s, i]));
export function sectionOrder(state: DesignSystemState): RuleFinding[] {
const findings: RuleFinding[] = [];
const sections = state.sections ?? [];
if (sections.length === 0) return findings;
// Resolve aliases, then filter to known sections for order checking
const knownSections = sections
.map(resolveAlias)
.filter(s => ORDER_MAP.has(s));
for (let i = 0; i < knownSections.length - 1; i++) {
const current = knownSections[i];
const next = knownSections[i + 1];
if (!current || !next) continue;
const currentIdx = ORDER_MAP.get(current);
const nextIdx = ORDER_MAP.get(next);
if (currentIdx === undefined || nextIdx === undefined) continue;
if (currentIdx > nextIdx) {
findings.push({
message: `Section '${current}' appears before '${next}', which is out of order. Expected order: ${CANONICAL_ORDER.join(', ')}`
});
break;
}
}
return findings;
}
export const sectionOrderRule: RuleDescriptor = {
name: 'section-order',
severity: 'warning',
description: 'Section order — warns when sections are out of canonical order.',
run: sectionOrder,
};
@@ -0,0 +1,36 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* Shared test helper for rule unit tests.
* Builds a DesignSystemState from parsed overrides, reusing the ModelHandler.
*/
import { ModelHandler } from '../../model/handler.js';
import type { ParsedDesignSystem } from '../../parser/spec.js';
import type { DesignSystemState } from '../../model/spec.js';
let modelHandler: ModelHandler | undefined;
export function buildState(overrides: Partial<ParsedDesignSystem> = {}): DesignSystemState {
if (!modelHandler) {
modelHandler = new ModelHandler();
}
const parsed: ParsedDesignSystem = { sourceMap: new Map(), ...overrides };
const result = modelHandler.execute(parsed);
const hasErrors = result.findings.some(d => d.severity === 'error');
if (hasErrors) {
throw new Error(`Model build failed: ${result.findings.map(d => d.message).join(', ')}`);
}
return result.designSystem;
}
@@ -0,0 +1,133 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { tokenLikeIgnored } from './token-like-ignored.js';
import { buildState } from './test-helpers.js';
import type { SourceLocation } from '../../parser/spec.js';
const loc: SourceLocation = { line: 1, column: 0, block: 'frontmatter' };
describe('tokenLikeIgnored', () => {
it('warns when an unknown key has hex color leaf values', () => {
const state = buildState({
sourceMap: new Map([['base_colors', loc]]),
rawValues: { base_colors: { ink: '#0B0F14', mist: '#F5F7FA' } },
});
const findings = tokenLikeIgnored(state);
expect(findings.length).toBe(1);
expect(findings[0]!.path).toBe('base_colors');
expect(findings[0]!.message).toContain('"base_colors"');
expect(findings[0]!.message).toContain('silently ignored by export');
});
it('warns when an unknown key has a fontFamily property', () => {
const state = buildState({
sourceMap: new Map([['brand_type', loc]]),
rawValues: {
brand_type: { heading: { fontFamily: 'Inter', fontSize: '32px' } },
},
});
const findings = tokenLikeIgnored(state);
expect(findings.length).toBe(1);
expect(findings[0]!.path).toBe('brand_type');
});
it('warns when an unknown key has CSS dimension leaf values', () => {
const state = buildState({
sourceMap: new Map([['semantic_spacing', loc]]),
rawValues: { semantic_spacing: { sm: '4px', md: '8px', lg: '16px' } },
});
const findings = tokenLikeIgnored(state);
expect(findings.length).toBe(1);
expect(findings[0]!.path).toBe('semantic_spacing');
});
it('stays silent when an unknown key has a flat string value (not a map)', () => {
const state = buildState({
sourceMap: new Map([['theme', loc]]),
rawValues: { theme: 'dark' },
});
expect(tokenLikeIgnored(state)).toEqual([]);
});
it('stays silent when an unknown key has a number value', () => {
const state = buildState({
sourceMap: new Map([['version_major', loc]]),
rawValues: { version_major: 2 },
});
expect(tokenLikeIgnored(state)).toEqual([]);
});
it('stays silent when an unknown key has a non-token-like object (no hex, no dimension, no typo prop)', () => {
const state = buildState({
sourceMap: new Map([['meta', loc]]),
rawValues: { meta: { author: 'Jane', created: '2026-01-01' } },
});
expect(tokenLikeIgnored(state)).toEqual([]);
});
it('stays silent for all recognized schema keys', () => {
const state = buildState({
sourceMap: new Map([
['version', loc],
['name', loc],
['colors', loc],
['typography', loc],
['spacing', loc],
['rounded', loc],
['components', loc],
]),
});
expect(tokenLikeIgnored(state)).toEqual([]);
});
it('emits one finding per token-like unknown key', () => {
const state = buildState({
sourceMap: new Map([
['base_colors', loc],
['semantic_colors', loc],
['meta', loc],
]),
rawValues: {
base_colors: { primary: '#1A73E8' },
semantic_colors: { success: '#34A853' },
meta: { owner: 'design-team' },
},
});
const findings = tokenLikeIgnored(state);
expect(findings.length).toBe(2);
expect(findings.map(f => f.path).sort()).toEqual(['base_colors', 'semantic_colors']);
});
it('warns on nested token maps', () => {
const state = buildState({
sourceMap: new Map([['palette', loc]]),
rawValues: {
palette: {
light: { brand: '#4A90E2', surface: '#FFFFFF' },
dark: { brand: '#82B1FF', surface: '#121212' },
},
},
});
const findings = tokenLikeIgnored(state);
expect(findings.length).toBe(1);
expect(findings[0]!.path).toBe('palette');
});
it('returns empty when there are no unknown keys', () => {
const state = buildState({});
expect(tokenLikeIgnored(state)).toEqual([]);
});
});
@@ -0,0 +1,108 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../../model/spec.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
/**
* CSS hex color pattern: #RGB, #RGBA, #RRGGBB, or #RRGGBBAA.
*/
const HEX_COLOR_RE = /^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
/**
* CSS dimension pattern: an optional sign, digits, and a CSS unit suffix.
*/
const CSS_DIMENSION_RE = /^-?\d*\.?\d+[a-zA-Z%]+$/;
/**
* Upper bound on a token-like leaf value's length before pattern matching.
* A hex color or CSS dimension is short; longer strings cannot match either,
* so capping the length avoids pathological regex backtracking on oversized
* attacker-supplied values.
*/
const MAX_TOKEN_VALUE_LENGTH = 64;
/**
* Typography-flavored property names that strongly suggest this map holds
* design tokens rather than arbitrary metadata.
*/
const TYPOGRAPHY_PROPS = new Set(['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing']);
/**
* Determine whether a plain object looks like a design-token map.
*
* A map is "token-like" when at least one leaf value is a hex color string or
* a CSS dimension string, OR at least one key is a well-known typography
* property name. Flat scalars (strings, numbers) and non-object values are
* never token-like on their own — the value must be an object.
*/
function isTokenLikeMap(value: unknown): boolean {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return false;
}
const obj = value as Record<string, unknown>;
return hasTokenLikeContent(obj);
}
function hasTokenLikeContent(obj: Record<string, unknown>): boolean {
for (const [key, val] of Object.entries(obj)) {
// Typography property key is itself a signal.
if (TYPOGRAPHY_PROPS.has(key)) return true;
if (typeof val === 'string') {
if (val.length <= MAX_TOKEN_VALUE_LENGTH && (HEX_COLOR_RE.test(val) || CSS_DIMENSION_RE.test(val))) return true;
} else if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
// Recurse one level for nested token maps (e.g. base_colors: { light: { ink: "#0B0F14" } })
if (hasTokenLikeContent(val as Record<string, unknown>)) return true;
}
}
return false;
}
/**
* Token-like ignored keys — warns when a top-level YAML key is not part of
* the recognized export schema and its value looks like a design-token map.
* These values will be silently dropped by `design.md export`.
*/
export function tokenLikeIgnored(state: DesignSystemState): RuleFinding[] {
const unknownKeys = state.unknownKeys ?? [];
const unknownKeyValues = state.unknownKeyValues ?? {};
const findings: RuleFinding[] = [];
for (const key of unknownKeys) {
const value = unknownKeyValues[key];
if (isTokenLikeMap(value)) {
findings.push({
path: key,
message:
`"${key}" looks like a design-token map but is not a recognized schema key ` +
`(colors, typography, spacing, rounded, components). ` +
`It will be silently ignored by export commands. ` +
`Rename it to a supported key or move its values under a recognized section.`,
});
}
}
return findings;
}
export const tokenLikeIgnoredRule: RuleDescriptor = {
name: 'token-like-ignored',
severity: 'warning',
description:
'Warns when a top-level YAML key looks like a design-token map but is not ' +
'part of the recognized export schema and will be silently ignored.',
run: tokenLikeIgnored,
};
@@ -0,0 +1,37 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { tokenSummary } from './token-summary.js';
import { buildState } from './test-helpers.js';
describe('tokenSummary', () => {
it('emits info diagnostic with token counts', () => {
const state = buildState({
colors: { primary: '#ff0000', secondary: '#00ff00' },
typography: { 'headline-lg': { fontFamily: 'Roboto', fontSize: '42px', fontWeight: 500 } },
rounded: { regular: '4px' },
spacing: { 'gutter-s': '8px' },
});
const findings = tokenSummary(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toMatch(/2 colors/);
expect(findings[0]!.message).toMatch(/1 typography/);
});
it('returns empty for completely empty state', () => {
const state = buildState({});
expect(tokenSummary(state)).toEqual([]);
});
});
@@ -0,0 +1,43 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../../model/spec.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
/**
* Token count summary — emits an info diagnostic summarizing how many
* tokens are defined in each section.
*/
export function tokenSummary(state: DesignSystemState): RuleFinding[] {
const parts: string[] = [];
if (state.colors.size > 0) parts.push(`${state.colors.size} color${state.colors.size !== 1 ? 's' : ''}`);
if (state.typography.size > 0) parts.push(`${state.typography.size} typography scale${state.typography.size !== 1 ? 's' : ''}`);
if (state.rounded.size > 0) parts.push(`${state.rounded.size} rounding level${state.rounded.size !== 1 ? 's' : ''}`);
if (state.spacing.size > 0) parts.push(`${state.spacing.size} spacing token${state.spacing.size !== 1 ? 's' : ''}`);
if (state.components.size > 0) parts.push(`${state.components.size} component${state.components.size !== 1 ? 's' : ''}`);
if (parts.length > 0) {
return [{
message: `Design system defines ${parts.join(', ')}.`,
}];
}
return [];
}
export const tokenSummaryRule: RuleDescriptor = {
name: 'token-summary',
severity: 'info',
description: 'Token count summary — emits an info diagnostic summarizing how many tokens are defined.',
run: tokenSummary,
};
@@ -0,0 +1,51 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import type { LintRule, RuleDescriptor } from './types.js';
import { DEFAULT_RULE_DESCRIPTORS } from './index.js';
describe('LintRule type', () => {
it('accepts a function that takes state and returns findings', () => {
const rule: LintRule = (_state) => [];
expect(rule({
colors: new Map(),
typography: new Map(),
rounded: new Map(),
spacing: new Map(),
components: new Map(),
symbolTable: new Map(),
})).toEqual([]);
});
it('accepts a RuleDescriptor object', () => {
const descriptor: RuleDescriptor = {
name: 'test-rule',
severity: 'info',
description: 'Test description',
run: (_state: any) => [],
};
expect(descriptor.name).toBe('test-rule');
});
it('has all rules in DEFAULT_RULE_DESCRIPTORS', () => {
expect(DEFAULT_RULE_DESCRIPTORS.length).toBe(10);
DEFAULT_RULE_DESCRIPTORS.forEach((rule: RuleDescriptor) => {
expect(rule.name).toBeTruthy();
expect(rule.severity).toBeTruthy();
expect(rule.description).toBeTruthy();
expect(rule.run).toBeTruthy();
});
});
});
@@ -0,0 +1,34 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../../model/spec.js';
import type { Finding, Severity } from '../spec.js';
/** A finding emitted by a rule — severity is injected by the descriptor. */
export interface RuleFinding {
path?: string;
message: string;
/** Optional override of the descriptor's default severity. */
severity?: Severity;
}
/** A pure lint rule: takes immutable state, returns findings. No side effects. */
export type LintRule = (state: DesignSystemState) => Finding[];
export interface RuleDescriptor {
name: string;
severity: Severity;
description: string;
run: (state: DesignSystemState) => RuleFinding[];
}
@@ -0,0 +1,122 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { unknownKey } from './unknown-key.js';
import { buildState } from './test-helpers.js';
import type { SourceLocation } from '../../parser/spec.js';
const loc: SourceLocation = { line: 1, column: 0, block: 'frontmatter' };
describe('unknownKey', () => {
it('warns and suggests "colors" for "colours" (distance 1)', () => {
const state = buildState({
sourceMap: new Map([
['name', loc],
['colours', loc],
]),
});
const findings = unknownKey(state);
expect(findings.length).toBe(1);
expect(findings[0]!.path).toBe('colours');
expect(findings[0]!.message).toBe('Unknown key "colours" — did you mean "colors"?');
});
it('warns and suggests "typography" for "typografy" (distance 2)', () => {
const state = buildState({
sourceMap: new Map([['typografy', loc]]),
});
const findings = unknownKey(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toBe('Unknown key "typografy" — did you mean "typography"?');
});
it('warns and suggests "name" for "nam" (distance 1)', () => {
const state = buildState({
sourceMap: new Map([['nam', loc]]),
});
const findings = unknownKey(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toBe('Unknown key "nam" — did you mean "name"?');
});
it('matches case-insensitively (e.g. "Colors" is treated as known)', () => {
const state = buildState({
sourceMap: new Map([['Colors', loc]]),
});
const findings = unknownKey(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toBe('Unknown key "Colors" — did you mean "colors"?');
});
it('stays silent for far-from-any-key extension keys', () => {
const state = buildState({
sourceMap: new Map([
['icons', loc],
['motion', loc],
['brand', loc],
]),
});
expect(unknownKey(state)).toEqual([]);
});
it('stays silent for "rounding" (distance 3 from "rounded")', () => {
const state = buildState({
sourceMap: new Map([['rounding', loc]]),
});
expect(unknownKey(state)).toEqual([]);
});
it('returns empty when all top-level keys are known', () => {
const state = buildState({
sourceMap: new Map([
['version', loc],
['name', loc],
['description', loc],
['colors', loc],
['typography', loc],
['rounded', loc],
['spacing', loc],
['components', loc],
]),
});
expect(unknownKey(state)).toEqual([]);
});
it('returns empty when there are no top-level keys', () => {
const state = buildState({});
expect(unknownKey(state)).toEqual([]);
});
it('emits one finding per misspelled key and ignores unrelated extension keys', () => {
const state = buildState({
sourceMap: new Map([
['colors', loc],
['colours', loc],
['typografy', loc],
['icons', loc],
]),
});
const findings = unknownKey(state);
expect(findings.map(f => f.path).sort()).toEqual(['colours', 'typografy']);
});
it('stays silent (and cheap) for very long keys via the length short-circuit', () => {
const state = buildState({
sourceMap: new Map([['a'.repeat(50000), loc]]),
});
const findings = unknownKey(state);
expect(findings.length).toBe(0);
});
});
@@ -0,0 +1,65 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { SCHEMA_KEYS } from '../../parser/spec.js';
import type { DesignSystemState } from '../../model/spec.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
import { levenshtein } from './levenshtein.js';
/** Max edit distance to consider a typo (not a custom key). */
const MAX_TYPO_DISTANCE = 2;
/**
* Unknown key — warns when a top-level YAML key looks like a typo of a known
* schema key. The DESIGN.md schema is intentionally extensible (custom keys
* are allowed), so only close matches to known keys are reported; unrelated
* extension keys stay silent.
*/
export function unknownKey(state: DesignSystemState): RuleFinding[] {
const knownSet = new Set<string>(SCHEMA_KEYS);
return (state.unknownKeys ?? []).flatMap(key => {
if (knownSet.has(key)) return [];
let bestMatch: string | undefined;
let bestDist = Infinity;
for (const known of SCHEMA_KEYS) {
// Edit distance is at least the length difference, so a key whose length
// differs from a known key by more than the typo threshold can never be a
// typo — skip the O(n*m) distance computation for it. This keeps the rule
// cheap on long, attacker-supplied keys without changing any result.
if (Math.abs(key.length - known.length) > MAX_TYPO_DISTANCE) continue;
const dist = levenshtein(key.toLowerCase(), known.toLowerCase());
if (dist < bestDist) {
bestDist = dist;
bestMatch = known;
}
}
if (bestDist <= MAX_TYPO_DISTANCE && bestMatch) {
return [{
path: key,
message: `Unknown key "${key}" — did you mean "${bestMatch}"?`,
}];
}
return [];
});
}
export const unknownKeyRule: RuleDescriptor = {
name: 'unknown-key',
severity: 'warning',
description: 'Unknown key — warns when a top-level YAML key looks like a typo of a known schema key.',
run: unknownKey,
};
@@ -0,0 +1,94 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { runLinter, preEvaluate } from './runner.js';
import { missingPrimaryRule } from './rules/missing-primary.js';
import { tokenSummaryRule } from './rules/token-summary.js';
import { ModelHandler } from '../model/handler.js';
import type { ParsedDesignSystem } from '../parser/spec.js';
import type { DesignSystemState } from '../model/spec.js';
const modelHandler = new ModelHandler();
function buildState(overrides: Partial<ParsedDesignSystem> = {}): DesignSystemState {
const parsed: ParsedDesignSystem = { sourceMap: new Map(), ...overrides };
const result = modelHandler.execute(parsed);
const hasErrors = result.findings.some(d => d.severity === 'error');
if (hasErrors) {
throw new Error(`Model build failed: ${result.findings.map(d => d.message).join(', ')}`);
}
return result.designSystem;
}
describe('runLinter', () => {
it('runs default rules when none specified', () => {
const state = buildState({ colors: { accent: '#ff0000' } });
const result = runLinter(state);
// Should have at least a warning (missing primary) and an info (summary)
expect(result.summary.warnings).toBeGreaterThan(0);
expect(result.summary.infos).toBeGreaterThan(0);
});
it('runs only the specified subset of rules', () => {
const state = buildState({ colors: { accent: '#ff0000' } });
const result = runLinter(state, [missingPrimaryRule]);
// Only the missing primary warning — no summary info
expect(result.findings.length).toBe(1);
expect(result.findings[0]!.message).toMatch(/primary/);
expect(result.summary.warnings).toBe(1);
expect(result.summary.infos).toBe(0);
});
it('returns empty findings for empty rules array', () => {
const state = buildState({ colors: { accent: '#ff0000' } });
const result = runLinter(state, []);
expect(result.findings).toEqual([]);
expect(result.summary).toEqual({ errors: 0, warnings: 0, infos: 0 });
});
});
describe('preEvaluate', () => {
it('groups findings into fixes, improvements, and suggestions', () => {
const state = buildState({
colors: {
primary: '#647D66',
secondary: '#ffff00',
white: '#ffffff',
},
components: {
'button-bad': {
backgroundColor: '{colors.secondary}',
textColor: '{colors.white}',
},
'button-broken': {
backgroundColor: '{colors.nonexistent}',
textColor: '{colors.white}',
}
},
});
const graded = preEvaluate(state);
expect(graded.fixes.length).toBeGreaterThan(0); // error: broken ref
expect(graded.improvements.length).toBeGreaterThan(0); // warning: contrast
expect(graded.suggestions.length).toBeGreaterThan(0); // info: summary
});
it('accepts custom rules', () => {
const state = buildState({ colors: { accent: '#ff0000' } });
const graded = preEvaluate(state, [tokenSummaryRule]);
expect(graded.fixes).toEqual([]);
expect(graded.improvements).toEqual([]);
expect(graded.suggestions.length).toBe(1);
});
});
+70
View File
@@ -0,0 +1,70 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../model/spec.js';
import type { LintResult, Finding, GradedTokenEdits, TokenEditEntry } from './spec.js';
import type { LintRule, RuleDescriptor } from './rules/types.js';
import { DEFAULT_RULES, DEFAULT_RULE_DESCRIPTORS } from './rules/index.js';
/** Type guard: checks if the array contains RuleDescriptors (objects with `run`). */
function isDescriptorArray(rules: LintRule[] | RuleDescriptor[]): rules is RuleDescriptor[] {
return rules.length > 0 && typeof rules[0] === 'object' && 'run' in rules[0];
}
/**
* Pure functional linter runner.
* Executes each rule against the state and aggregates findings.
*/
export function runLinter(
state: DesignSystemState,
rules: LintRule[] | RuleDescriptor[] = DEFAULT_RULES,
): LintResult {
const findings: Finding[] = isDescriptorArray(rules)
? rules.flatMap(desc => desc.run(state).map(f => ({
severity: f.severity ?? desc.severity,
path: f.path,
message: f.message,
})))
: rules.flatMap(rule => rule(state));
return {
findings,
summary: {
errors: findings.filter(d => d.severity === 'error').length,
warnings: findings.filter(d => d.severity === 'warning').length,
infos: findings.filter(d => d.severity === 'info').length,
},
};
}
/**
* Groups lint findings into a graded edit menu (fixes / improvements / suggestions).
*/
export function preEvaluate(
state: DesignSystemState,
rules: LintRule[] | RuleDescriptor[] = DEFAULT_RULES,
): GradedTokenEdits {
const { findings } = runLinter(state, rules);
const fixes: TokenEditEntry[] = [];
const improvements: TokenEditEntry[] = [];
const suggestions: TokenEditEntry[] = [];
for (const d of findings) {
const entry: TokenEditEntry = { path: d.path ?? '', findings: [d] };
if (d.severity === 'error') fixes.push(entry);
else if (d.severity === 'warning') improvements.push(entry);
else suggestions.push(entry);
}
return { fixes, improvements, suggestions };
}
+52
View File
@@ -0,0 +1,52 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../model/spec.js';
export type { Finding, Severity } from '../model/spec.js';
import type { Finding } from '../model/spec.js';
// ── LINT RESULT ────────────────────────────────────────────────────
export interface LintResult {
findings: Finding[];
summary: {
errors: number;
warnings: number;
infos: number;
};
}
// ── GRADED TOKEN EDITS ─────────────────────────────────────────────
export interface GradedTokenEdits {
/** Edits that fix errors (highest priority) */
fixes: TokenEditEntry[];
/** Edits that resolve warnings */
improvements: TokenEditEntry[];
/** Edits that are purely additive / informational */
suggestions: TokenEditEntry[];
}
export interface TokenEditEntry {
path: string;
currentValue?: string;
suggestedValue?: string;
findings: Finding[];
}
// ── INTERFACE ──────────────────────────────────────────────────────
export interface LinterSpec {
lint(state: DesignSystemState): LintResult;
preEvaluate(state: DesignSystemState): GradedTokenEdits;
}
@@ -0,0 +1,595 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
export interface ParsedColorResult {
hex: string;
r: number;
g: number;
b: number;
a?: number;
luminance: number;
}
const CSS_NAMED_COLORS: Record<string, string> = {
aliceblue: '#f0f8ff', antiquewhite: '#faebd7', aqua: '#00ffff', aquamarine: '#7fffd4', azure: '#f0ffff',
beige: '#f5f5dc', bisque: '#ffe4c4', black: '#000000', blanchedalmond: '#ffebcd', blue: '#0000ff',
blueviolet: '#8a2be2', brown: '#a52a2a', burlywood: '#deb887', cadetblue: '#5f9ea0', chartreuse: '#7fff00',
chocolate: '#d2691e', coral: '#ff7f50', cornflowerblue: '#6495ed', cornsilk: '#fff8dc', crimson: '#dc143c',
cyan: '#00ffff', darkblue: '#00008b', darkcyan: '#008b8b', darkgoldenrod: '#b8860b', darkgray: '#a9a9a9',
darkgrey: '#a9a9a9', darkgreen: '#006400', darkkhaki: '#bdb76b', darkmagenta: '#8b008b', darkolivegreen: '#556b2f',
darkorange: '#ff8c00', darkorchid: '#9932cc', darkred: '#8b0000', darksalmon: '#e9967a', darkseagreen: '#8fbc8f',
darkslateblue: '#483d8b', darkslategrey: '#2f4f4f', darkslategray: '#2f4f4f', darkturquoise: '#00ced1',
darkviolet: '#9400d3', deeppink: '#ff1493', deepskyblue: '#00bfff', dimgray: '#696969', dimgrey: '#696969',
dodgerblue: '#1e90ff', firebrick: '#b22222', floralwhite: '#fffaf0', forestgreen: '#228b22', fuchsia: '#ff00ff',
gainsboro: '#dcdcdc', ghostwhite: '#f8f8ff', gold: '#ffd700', goldenrod: '#daa520', gray: '#808080',
grey: '#808080', green: '#008000', greenyellow: '#adff2f', honeydew: '#f0fff0', hotpink: '#ff69b4',
indianred: '#cd5c5c', indigo: '#4b0082', ivory: '#fffff0', khaki: '#f0e68c', lavender: '#e6e6fa',
lavenderblush: '#fff0f5', lawngreen: '#7cfc00', lemonchiffon: '#fffacd', lightblue: '#add8e6', lightcoral: '#f08080',
lightcyan: '#e0ffff', lightgoldenrodyellow: '#fafad2', lightgray: '#d3d3d3', lightgrey: '#d3d3d3', lightgreen: '#90ee90',
lightpink: '#ffb6c1', lightsalmon: '#ffa07a', lightseagreen: '#20b2aa', lightskyblue: '#87cefa', lightslate: '#778899',
lightslategray: '#778899', lightslategrey: '#778899', lightsteelblue: '#b0c4de', lightyellow: '#ffffe0', lime: '#00ff00',
limegreen: '#32cd32', linen: '#faf0e6', magenta: '#ff00ff', maroon: '#800000',
mediumaquamarine: '#66cdaa', mediumblue: '#0000cd', mediumorchid: '#ba55d3', mediumpurple: '#9370db', mediumseagreen: '#3cb371',
mediumslateblue: '#7b68ee', mediumspringgreen: '#00fa9a', mediumturquoise: '#48d1cc', mediumvioletred: '#c71585', midnightblue: '#191970',
mintcream: '#f5fffa', mistyrose: '#ffe4e1', moccasin: '#ffe4b5', navajowhite: '#ffdead', navy: '#000080',
oldlace: '#fdf5e6', olive: '#808000', olivedrab: '#6b8e23', orange: '#ffa500', orangered: '#ff4500',
orchid: '#da70d6', palegoldenrod: '#eee8aa', palegreen: '#98fb98', paleturquoise: '#afeeee', palevioletred: '#db7093',
papayawhip: '#ffefd5', peachpuff: '#ffdab9', peru: '#cd853f', pink: '#ffc0cb', plum: '#dda0dd',
powderblue: '#b0e0e6', purple: '#800080', rebeccapurple: '#663399', red: '#ff0000', rosybrown: '#bc8f8f',
royalblue: '#4169e1', saddlebrown: '#8b4513', salmon: '#fa8072', sandybrown: '#f4a460', seagreen: '#2e8b57',
seashell: '#fff5ee', sienna: '#a0522d', silver: '#c0c0c0', skyblue: '#87ceeb', slateblue: '#6a5acd',
slategray: '#708090', slategrey: '#708090', snow: '#fffafa', springgreen: '#00ff7f', steelblue: '#4682b4',
tan: '#d2b48c', teal: '#008080', thistle: '#d8bfd8', tomato: '#ff6347', turquoise: '#40e0d0',
violet: '#ee82ee', wheat: '#f5deb3', white: '#ffffff', whitesmoke: '#f5f5f5', yellow: '#ffff00',
yellowgreen: '#9acd32', transparent: '#00000000',
};
/**
* Maximum nesting depth for recursive color-mix() resolution. Guards against
* stack exhaustion from pathologically nested, attacker-supplied color values.
*/
const MAX_COLOR_MIX_DEPTH = 32;
/**
* Parse a CSS color string into its sRGB representation + WCAG relative luminance.
* Returns null if the color is invalid.
*/
export function parseCssColor(colorStr: string, depth = 0): ParsedColorResult | null {
if (typeof colorStr !== 'string') return null;
if (depth > MAX_COLOR_MIX_DEPTH) return null;
const clean = colorStr.trim().toLowerCase();
if (!clean) return null;
// 1. Hex Color Pattern
if (clean.startsWith('#')) {
if (!/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(clean)) {
return null;
}
return parseHex(clean);
}
// 2. Named Colors lookup
if (Object.prototype.hasOwnProperty.call(CSS_NAMED_COLORS, clean)) {
return parseHex(CSS_NAMED_COLORS[clean]!);
}
// 3. Functional notations parse
const parsedFunc = tokenizeFunc(clean);
if (!parsedFunc) {
return null;
}
const { name, args } = parsedFunc;
switch (name) {
case 'rgb':
case 'rgba': {
// Supports rgb(255, 0, 0) and rgb(255 0 0 / 0.5)
// args could be: [r, g, b] or [r, g, b, a]
if (args.length !== 3 && args.length !== 4) return null;
const rRaw = parsePercentOrNumber(args[0]!, 255);
const gRaw = parsePercentOrNumber(args[1]!, 255);
const bRaw = parsePercentOrNumber(args[2]!, 255);
const aVal = args.length === 4 ? parseAlpha(args[3]) : 1;
if (isNaN(rRaw) || isNaN(gRaw) || isNaN(bRaw) || isNaN(aVal)) return null;
const r = Math.max(0, Math.min(255, Math.round(rRaw)));
const g = Math.max(0, Math.min(255, Math.round(gRaw)));
const b = Math.max(0, Math.min(255, Math.round(bRaw)));
const a = Math.max(0, Math.min(1, aVal));
return makeResult(r, g, b, a);
}
case 'hsl':
case 'hsla': {
// Supports hsl(120, 100%, 50%) and hsl(120deg 100% 50% / 0.5)
if (args.length !== 3 && args.length !== 4) return null;
const h = parseHue(args[0]!);
const s = parsePercentOrNumber(args[1]!, 1);
const l = parsePercentOrNumber(args[2]!, 1);
const a = args.length === 4 ? parseAlpha(args[3]) : 1;
if (isNaN(h) || isNaN(s) || isNaN(l) || isNaN(a)) return null;
const rgb = hslToRgb(h, s, l);
return makeResult(rgb.r, rgb.g, rgb.b, a);
}
case 'hwb': {
// Supports hwb(120 0% 0% / 0.5)
if (args.length !== 3 && args.length !== 4) return null;
const h = parseHue(args[0]!);
const w = parsePercentOrNumber(args[1]!, 1);
const b = parsePercentOrNumber(args[2]!, 1);
const a = args.length === 4 ? parseAlpha(args[3]) : 1;
if (isNaN(h) || isNaN(w) || isNaN(b) || isNaN(a)) return null;
const rgb = hwbToRgb(h, w, b);
return makeResult(rgb.r, rgb.g, rgb.b, a);
}
case 'lab': {
// lab(l a b / alpha)
if (args.length !== 3 && args.length !== 4) return null;
const l = parsePercentOrNumber(args[0]!, 100); // L is typically 0-100
const aVal = parseFloat(args[1]!);
const bVal = parseFloat(args[2]!);
const a = args.length === 4 ? parseAlpha(args[3]) : 1;
if (isNaN(l) || isNaN(aVal) || isNaN(bVal) || isNaN(a)) return null;
const rgb = labToRgb(l, aVal, bVal);
return makeResult(rgb.r, rgb.g, rgb.b, a);
}
case 'lch': {
// lch(l c h / alpha)
if (args.length !== 3 && args.length !== 4) return null;
const l = parsePercentOrNumber(args[0]!, 100);
const c = parseFloat(args[1]!);
const h = parseHue(args[2]!);
const a = args.length === 4 ? parseAlpha(args[3]) : 1;
if (isNaN(l) || isNaN(c) || isNaN(h) || isNaN(a)) return null;
const rgb = lchToRgb(l, c, h);
return makeResult(rgb.r, rgb.g, rgb.b, a);
}
case 'oklab': {
// oklab(l a b / alpha) - L is 0-1 or 0%-100%
if (args.length !== 3 && args.length !== 4) return null;
const l = parsePercentOrNumber(args[0]!, 1);
const aVal = parseFloat(args[1]!);
const bVal = parseFloat(args[2]!);
const a = args.length === 4 ? parseAlpha(args[3]) : 1;
if (isNaN(l) || isNaN(aVal) || isNaN(bVal) || isNaN(a)) return null;
const rgb = oklabToRgb(l, aVal, bVal);
return makeResult(rgb.r, rgb.g, rgb.b, a);
}
case 'oklch': {
// oklch(l c h / alpha)
if (args.length !== 3 && args.length !== 4) return null;
const l = parsePercentOrNumber(args[0]!, 1);
const c = parseFloat(args[1]!);
const h = parseHue(args[2]!);
const a = args.length === 4 ? parseAlpha(args[3]) : 1;
if (isNaN(l) || isNaN(c) || isNaN(h) || isNaN(a)) return null;
const rgb = oklchToRgb(l, c, h);
return makeResult(rgb.r, rgb.g, rgb.b, a);
}
case 'color-mix': {
// color-mix(in srgb, color1 percentage, color2 percentage)
// Let's split the inner tokens by comma at depth 0
const subArgs = splitByComma(clean.slice(10, -1));
if (subArgs.length !== 3) return null;
// SubArg 0: color space (e.g. "in srgb")
const spaceTokens = subArgs[0]!.trim().split(/\s+/);
if (spaceTokens.length !== 2 || spaceTokens[0] !== 'in' || spaceTokens[1] !== 'srgb') {
return null; // We only support "in srgb" blending standard
}
// SubArg 1: "<color1> [weight1]"
// SubArg 2: "<color2> [weight2]"
const parsed1 = parseColorWithWeight(subArgs[1]!);
const parsed2 = parseColorWithWeight(subArgs[2]!);
if (!parsed1 || !parsed2) return null;
const c1 = parseCssColor(parsed1.colorStr, depth + 1);
const c2 = parseCssColor(parsed2.colorStr, depth + 1);
if (!c1 || !c2) return null;
// Normalize weights
let w1 = parsed1.weight;
let w2 = parsed2.weight;
if (w1 === null && w2 === null) {
w1 = 50;
w2 = 50;
} else if (w1 !== null && w2 === null) {
w2 = 100 - w1;
} else if (w2 !== null && w1 === null) {
w1 = 100 - w2;
} else if (w1 !== null && w2 !== null) {
const sum = w1 + w2;
if (sum === 0) return null;
// Scale to sum to 100
w1 = (w1 / sum) * 100;
w2 = (w2 / sum) * 100;
}
// Convert weight to 0-1 range
const f1 = w1! / 100;
const f2 = w2! / 100;
// Blend components with premultiplied alpha
const a1 = c1.a !== undefined ? c1.a : 1;
const a2 = c2.a !== undefined ? c2.a : 1;
const aMix = a1 * f1 + a2 * f2;
let r = 0, g = 0, b = 0;
if (aMix > 0) {
r = Math.round((c1.r * a1 * f1 + c2.r * a2 * f2) / aMix);
g = Math.round((c1.g * a1 * f1 + c2.g * a2 * f2) / aMix);
b = Math.round((c1.b * a1 * f1 + c2.b * a2 * f2) / aMix);
}
return makeResult(
Math.max(0, Math.min(255, r)),
Math.max(0, Math.min(255, g)),
Math.max(0, Math.min(255, b)),
Math.max(0, Math.min(1, aMix))
);
}
default:
return null;
}
}
// ── Parsing internal utilities ─────────────────────────────────────
function parseHex(hexStr: string): ParsedColorResult {
let hex = hexStr;
if (hex.length === 4) {
hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
} else if (hex.length === 5) {
hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}${hex[4]}${hex[4]}`;
}
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
let a: number | undefined;
if (hex.length === 9) {
a = parseInt(hex.slice(7, 9), 16) / 255;
}
return makeResult(r, g, b, a);
}
function makeResult(r: number, g: number, b: number, a?: number): ParsedColorResult {
// Compute hex string
const hexR = r.toString(16).padStart(2, '0');
const hexG = g.toString(16).padStart(2, '0');
const hexB = b.toString(16).padStart(2, '0');
let hex = `#${hexR}${hexG}${hexB}`;
if (a !== undefined && a < 1) {
const hexA = Math.round(a * 255).toString(16).padStart(2, '0');
hex += hexA;
}
const luminance = computeLuminance(r, g, b);
return { hex, r, g, b, a, luminance };
}
function computeLuminance(r: number, g: number, b: number): number {
const linearize = (c: number) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
};
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
}
function parseHue(s: string): number {
const lower = s.trim().toLowerCase();
let val = 0;
if (lower.endsWith('deg')) {
val = parseFloat(lower.slice(0, -3));
} else if (lower.endsWith('grad')) {
val = (parseFloat(lower.slice(0, -4)) * 360) / 400;
} else if (lower.endsWith('rad')) {
val = (parseFloat(lower.slice(0, -3)) * 180) / Math.PI;
} else if (lower.endsWith('turn')) {
val = parseFloat(lower.slice(0, -4)) * 360;
} else {
val = parseFloat(lower);
}
val = val % 360;
if (val < 0) val += 360;
return val;
}
function parsePercentOrNumber(s: string, refMax: number): number {
const trim = s.trim();
if (trim.endsWith('%')) {
return (parseFloat(trim.slice(0, -1)) / 100) * refMax;
}
return parseFloat(trim);
}
function parseAlpha(s: string | undefined): number {
if (s === undefined) return 1;
const trim = s.trim();
if (trim.endsWith('%')) {
return parseFloat(trim.slice(0, -1)) / 100;
}
return parseFloat(trim);
}
function tokenizeFunc(str: string): { name: string; args: string[] } | null {
const match = str.trim().match(/^([a-z-]{3,15})\((.*)\)$/i);
if (!match) return null;
const name = match[1]!.toLowerCase();
const inner = match[2]!.trim();
let coordStr = inner;
let alphaStr: string | undefined = undefined;
// Parenthesis-aware search for the first depth-0 '/' division
let slantIndex = -1;
let depth = 0;
for (let i = 0; i < inner.length; i++) {
const char = inner[i];
if (char === '(') depth++;
else if (char === ')') depth--;
else if (depth === 0 && char === '/') {
slantIndex = i;
break;
}
}
if (slantIndex !== -1) {
coordStr = inner.slice(0, slantIndex).trim();
alphaStr = inner.slice(slantIndex + 1).trim();
}
// Split coordinate parameters
const args = splitList(coordStr);
if (alphaStr !== undefined) {
args.push(alphaStr);
}
return { name, args };
}
/**
* Split a space/comma separated parameter coordinates list, ignoring nested parens
*/
function splitList(str: string): string[] {
const results: string[] = [];
let current = '';
let depth = 0;
for (let i = 0; i < str.length; i++) {
const char = str[i];
if (char === '(') {
depth++;
current += char;
} else if (char === ')') {
depth--;
current += char;
} else if (depth === 0 && (char === ',' || /\s/.test(char))) {
if (current.trim()) {
results.push(current.trim());
current = '';
}
} else {
current += char;
}
}
if (current.trim()) {
results.push(current.trim());
}
return results;
}
function splitByComma(str: string): string[] {
const results: string[] = [];
let current = '';
let depth = 0;
for (let i = 0; i < str.length; i++) {
const char = str[i];
if (char === '(') {
depth++;
current += char;
} else if (char === ')') {
depth--;
current += char;
} else if (depth === 0 && char === ',') {
results.push(current.trim());
current = '';
} else {
current += char;
}
}
if (current.trim()) {
results.push(current.trim());
}
return results;
}
function parseColorWithWeight(subArg: string): { colorStr: string; weight: number | null } | null {
// This parses strings like "red 20%" or "20% red" or "rgb(255, 0, 0)"
const parts = splitList(subArg.trim());
if (parts.length === 0 || parts.length > 2) return null;
if (parts.length === 1) {
return { colorStr: parts[0]!, weight: null };
}
// CSS color-mix weights are percentages only; a bare number is not a valid weight.
const p0 = parts[0]!;
const p1 = parts[1]!;
const isP0Weight = p0.endsWith('%');
const isP1Weight = p1.endsWith('%');
if (isP0Weight && !isP1Weight) {
return { colorStr: p1, weight: parseFloat(p0.slice(0, -1)) };
} else if (isP1Weight && !isP0Weight) {
return { colorStr: p0, weight: parseFloat(p1.slice(0, -1)) };
}
return null;
}
// ── Chromatic conversions logic module ────────────────────────────
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r_ = 0, g_ = 0, b_ = 0;
if (h < 60) {
r_ = c; g_ = x; b_ = 0;
} else if (h < 120) {
r_ = x; g_ = c; b_ = 0;
} else if (h < 180) {
r_ = 0; g_ = c; b_ = x;
} else if (h < 240) {
r_ = 0; g_ = x; b_ = c;
} else if (h < 300) {
r_ = x; g_ = 0; b_ = c;
} else {
r_ = c; g_ = 0; b_ = x;
}
return {
r: Math.max(0, Math.min(255, Math.round((r_ + m) * 255))),
g: Math.max(0, Math.min(255, Math.round((g_ + m) * 255))),
b: Math.max(0, Math.min(255, Math.round((b_ + m) * 255))),
};
}
function hwbToRgb(h: number, w: number, b: number): { r: number; g: number; b: number } {
if (w + b >= 1) {
const sum = w + b;
const val = Math.max(0, Math.min(255, Math.round((w / sum) * 255)));
return { r: val, g: val, b: val };
}
const pure = hslToRgb(h, 1, 0.5);
const r = Math.round((pure.r / 255) * (1 - w - b) * 255 + w * 255);
const g = Math.round((pure.g / 255) * (1 - w - b) * 255 + w * 255);
const bVal = Math.round((pure.b / 255) * (1 - w - b) * 255 + w * 255);
return {
r: Math.max(0, Math.min(255, r)),
g: Math.max(0, Math.min(255, g)),
b: Math.max(0, Math.min(255, bVal)),
};
}
function labToRgb(l: number, a: number, b: number): { r: number; g: number; b: number } {
// Convert Lab to D50 XYZ
const fy = (l + 16) / 116;
const fx = a / 500 + fy;
const fz = fy - b / 200;
const e = 216 / 24389;
const k = 24389 / 27;
const fx3 = fx * fx * fx;
const fz3 = fz * fz * fz;
const xr = fx3 > e ? fx3 : (116 * fx - 16) / k;
const yr = l > k * e ? fy * fy * fy : l / k;
const zr = fz3 > e ? fz3 : (116 * fz - 16) / k;
// D50 White point
const Xn = 0.96422;
const Yn = 1.0;
const Zn = 0.82521;
const x = xr * Xn;
const y = yr * Yn;
const z = zr * Zn;
// Convert D50 XYZ to D65 XYZ via Bradford chromatic adaptation
const x65 = 0.9555726312052288 * x - 0.02303316850884054 * y + 0.06316100215997244 * z;
const y65 = -0.02828971739420664 * x + 1.0099416310812543 * y + 0.021007716449297163 * z;
const z65 = 0.012298224741016325 * x - 0.02048298287477757 * y + 1.3299098463422234 * z;
// Convert D65 XYZ to Linear sRGB
const r_lin = 3.2404542 * x65 - 1.5371385 * y65 - 0.4985314 * z65;
const g_lin = -0.9692660 * x65 + 1.8760108 * y65 + 0.0415560 * z65;
const b_lin = 0.0556434 * x65 - 0.2040259 * y65 + 1.0572252 * z65;
// Convert Linear sRGB to sRGB via standard gamma correction
const gamma = (val: number) => {
return val <= 0.0031308 ? 12.92 * val : 1.055 * Math.pow(val, 1 / 2.4) - 0.055;
};
return {
r: Math.max(0, Math.min(255, Math.round(gamma(r_lin) * 255))),
g: Math.max(0, Math.min(255, Math.round(gamma(g_lin) * 255))),
b: Math.max(0, Math.min(255, Math.round(gamma(b_lin) * 255))),
};
}
function lchToRgb(l: number, c: number, h: number): { r: number; g: number; b: number } {
const hRad = (h * Math.PI) / 180;
const a = c * Math.cos(hRad);
const b = c * Math.sin(hRad);
return labToRgb(l, a, b);
}
function oklabToRgb(l: number, a: number, b: number): { r: number; g: number; b: number } {
const l_ = l + 0.3963377774 * a + 0.2158037573 * b;
const m_ = l - 0.1055613458 * a - 0.0638541728 * b;
const s_ = l - 0.0894841775 * a - 1.2914855480 * b;
const l3 = l_ * l_ * l_;
const m3 = m_ * m_ * m_;
const s3 = s_ * s_ * s_;
const r_lin = +4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3;
const g_lin = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3;
const b_lin = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3;
const gamma = (val: number) => {
return val <= 0.0031308 ? 12.92 * val : 1.055 * Math.pow(val, 1 / 2.4) - 0.055;
};
return {
r: Math.max(0, Math.min(255, Math.round(gamma(r_lin) * 255))),
g: Math.max(0, Math.min(255, Math.round(gamma(g_lin) * 255))),
b: Math.max(0, Math.min(255, Math.round(gamma(b_lin) * 255))),
};
}
function oklchToRgb(l: number, c: number, h: number): { r: number; g: number; b: number } {
const hRad = (h * Math.PI) / 180;
const a = c * Math.cos(hRad);
const b = c * Math.sin(hRad);
return oklabToRgb(l, a, b);
}
@@ -0,0 +1,703 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { ModelHandler, contrastRatio } from './handler.js';
import type { ParsedDesignSystem } from '../parser/spec.js';
const handler = new ModelHandler();
function makeParsed(overrides: Partial<ParsedDesignSystem> = {}): ParsedDesignSystem {
return {
sourceMap: new Map(),
...overrides,
};
}
describe('ModelHandler', () => {
// ── Cycle 9: Build symbol table from parsed colors ────────────────
describe('symbol table from colors', () => {
it('resolves valid hex colors into the symbol table', () => {
const result = handler.execute(makeParsed({
colors: { primary: '#647D66', secondary: '#ff0000' },
}));
const primary = result.designSystem.symbolTable.get('colors.primary');
expect(primary).toBeDefined();
expect(typeof primary === 'object' && primary !== null && 'type' in primary && primary.type === 'color').toBe(true);
if (typeof primary === 'object' && primary !== null && 'hex' in primary) {
expect(primary.hex).toBe('#647d66');
}
expect(result.designSystem.colors.size).toBe(2);
});
it('emits diagnostic for invalid color format', () => {
const result = handler.execute(makeParsed({
colors: { primary: 'invalid-color' },
}));
expect(result.findings.length).toBe(1);
expect(result.findings[0]!.path).toBe('colors.primary');
expect(result.findings[0]!.severity).toBe('error');
});
it('normalizes #RGB shorthand to #RRGGBB', () => {
const result = handler.execute(makeParsed({
colors: { accent: '#abc' },
}));
const accent = result.designSystem.colors.get('accent');
expect(accent?.hex).toBe('#aabbcc');
});
it('normalizes #RGBA shorthand to #RRGGBBAA and extracts alpha', () => {
const result = handler.execute(makeParsed({
colors: { transparent: '#abc0' },
}));
const transparent = result.designSystem.colors.get('transparent');
expect(transparent?.hex).toBe('#aabbcc00');
expect(transparent?.a).toBe(0);
});
it('accepts 8-digit hex colors and extracts alpha', () => {
const result = handler.execute(makeParsed({
colors: { semitransparent: '#FFFFFFA6' },
}));
const semitransparent = result.designSystem.colors.get('semitransparent');
expect(semitransparent?.hex).toBe('#ffffffa6');
expect(semitransparent?.a).toBeCloseTo(166 / 255, 5);
});
it('successfully parses nested color declarations (Issue #102)', () => {
const result = handler.execute(makeParsed({
colors: {
background: {
light: '#fbfaf1',
dark: '#11140e'
}
}
}));
expect(result.findings.filter(f => f.severity === 'error').length).toBe(0);
expect(result.designSystem.colors.has('background.light')).toBe(true);
expect(result.designSystem.colors.has('background.dark')).toBe(true);
expect(result.designSystem.colors.get('background.light')?.hex).toBe('#fbfaf1');
expect(result.designSystem.symbolTable.has('colors.background.light')).toBe(true);
});
it('successfully parses 3-level nested color declarations', () => {
const result = handler.execute(makeParsed({
colors: {
background: {
light: {
primary: '#fbfaf1',
secondary: '#f0f0f0'
}
}
}
}));
expect(result.findings.filter(f => f.severity === 'error').length).toBe(0);
expect(result.designSystem.colors.has('background.light.primary')).toBe(true);
expect(result.designSystem.colors.has('background.light.secondary')).toBe(true);
expect(result.designSystem.colors.get('background.light.primary')?.hex).toBe('#fbfaf1');
expect(result.designSystem.symbolTable.has('colors.background.light.primary')).toBe(true);
});
it('successfully parses 4-level nested color declarations', () => {
const result = handler.execute(makeParsed({
colors: {
theme: {
surface: {
background: {
base: '#fbfaf1'
}
}
}
}
}));
expect(result.findings.filter(f => f.severity === 'error').length).toBe(0);
expect(result.designSystem.colors.has('theme.surface.background.base')).toBe(true);
expect(result.designSystem.colors.get('theme.surface.background.base')?.hex).toBe('#fbfaf1');
expect(result.designSystem.symbolTable.has('colors.theme.surface.background.base')).toBe(true);
});
it('resolves standard CSS named colors and converts them to hex/sRGB', () => {
const result = handler.execute(makeParsed({
colors: { c1: 'red', c2: 'transparent', c3: 'aliceblue' },
}));
expect(result.findings.length).toBe(0);
const c1 = result.designSystem.colors.get('c1');
expect(c1?.hex).toBe('#ff0000');
expect(c1?.r).toBe(255);
expect(c1?.g).toBe(0);
expect(c1?.b).toBe(0);
const c2 = result.designSystem.colors.get('c2');
expect(c2?.hex).toBe('#00000000');
expect(c2?.a).toBe(0);
});
it('resolves functional rgb/rgba colors', () => {
// comma separated
const resComma = handler.execute(makeParsed({
colors: { rgb1: 'rgb(255, 100, 50)', rgba1: 'rgba(255, 100, 50, 0.5)' },
}));
expect(resComma.findings.length).toBe(0);
expect(resComma.designSystem.colors.get('rgb1')?.hex).toBe('#ff6432');
expect(resComma.designSystem.colors.get('rgba1')?.a).toBeCloseTo(0.5);
// space separated and percentages
const resSpace = handler.execute(makeParsed({
colors: { rgb2: 'rgb(100% 50% 0%)', rgba2: 'rgb(100% 50% 0% / 40%)' },
}));
expect(resSpace.findings.length).toBe(0);
expect(resSpace.designSystem.colors.get('rgb2')?.r).toBe(255);
expect(resSpace.designSystem.colors.get('rgb2')?.g).toBe(128);
expect(resSpace.designSystem.colors.get('rgba2')?.a).toBeCloseTo(0.4);
});
it('resolves functional hsl/hsla colors', () => {
const result = handler.execute(makeParsed({
colors: { hsl1: 'hsl(120, 100%, 50%)', hsla1: 'hsl(120deg 100% 50% / 0.25)' },
}));
expect(result.findings.length).toBe(0);
const hsl1 = result.designSystem.colors.get('hsl1');
expect(hsl1?.hex).toBe('#00ff00');
const hsla1 = result.designSystem.colors.get('hsla1');
expect(hsla1?.hex).toBe('#00ff0040');
expect(hsla1?.a).toBeCloseTo(0.25);
});
it('resolves functional hwb colors', () => {
const result = handler.execute(makeParsed({
colors: { hwb1: 'hwb(120 0% 0%)', hwb2: 'hwb(120 50% 50%)', hwb3: 'hwb(120 20% 40% / 0.5)' },
}));
expect(result.findings.length).toBe(0);
expect(result.designSystem.colors.get('hwb1')?.hex).toBe('#00ff00');
expect(result.designSystem.colors.get('hwb2')?.hex).toBe('#808080');
expect(result.designSystem.colors.get('hwb3')?.a).toBeCloseTo(0.5);
});
it('resolves lab, lch, oklab, oklch color spaces', () => {
const result = handler.execute(makeParsed({
colors: {
lab1: 'lab(50% 40 -20)',
lch1: 'lch(50% 44.72 333.43)',
oklab1: 'oklab(0.6 0.1 -0.1)',
oklch1: 'oklch(0.6 0.1414 315)'
},
}));
expect(result.findings.length).toBe(0);
expect(result.designSystem.colors.get('lab1')).toBeDefined();
expect(result.designSystem.colors.get('lch1')).toBeDefined();
expect(result.designSystem.colors.get('oklab1')).toBeDefined();
expect(result.designSystem.colors.get('oklch1')).toBeDefined();
});
it('resolves color-mix colors', () => {
const result = handler.execute(makeParsed({
colors: { mix1: 'color-mix(in srgb, red 20%, blue 80%)', mix2: 'color-mix(in srgb, red, white 50%)' },
}));
expect(result.findings.length).toBe(0);
const mix1 = result.designSystem.colors.get('mix1');
expect(mix1?.r).toBe(51);
expect(mix1?.b).toBe(204);
const mix2 = result.designSystem.colors.get('mix2');
expect(mix2?.r).toBe(255);
expect(mix2?.g).toBe(128);
expect(mix2?.b).toBe(128);
});
it('parses grad hue units correctly (100grad === 90deg)', () => {
const result = handler.execute(makeParsed({
colors: { grad: 'hsl(100grad 100% 50%)', deg: 'hsl(90deg 100% 50%)' },
}));
expect(result.findings.length).toBe(0);
const grad = result.designSystem.colors.get('grad');
expect(grad?.hex).toBe('#80ff00');
expect(grad?.hex).toBe(result.designSystem.colors.get('deg')?.hex);
});
it('rejects color-mix with bare-number (non-percentage) weights', () => {
const result = handler.execute(makeParsed({
colors: { bad: 'color-mix(in srgb, red 20, blue)' },
}));
// CSS color-mix weights are percentages only; a bare number is invalid.
expect(result.designSystem.colors.has('bad')).toBe(false);
expect(result.findings.some(f => f.path === 'colors.bad' && f.severity === 'error')).toBe(true);
});
});
// ── Cycle 10: Resolve single-level token reference ────────────────
describe('single-level token reference resolution', () => {
it('resolves a direct {section.token} reference in components', () => {
const result = handler.execute(makeParsed({
colors: { primary: '#647D66' },
components: {
'button-primary': {
backgroundColor: '{colors.primary}',
},
},
}));
const btn = result.designSystem.components.get('button-primary');
expect(btn).toBeDefined();
const bg = btn?.properties.get('backgroundColor');
expect(typeof bg === 'object' && bg !== null && 'type' in bg && bg.type === 'color').toBe(true);
});
});
// ── Cycle 11: Resolve chained token reference ─────────────────────
describe('chained token reference resolution', () => {
it('resolves chained refs: {a} → {b} → #value', () => {
const result = handler.execute(makeParsed({
colors: {
'brand': '#647D66',
'primary': '{colors.brand}' as string,
},
components: {
'button': {
backgroundColor: '{colors.primary}',
},
},
}));
const btn = result.designSystem.components.get('button');
const bg = btn?.properties.get('backgroundColor');
expect(typeof bg === 'object' && bg !== null && 'type' in bg && bg.type === 'color').toBe(true);
if (typeof bg === 'object' && bg !== null && 'hex' in bg) {
expect(bg.hex).toBe('#647d66');
}
});
it('resolves references to nested colors', () => {
const result = handler.execute(makeParsed({
colors: {
background: {
light: '#fbfaf1',
dark: '#11140e'
},
page: '{colors.background.light}'
}
}));
expect(result.findings.filter(f => f.severity === 'error').length).toBe(0);
const page = result.designSystem.colors.get('page');
expect(page?.hex).toBe('#fbfaf1');
});
});
// ── Cycle 12: Detect circular reference ───────────────────────────
describe('circular reference detection', () => {
it('detects circular refs and records them as unresolved', () => {
const result = handler.execute(makeParsed({
colors: {
'a': '{colors.b}' as string,
'b': '{colors.a}' as string,
},
components: {
'card': {
backgroundColor: '{colors.a}',
},
},
}));
const card = result.designSystem.components.get('card');
expect(card?.unresolvedRefs.length).toBeGreaterThan(0);
});
it('detects long circular reference chains', () => {
const result = handler.execute(makeParsed({
colors: {
'a': '{colors.b}',
'b': '{colors.c}',
'c': '{colors.d}',
'd': '{colors.e}',
'e': '{colors.f}',
'f': '{colors.g}',
'g': '{colors.h}',
'h': '{colors.i}',
'i': '{colors.j}',
'j': '{colors.a}',
},
components: {
'card': {
backgroundColor: '{colors.a}',
},
},
}));
const card = result.designSystem.components.get('card');
expect(card?.unresolvedRefs.length).toBeGreaterThan(0);
});
});
// ── Cycle N: Non-standard units are parsed, not dropped ────────────
describe('non-standard dimension units', () => {
it('emits diagnostic for non-standard dimension units in typography', () => {
const result = handler.execute(makeParsed({
typography: {
'headline': { fontFamily: 'Roboto', fontSize: '32px', letterSpacing: '-0.02vh' },
},
}));
expect(result.findings.length).toBe(1);
expect(result.findings[0]!.path).toBe('typography.headline.letterSpacing');
expect(result.findings[0]!.severity).toBe('error');
});
});
describe('typography validation', () => {
it('emits diagnostic when fontFamily is a hex color', () => {
const result = handler.execute(makeParsed({
typography: {
'headline': { fontFamily: '#ffffff' },
},
}));
expect(result.findings.length).toBe(1);
expect(result.findings[0]!.path).toBe('typography.headline.fontFamily');
expect(result.findings[0]!.severity).toBe('error');
});
it('emits diagnostic when fontWeight is not a number or valid number string', () => {
const result = handler.execute(makeParsed({
typography: {
'headline': { fontWeight: 'bold' },
},
}));
expect(result.findings.length).toBe(1);
expect(result.findings[0]!.path).toBe('typography.headline.fontWeight');
expect(result.findings[0]!.severity).toBe('error');
});
it('accepts string representations of numbers for fontWeight', () => {
const result = handler.execute(makeParsed({
typography: {
'headline': { fontWeight: '700' },
},
}));
expect(result.findings.length).toBe(0);
const headline = result.designSystem.typography.get('headline');
expect(headline?.fontWeight).toBe(700);
});
});
describe('rounded validation', () => {
it('emits diagnostic for non-standard units in rounded', () => {
const result = handler.execute(makeParsed({
rounded: { sm: '2vh' },
}));
expect(result.findings.length).toBe(1);
expect(result.findings[0]!.path).toBe('rounded.sm');
expect(result.findings[0]!.severity).toBe('error');
});
});
// ── Cycle 13: Compute WCAG contrast ratio ─────────────────────────
describe('WCAG contrast ratio', () => {
it('computes correct contrast ratio for black on white (21:1)', () => {
const result = handler.execute(makeParsed({
colors: { black: '#000000', white: '#ffffff' },
}));
const black = result.designSystem.colors.get('black');
const white = result.designSystem.colors.get('white');
expect(black).toBeDefined();
expect(white).toBeDefined();
const ratio = contrastRatio(black!, white!);
expect(ratio).toBeCloseTo(21, 0);
});
it('computes correct contrast for identical colors (1:1)', () => {
const result = handler.execute(makeParsed({
colors: { red1: '#ff0000', red2: '#ff0000' },
}));
const ratio = contrastRatio(result.designSystem.colors.get('red1')!, result.designSystem.colors.get('red2')!);
expect(ratio).toBeCloseTo(1, 1);
});
});
describe('return signature', () => {
it('returns findings array', () => {
const result = handler.execute(makeParsed({
colors: { primary: '#647D66' },
}));
expect(result.findings).toBeDefined();
});
});
// ── Fix #25: rounded and spacing token references ─────────────────
describe('rounded token reference resolution', () => {
it('resolves a direct token reference in rounded', () => {
const result = handler.execute(makeParsed({
rounded: {
sm: '4px',
button: '{rounded.sm}' as string,
},
}));
const button = result.designSystem.rounded.get('button');
expect(button).toBeDefined();
expect(button?.value).toBe(4);
expect(button?.unit).toBe('px');
});
it('resolves a chained token reference in rounded', () => {
const result = handler.execute(makeParsed({
rounded: {
sm: '4px',
md: '{rounded.sm}' as string,
card: '{rounded.md}' as string,
},
}));
const card = result.designSystem.rounded.get('card');
expect(card).toBeDefined();
expect(card?.value).toBe(4);
expect(card?.unit).toBe('px');
});
it('resolved rounded reference appears in symbol table', () => {
const result = handler.execute(makeParsed({
rounded: {
sm: '4px',
button: '{rounded.sm}' as string,
},
}));
const sym = result.designSystem.symbolTable.get('rounded.button');
expect(sym).toBeDefined();
expect(typeof sym === 'object' && sym !== null && 'type' in sym && sym.type === 'dimension').toBe(true);
});
});
describe('spacing token reference resolution', () => {
it('resolves a direct token reference in spacing', () => {
const result = handler.execute(makeParsed({
spacing: {
base: '8px',
'button-padding': '{spacing.base}' as string,
},
}));
const buttonPadding = result.designSystem.spacing.get('button-padding');
expect(buttonPadding).toBeDefined();
expect(buttonPadding?.value).toBe(8);
expect(buttonPadding?.unit).toBe('px');
});
it('resolves a chained token reference in spacing', () => {
const result = handler.execute(makeParsed({
spacing: {
base: '8px',
md: '{spacing.base}' as string,
'section-gap': '{spacing.md}' as string,
},
}));
const sectionGap = result.designSystem.spacing.get('section-gap');
expect(sectionGap).toBeDefined();
expect(sectionGap?.value).toBe(8);
expect(sectionGap?.unit).toBe('px');
});
it('resolved spacing reference appears in symbol table', () => {
const result = handler.execute(makeParsed({
spacing: {
base: '8px',
'button-padding': '{spacing.base}' as string,
},
}));
const sym = result.designSystem.symbolTable.get('spacing.button-padding');
expect(sym).toBeDefined();
expect(typeof sym === 'object' && sym !== null && 'type' in sym && sym.type === 'dimension').toBe(true);
});
it('resolved spacing reference propagates correctly to component resolution', () => {
const result = handler.execute(makeParsed({
spacing: {
base: '8px',
'button-padding': '{spacing.base}' as string,
},
components: {
'button-primary': {
padding: '{spacing.button-padding}',
},
},
}));
const btn = result.designSystem.components.get('button-primary');
const padding = btn?.properties.get('padding');
expect(typeof padding === 'object' && padding !== null && 'type' in padding && padding.type === 'dimension').toBe(true);
if (typeof padding === 'object' && padding !== null && 'value' in padding) {
expect(padding.value).toBe(8);
}
});
});
// ── Fix #42: numeric component props crash model builder ──────────
describe('numeric component property values', () => {
it('does not crash when fontWeight is a bare number', () => {
const result = handler.execute(makeParsed({
colors: { primary: '#000000' },
components: {
'button-primary': {
backgroundColor: '{colors.primary}',
fontWeight: 600 as unknown as string,
},
},
}));
expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0);
const btn = result.designSystem.components.get('button-primary');
expect(btn).toBeDefined();
expect(btn?.properties.get('fontWeight') as unknown).toBe(600);
});
it('stores numeric fontWeight value as-is in component properties', () => {
const result = handler.execute(makeParsed({
components: {
'heading': {
fontWeight: 700 as unknown as string,
},
},
}));
const heading = result.designSystem.components.get('heading');
expect(heading?.properties.get('fontWeight') as unknown).toBe(700);
});
it('does not crash when borderWidth is a bare number', () => {
const result = handler.execute(makeParsed({
components: {
'card': {
borderWidth: 1 as unknown as string,
},
},
}));
expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0);
const card = result.designSystem.components.get('card');
expect(card?.properties.get('borderWidth') as unknown).toBe(1);
});
it('handles mixed numeric and string props in same component without crashing', () => {
const result = handler.execute(makeParsed({
colors: { primary: '#ff0000' },
spacing: { md: '16px' },
components: {
'button': {
fontWeight: 600 as unknown as string,
backgroundColor: '{colors.primary}',
padding: '{spacing.md}',
borderRadius: '4px',
},
},
}));
expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0);
const btn = result.designSystem.components.get('button');
expect(btn?.properties.get('fontWeight') as unknown).toBe(600);
});
});
// ── Fix #75: non-string YAML scalars crash model builder ────────────
describe('non-string component property values (Issue #75)', () => {
it('does not crash when a component property is a float (opacity: 0.9)', () => {
const result = handler.execute(makeParsed({
colors: { primary: '#FF0000', 'on-primary': '#FFFFFF' },
components: {
button: {
backgroundColor: '{colors.primary}',
textColor: '{colors.on-primary}',
opacity: 0.9 as unknown as string,
},
},
}));
expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0);
const btn = result.designSystem.components.get('button');
expect(btn?.properties.get('opacity') as unknown).toBe(0.9);
});
it('does not crash when a component property is a boolean (visible: true)', () => {
const result = handler.execute(makeParsed({
components: {
banner: {
visible: true as unknown as string,
},
},
}));
expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0);
const banner = result.designSystem.components.get('banner');
expect(banner?.properties.get('visible') as unknown).toBe(true);
});
it('handles mixed number, boolean, and string props without crashing', () => {
const result = handler.execute(makeParsed({
colors: { primary: '#ff0000' },
components: {
card: {
backgroundColor: '{colors.primary}',
borderRadius: '8px',
fontWeight: 500 as unknown as string,
opacity: 0.85 as unknown as string,
visible: true as unknown as string,
disabled: false as unknown as string,
},
},
}));
expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0);
const card = result.designSystem.components.get('card');
expect(card?.properties.get('fontWeight') as unknown).toBe(500);
expect(card?.properties.get('opacity') as unknown).toBe(0.85);
expect(card?.properties.get('visible') as unknown).toBe(true);
expect(card?.properties.get('disabled') as unknown).toBe(false);
});
});
describe('token nesting depth limit', () => {
it('emits error when token nesting depth exceeds 20', () => {
// 22 levels: Level 1..21 are objects, Level 22 is a leaf.
// forEachLeaf will be called for Level 22 with depth 21.
let obj: any = '#ffffff';
for (let i = 22; i >= 1; i--) {
obj = { [`level${i}`]: obj };
}
const result = handler.execute(makeParsed({
colors: obj,
}));
expect(result.findings.some((f) => f.message.includes('nesting depth'))).toBe(true);
expect(result.findings.find((f) => f.message.includes('nesting depth'))?.path).toBe('colors');
});
it('allows nesting up to depth 20', () => {
// 21 levels: Level 1..20 are objects, Level 21 is a leaf.
// forEachLeaf will be called for Level 21 with depth 20.
let obj: any = '#ffffff';
for (let i = 21; i >= 1; i--) {
obj = { [`level${i}`]: obj };
}
const result = handler.execute(makeParsed({
colors: obj,
}));
expect(result.findings.some((f) => f.message.includes('nesting depth'))).toBe(false);
// Construct the expected path: level1.level2...level21
const path = Array.from({ length: 21 }, (_, i) => `level${i + 1}`).join('.');
expect(result.designSystem.colors.has(path)).toBe(true);
});
});
describe('color-mix nesting depth limit', () => {
it('rejects pathologically nested color-mix as an invalid color without collapsing the model', () => {
let nested = 'red';
for (let i = 0; i < 50; i++) nested = `color-mix(in srgb, ${nested}, blue)`;
const result = handler.execute(makeParsed({
colors: { ok: '#ffffff', deep: nested },
}));
// The over-deep color resolves to "invalid" (a precise per-token error),
// not a thrown RangeError that collapses the whole model build.
expect(result.designSystem.colors.has('deep')).toBe(false);
expect(result.findings.some(f => f.path === 'colors.deep' && f.severity === 'error')).toBe(true);
// Other valid tokens are unaffected.
expect(result.designSystem.colors.get('ok')?.hex).toBe('#ffffff');
});
});
});
+441
View File
@@ -0,0 +1,441 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { ParsedDesignSystem } from '../parser/spec.js';
import { SCHEMA_KEYS } from '../parser/spec.js';
import type {
ModelSpec,
ModelResult,
ResolvedColor,
ResolvedDimension,
ResolvedTypography,
ResolvedValue,
ComponentDef,
Finding,
} from './spec.js';
import { isValidColor, isParseableDimension, isTokenReference, parseDimensionParts } from './spec.js';
import { parseCssColor } from './color-parser.js';
import {
MAX_REFERENCE_DEPTH,
MAX_TOKEN_NESTING_DEPTH,
} from '../spec-config.js';
const SCHEMA_KEY_SET: ReadonlySet<string> = new Set(SCHEMA_KEYS);
/**
* Builds a resolved DesignSystemState from parsed YAML tokens.
* Handles color parsing, dimension parsing, typography construction,
* and chained token reference resolution with cycle detection.
* Never throws — all errors returned as ModelResult failures.
*/
export class ModelHandler implements ModelSpec {
execute(input: ParsedDesignSystem): ModelResult {
try {
const findings: Finding[] = [];
const symbolTable = new Map<string, ResolvedValue>();
const colors = new Map<string, ResolvedColor>();
const typography = new Map<string, ResolvedTypography>();
const rounded = new Map<string, ResolvedDimension>();
const spacing = new Map<string, ResolvedDimension>();
// ── Phase 1: Resolve primitive tokens ──────────────────────────
// Colors
if (input.colors) {
forEachLeaf(input.colors, (name, raw) => {
if (typeof raw === 'string' && isTokenReference(raw)) {
// Store raw reference for later resolution
symbolTable.set(`colors.${name}`, raw);
} else if (isValidColor(raw)) {
const resolved = parseColor(raw);
colors.set(name, resolved);
symbolTable.set(`colors.${name}`, resolved);
} else {
findings.push({
severity: 'error',
path: `colors.${name}`,
message: `'${raw}' is not a valid color. Expected a CSS color value (e.g., #ffffff, rgb(0 0 0), oklch(0.5 0.2 240)).`,
});
// Store as-is for fallback
symbolTable.set(`colors.${name}`, raw);
}
}, '', 0, findings, 'colors');
}
// Typography
if (input.typography) {
for (const [name, props] of Object.entries(input.typography)) {
const resolved = parseTypography(props, `typography.${name}`, findings);
typography.set(name, resolved);
symbolTable.set(`typography.${name}`, resolved);
}
}
// Rounded
if (input.rounded) {
forEachLeaf(input.rounded, (name, raw) => {
if (typeof raw === 'string') {
if (isParseableDimension(raw)) {
const resolved = parseDimension(raw);
if (resolved.unit !== 'px' && resolved.unit !== 'rem' && resolved.unit !== 'em') {
findings.push({
severity: 'error',
path: `rounded.${name}`,
message: `'${raw}' has an invalid unit '${resolved.unit}'. Only px, rem, and em are allowed.`,
});
}
rounded.set(name, resolved);
symbolTable.set(`rounded.${name}`, resolved);
} else if (!isTokenReference(raw)) {
findings.push({
severity: 'error',
path: `rounded.${name}`,
message: `'${raw}' is not a valid dimension.`,
});
symbolTable.set(`rounded.${name}`, raw);
} else {
symbolTable.set(`rounded.${name}`, raw);
}
}
}, '', 0, findings, 'rounded');
}
// Spacing
if (input.spacing) {
forEachLeaf(input.spacing, (name, raw) => {
if (isParseableDimension(raw)) {
const resolved = parseDimension(raw);
spacing.set(name, resolved);
symbolTable.set(`spacing.${name}`, resolved);
} else {
symbolTable.set(`spacing.${name}`, raw);
}
}, '', 0, findings, 'spacing');
}
// ── Phase 2: Resolve chained color references ──────────────────
// Iterate color entries that are still raw references and resolve them
if (input.colors) {
forEachLeaf(input.colors, (name, raw) => {
if (typeof raw === 'string' && isTokenReference(raw)) {
const resolved = resolveReference(symbolTable, raw.slice(1, -1), new Set());
if (resolved !== null && typeof resolved === 'object' && 'type' in resolved && resolved.type === 'color') {
colors.set(name, resolved as ResolvedColor);
symbolTable.set(`colors.${name}`, resolved);
}
}
});
}
// Resolve chained rounded references
if (input.rounded) {
forEachLeaf(input.rounded, (name, raw) => {
if (typeof raw === 'string' && isTokenReference(raw)) {
const resolved = resolveReference(symbolTable, raw.slice(1, -1), new Set());
if (
resolved !== null &&
typeof resolved === 'object' &&
'type' in resolved &&
resolved.type === 'dimension'
) {
rounded.set(name, resolved as ResolvedDimension);
symbolTable.set(`rounded.${name}`, resolved);
}
}
});
}
// Resolve chained spacing references
if (input.spacing) {
forEachLeaf(input.spacing, (name, raw) => {
if (typeof raw === 'string' && isTokenReference(raw)) {
const resolved = resolveReference(symbolTable, raw.slice(1, -1), new Set());
if (
resolved !== null &&
typeof resolved === 'object' &&
'type' in resolved &&
resolved.type === 'dimension'
) {
spacing.set(name, resolved as ResolvedDimension);
symbolTable.set(`spacing.${name}`, resolved);
}
}
});
}
// ── Phase 3: Build components ──────────────────────────────────
const components = new Map<string, ComponentDef>();
if (input.components) {
for (const [compName, props] of Object.entries(input.components)) {
const properties = new Map<string, ResolvedValue>();
const unresolvedRefs: string[] = [];
for (const [propName, rawValue] of Object.entries(props)) {
// Non-string scalars (numbers, booleans) are valid YAML values
// that can appear in component properties (e.g. fontWeight: 600,
// visible: true, opacity: 0.9). Store them as-is rather than
// passing them to string-only helpers like isTokenReference or
// isValidColor, which would either silently coerce or crash.
if (typeof rawValue === 'number' || typeof rawValue === 'boolean') {
properties.set(propName, rawValue);
} else if (isTokenReference(rawValue)) {
const refPath = rawValue.slice(1, -1);
const resolved = resolveReference(symbolTable, refPath, new Set());
if (resolved !== null) {
properties.set(propName, resolved);
} else {
unresolvedRefs.push(rawValue);
properties.set(propName, rawValue);
}
} else if (isValidColor(rawValue)) {
properties.set(propName, parseColor(rawValue));
} else if (isParseableDimension(rawValue)) {
properties.set(propName, parseDimension(rawValue));
} else {
properties.set(propName, rawValue);
}
}
components.set(compName, { properties, unresolvedRefs });
}
}
const unknownKeys = [...input.sourceMap.keys()].filter(
key => !SCHEMA_KEY_SET.has(key)
);
const unknownKeyValues: Record<string, unknown> = {};
if (input.rawValues) {
for (const key of unknownKeys) {
if (Object.prototype.hasOwnProperty.call(input.rawValues, key)) {
unknownKeyValues[key] = input.rawValues[key];
}
}
}
return {
designSystem: {
name: input.name,
description: input.description,
colors,
typography,
rounded,
spacing,
components,
symbolTable,
sections: input.sections,
unknownKeys,
unknownKeyValues,
},
findings,
};
} catch (error) {
return {
designSystem: {
colors: new Map(),
typography: new Map(),
rounded: new Map(),
spacing: new Map(),
components: new Map(),
symbolTable: new Map(),
},
findings: [
{
severity: 'error',
message: `Unexpected error during model building: ${error instanceof Error ? error.message : String(error)
}`,
},
],
};
}
}
}
// ── Pure utility functions ─────────────────────────────────────────
/**
* Parse a CSS color string into a ResolvedColor with RGB + WCAG luminance.
*/
export function parseColor(raw: string): ResolvedColor {
const parsed = parseCssColor(raw);
if (!parsed) {
throw new Error(`Invalid color: ${raw}`);
}
return {
type: 'color',
...parsed,
};
}
/**
* Parse a dimension string like "42px" or "1.5rem".
*/
function parseDimension(raw: string): ResolvedDimension {
const parts = parseDimensionParts(raw);
if (!parts) {
throw new Error(`Invalid dimension: ${raw}`);
}
return {
type: 'dimension',
value: parts.value,
unit: parts.unit,
};
}
/**
* Parse a typography properties object into a ResolvedTypography.
*/
function parseTypography(props: Record<string, string | number>, path: string, findings: Finding[]): ResolvedTypography {
const result: ResolvedTypography = { type: 'typography' };
if (typeof props['fontFamily'] === 'string') {
const ff = props['fontFamily'];
if (isValidColor(ff)) {
findings.push({
severity: 'error',
path: `${path}.fontFamily`,
message: `'${ff}' appears to be a color, not a valid font family.`,
});
}
result.fontFamily = ff;
}
if (props['fontWeight'] !== undefined) {
const fw = props['fontWeight'];
let fwValue: number | undefined;
if (typeof fw === 'number') {
fwValue = fw;
} else if (typeof fw === 'string') {
const parsed = Number(fw);
if (!isNaN(parsed)) {
fwValue = parsed;
}
}
if (fwValue === undefined) {
findings.push({
severity: 'error',
path: `${path}.fontWeight`,
message: `'${fw}' is not a valid font weight. Expected a number.`,
});
} else {
result.fontWeight = fwValue;
}
}
if (typeof props['fontFeature'] === 'string') result.fontFeature = props['fontFeature'];
if (typeof props['fontVariation'] === 'string') result.fontVariation = props['fontVariation'];
const dimensionProps = ['fontSize', 'lineHeight', 'letterSpacing'] as const;
for (const prop of dimensionProps) {
const raw = props[prop];
if (typeof raw === 'string') {
if (isParseableDimension(raw)) {
const parsed = parseDimension(raw);
if (parsed.unit !== 'px' && parsed.unit !== 'rem' && parsed.unit !== 'em') {
findings.push({
severity: 'error',
path: `${path}.${prop}`,
message: `'${raw}' has an invalid unit '${parsed.unit}'. Only px, rem, and em are allowed.`,
});
}
result[prop] = parsed;
} else if (prop === 'lineHeight' && /^\d*\.?\d+$/.test(raw)) {
result[prop] = {
type: 'dimension',
value: parseFloat(raw),
unit: '',
};
} else if (!isTokenReference(raw)) {
findings.push({
severity: 'error',
path: `${path}.${prop}`,
message: `'${raw}' is not a valid dimension.`,
});
}
}
}
return result;
}
/**
* Resolve a token reference with chained resolution and cycle detection.
* Returns null if the reference cannot be resolved (not found or circular).
*/
function resolveReference(
symbolTable: Map<string, ResolvedValue>,
path: string,
visited: Set<string>,
depth: number = 0,
): ResolvedValue | null {
if (depth > MAX_REFERENCE_DEPTH) return null;
if (visited.has(path)) return null; // Circular reference
visited.add(path);
const value = symbolTable.get(path);
if (value === undefined) return null;
// If the value is itself a reference string, follow the chain
if (typeof value === 'string' && isTokenReference(value)) {
const innerPath = value.slice(1, -1);
return resolveReference(symbolTable, innerPath, visited, depth + 1);
}
return value;
}
/**
* WCAG 2.1 contrast ratio between two resolved colors.
*/
export function contrastRatio(a: ResolvedColor, b: ResolvedColor): number {
const L1 = Math.max(a.luminance, b.luminance);
const L2 = Math.min(a.luminance, b.luminance);
return (L1 + 0.05) / (L2 + 0.05);
}
/**
* Recursively iterate over an object and call a function for each leaf node.
* Leaf node paths are dot-separated (e.g. "background.light").
*/
function forEachLeaf(
obj: Record<string, any>,
fn: (path: string, value: any) => void,
prefix = '',
depth = 0,
findings?: Finding[],
rootPath?: string
) {
if (depth > MAX_TOKEN_NESTING_DEPTH) {
if (findings && rootPath) {
// Check if we've already reported this rootPath to avoid spamming
if (!findings.some((f) => f.path === rootPath && f.message.includes('nesting depth'))) {
findings.push({
severity: 'error',
path: rootPath,
message: `Token nesting depth exceeds maximum allowed depth of ${MAX_TOKEN_NESTING_DEPTH}.`,
});
}
}
return;
}
for (const [key, value] of Object.entries(obj)) {
const fullPath = prefix ? `${prefix}.${key}` : key;
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
forEachLeaf(value, fn, fullPath, depth + 1, findings, rootPath);
} else {
fn(fullPath, value);
}
}
}
+106
View File
@@ -0,0 +1,106 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { isValidColor, isStandardDimension, isParseableDimension, parseDimensionParts, isTokenReference } from './spec.js';
describe('isValidColor', () => {
const validColors = ['#ff0000', '#FF0000', '#abc', '#ABC', '#647D66', '#000', '#fff', 'red', 'blue'];
const invalidColors = ['#gg0000', '#12345', '647D66', '#1234567', '', '#'];
it.each(validColors)('accepts valid hex color: %s', (color: string) => {
expect(isValidColor(color)).toBe(true);
});
it.each(invalidColors)('rejects invalid color: %s', (color: string) => {
expect(isValidColor(color)).toBe(false);
});
});
describe('isStandardDimension', () => {
const standard = ['12px', '1.5rem', '0px', '42px', '0.75rem', '100px', '12em', '-0.02em'];
const nonStandard = ['42', 'px', 'rem', '12vh', '', '12 px', '12vw'];
it.each(standard)('accepts standard dimension: %s', (dim: string) => {
expect(isStandardDimension(dim)).toBe(true);
});
it.each(nonStandard)('rejects non-standard dimension: %s', (dim: string) => {
expect(isStandardDimension(dim)).toBe(false);
});
});
describe('isParseableDimension', () => {
const parseable = [
'12px', '1.5rem', '-0.02em', '100vh', '50%', '0.75rem', '1em', '12vw',
// CSS Level 4 units now in scope
'10cqi', '20lvh', '30dvw', '5cqmin',
];
const unparseable = ['42', 'px', 'rem', '', '12 px', 'auto', 'inherit'];
it.each(parseable)('accepts parseable dimension: %s', (dim: string) => {
expect(isParseableDimension(dim)).toBe(true);
});
it.each(unparseable)('rejects unparseable dimension: %s', (dim: string) => {
expect(isParseableDimension(dim)).toBe(false);
});
});
describe('parseDimensionParts', () => {
it('parses standard dimensions', () => {
expect(parseDimensionParts('42px')).toEqual({ value: 42, unit: 'px' });
expect(parseDimensionParts('1.5rem')).toEqual({ value: 1.5, unit: 'rem' });
expect(parseDimensionParts('-0.02em')).toEqual({ value: -0.02, unit: 'em' });
});
it('parses leading-zero-free decimals (.5rem)', () => {
expect(parseDimensionParts('.5rem')).toEqual({ value: 0.5, unit: 'rem' });
expect(parseDimensionParts('-.25em')).toEqual({ value: -0.25, unit: 'em' });
});
it('returns null for bare numbers without a unit', () => {
expect(parseDimensionParts('42')).toBeNull();
expect(parseDimensionParts('')).toBeNull();
});
it('returns null for CSS keywords', () => {
expect(parseDimensionParts('auto')).toBeNull();
expect(parseDimensionParts('inherit')).toBeNull();
});
it('returns null for oversized values without pathological backtracking', () => {
// Length-capped: an absurdly long value is rejected immediately rather than
// triggering quadratic regex backtracking.
expect(parseDimensionParts('1'.repeat(100000))).toBeNull();
expect(parseDimensionParts('1'.repeat(100000) + 'px')).toBeNull();
// Legitimate dimensions well under the cap still parse.
expect(parseDimensionParts('999999.999999px')).toEqual({ value: 999999.999999, unit: 'px' });
});
});
describe('isTokenReference', () => {
it('recognizes curly-brace token references', () => {
expect(isTokenReference('{colors.primary}')).toBe(true);
expect(isTokenReference('{typography.headline-lg}')).toBe(true);
expect(isTokenReference('{colors.primary-60}')).toBe(true);
});
it('rejects non-references', () => {
expect(isTokenReference('#ff0000')).toBe(false);
expect(isTokenReference('colors.primary')).toBe(false);
expect(isTokenReference('{}')).toBe(false);
expect(isTokenReference('{ colors.primary }')).toBe(false);
});
});
+199
View File
@@ -0,0 +1,199 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { z } from 'zod';
import type { ParsedDesignSystem } from '../parser/spec.js';
import {
STANDARD_UNITS as _STANDARD_UNITS,
VALID_TYPOGRAPHY_PROPS as _VALID_TYPOGRAPHY_PROPS,
VALID_COMPONENT_SUB_TOKENS as _VALID_COMPONENT_SUB_TOKENS,
} from '../spec-config.js';
import { parseCssColor } from './color-parser.js';
export const SeveritySchema = z.enum(['error', 'warning', 'info']);
export type Severity = z.infer<typeof SeveritySchema>;
export interface Finding {
severity: Severity;
path?: string;
message: string;
}
// ── RESOLVED VALUE TYPES ───────────────────────────────────────────
export interface ResolvedColor {
type: 'color';
hex: string;
r: number;
g: number;
b: number;
/** Alpha channel from 0 to 1. Optional, defaults to 1 if not present. */
a?: number;
/** WCAG relative luminance */
luminance: number;
}
export interface ResolvedDimension {
type: 'dimension';
value: number;
/** The unit string. Standard units are 'px' and 'rem'; others are preserved but flagged by the linter. */
unit: string;
}
export interface ResolvedTypography {
type: 'typography';
fontFamily?: string | undefined;
fontSize?: ResolvedDimension | undefined;
fontWeight?: number | undefined;
lineHeight?: ResolvedDimension | undefined;
letterSpacing?: ResolvedDimension | undefined;
fontFeature?: string | undefined;
fontVariation?: string | undefined;
}
export type ResolvedValue = ResolvedColor | ResolvedDimension | ResolvedTypography | string | number | boolean;
// ── Re-exported from spec-config (single source of truth) ─────────
export const VALID_TYPOGRAPHY_PROPS = _VALID_TYPOGRAPHY_PROPS;
export const VALID_COMPONENT_SUB_TOKENS = _VALID_COMPONENT_SUB_TOKENS;
// ── STATE ──────────────────────────────────────────────────────────
export interface DesignSystemState {
name?: string | undefined;
description?: string | undefined;
colors: Map<string, ResolvedColor>;
typography: Map<string, ResolvedTypography>;
rounded: Map<string, ResolvedDimension>;
spacing: Map<string, ResolvedDimension>;
components: Map<string, ComponentDef>;
/** Flat lookup: "colors.primary" → ResolvedColor */
symbolTable: Map<string, ResolvedValue>;
/** Markdown heading names found in the document */
sections?: string[] | undefined;
/** Top-level YAML keys that are not part of the known schema */
unknownKeys?: string[] | undefined;
/** Raw YAML values for unknown top-level keys, keyed by the unknown key name */
unknownKeyValues?: Record<string, unknown> | undefined;
}
export interface ComponentDef {
properties: Map<string, ResolvedValue>;
/** Unresolved references that failed to resolve */
unresolvedRefs: string[];
}
// ── ERROR CODES ────────────────────────────────────────────────────
export const ModelErrorCode = z.enum([
'INVALID_COLOR',
'INVALID_DIMENSION',
'INVALID_TYPOGRAPHY_PROP',
'UNRESOLVED_REFERENCE',
'CIRCULAR_REFERENCE',
'REFERENCE_TO_NON_PRIMITIVE',
'NESTING_DEPTH_EXCEEDED',
'UNKNOWN_ERROR',
]);
// ── RESULT ─────────────────────────────────────────────────────────
export interface ModelResult {
designSystem: DesignSystemState;
findings: Finding[];
}
// ── INTERFACE ──────────────────────────────────────────────────────
export interface ModelSpec {
execute(input: ParsedDesignSystem): ModelResult;
}
// ── VALIDATION HELPERS ─────────────────────────────────────────────
/** Units the spec formally supports. Sourced from spec-config.ts. */
const STANDARD_UNITS: Set<string> = new Set(_STANDARD_UNITS);
/**
* All known CSS length/percentage units.
* Adding a new CSS unit = one string here. Never edit a regex.
*/
const CSS_UNITS = new Set([
// Absolute
'px', 'cm', 'mm', 'in', 'pt', 'pc',
// Relative to font
'em', 'rem', 'ex', 'ch', 'cap', 'ic', 'lh', 'rlh',
// Viewport — classic
'vh', 'vw', 'vmin', 'vmax',
// Viewport — dynamic/small/large (CSS Level 4)
'dvh', 'dvw', 'dvmin', 'dvmax',
'svh', 'svw', 'svmin', 'svmax',
'lvh', 'lvw', 'lvmin', 'lvmax',
// Container query units
'cqw', 'cqh', 'cqi', 'cqb', 'cqmin', 'cqmax',
// Percentage
'%',
]);
/**
* Upper bound on a dimension string's length. Real CSS dimensions are a handful
* of characters; capping the length keeps validation linear and prevents
* pathological regex backtracking on oversized, attacker-supplied values.
*/
const MAX_DIMENSION_LENGTH = 64;
/**
* Parse a dimension string into its numeric value and unit suffix.
* Accepts an optional leading sign and optional decimal (`.5rem` is valid).
* Returns null for non-dimension strings (bare numbers, keywords like `auto`).
*/
export function parseDimensionParts(raw: string): { value: number; unit: string } | null {
if (typeof raw !== 'string') return null;
if (raw.length > MAX_DIMENSION_LENGTH) return null;
const match = raw.match(/^(-?\d*\.?\d+)([a-zA-Z%]+)$/);
if (!match) return null;
const value = parseFloat(match[1]!);
return Number.isNaN(value) ? null : { value, unit: match[2]! };
}
/**
* Validate a hex color string. Accepts #RGB, #RGBA, #RRGGBB, and #RRGGBBAA.
*/
export function isValidColor(raw: string): boolean {
return parseCssColor(raw) !== null;
}
/**
* Validate a dimension string uses a spec-standard unit (px or rem only).
*/
export function isStandardDimension(raw: string): boolean {
const parts = parseDimensionParts(raw);
return parts !== null && STANDARD_UNITS.has(parts.unit);
}
/**
* Check if a dimension string is parseable (any known CSS length/percentage unit).
* Adding support for a new unit: add it to CSS_UNITS above.
*/
export function isParseableDimension(raw: string): boolean {
const parts = parseDimensionParts(raw);
return parts !== null && CSS_UNITS.has(parts.unit);
}
/**
* @deprecated Use isStandardDimension for spec compliance or isParseableDimension for generous parsing.
*/
export const isValidDimension = isStandardDimension;
/**
* Check if a string is a token reference ({section.token}).
*/
export function isTokenReference(raw: string): boolean {
return /^\{[a-zA-Z0-9._-]+\}$/.test(raw);
}
@@ -0,0 +1,156 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { ParserHandler } from './handler.js';
const handler = new ParserHandler();
describe('ParserHandler', () => {
// ── Cycle 2: Frontmatter extraction ───────────────────────────────
describe('frontmatter extraction', () => {
it('extracts YAML from frontmatter delimiters', () => {
const input = `---
name: Kindred Spirit
colors:
primary: "#647D66"
---
Some markdown content here.
`;
const result = handler.execute({ content: input });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('Kindred Spirit');
expect(result.data.colors?.['primary']).toBe('#647D66');
}
});
});
// ── Cycle 3: Code block extraction ────────────────────────────────
describe('code block extraction', () => {
it('extracts YAML from fenced yaml code blocks', () => {
const input = `# Design System
\`\`\`yaml
colors:
primary: "#ff0000"
secondary: "#00ff00"
\`\`\`
Some explanation text.
`;
const result = handler.execute({ content: input });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.colors?.['primary']).toBe('#ff0000');
expect(result.data.colors?.['secondary']).toBe('#00ff00');
}
});
it('extracts YAML code blocks with attributes', () => {
const input = `# Code block with attributes
\`\`\`yaml title="theme"
colors:
primary: "#ffffff"
\`\`\`
`;
const result = handler.execute({ content: input });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.colors?.['primary']).toBe('#ffffff');
}
});
});
// ── Cycle 4: Merge multiple code blocks ───────────────────────────
describe('merging multiple code blocks', () => {
it('merges separate YAML blocks into one tree', () => {
const input = `# Colors
\`\`\`yaml
colors:
primary: "#647D66"
\`\`\`
# Typography
\`\`\`yaml
typography:
headline-lg:
fontFamily: Google Sans Display
fontSize: 42px
\`\`\`
`;
const result = handler.execute({ content: input });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.colors?.['primary']).toBe('#647D66');
expect(result.data.typography?.['headline-lg']?.['fontFamily']).toBe('Google Sans Display');
}
});
});
// ── Cycle 5: Duplicate section detection ──────────────────────────
describe('duplicate section detection', () => {
it('returns DUPLICATE_SECTION when same top-level key appears in multiple blocks', () => {
const input = `
\`\`\`yaml
colors:
primary: "#ff0000"
\`\`\`
\`\`\`yaml
colors:
secondary: "#00ff00"
\`\`\`
`;
const result = handler.execute({ content: input });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe('DUPLICATE_SECTION');
expect(result.error.message).toContain('colors');
}
});
});
// ── Cycle 6: Malformed YAML ───────────────────────────────────────
describe('malformed YAML', () => {
it('returns YAML_PARSE_ERROR on invalid YAML syntax', () => {
const input = `---
colors:
primary: "#ff0000"
- this is invalid
---`;
const result = handler.execute({ content: input });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe('YAML_PARSE_ERROR');
}
});
it('returns NO_YAML_FOUND when no YAML content exists', () => {
const input = `# Just a heading
Some markdown text with no YAML blocks.
`;
const result = handler.execute({ content: input });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe('NO_YAML_FOUND');
}
});
});
});
+215
View File
@@ -0,0 +1,215 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import YAML from 'yaml';
import type { ParserSpec, ParserInput, ParserResult, ParsedDesignSystem, SourceLocation } from './spec.js';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkFrontmatter from 'remark-frontmatter';
import { visit } from 'unist-util-visit';
import type { Root, Code, Yaml, Heading, PhrasingContent } from 'mdast';
/**
* Extracts and parses YAML design tokens from DESIGN.md content.
* Supports two embedding modes: frontmatter (---) and fenced yaml code blocks.
* Never throws — all errors returned as ParserResult failures.
*/
export class ParserHandler implements ParserSpec {
execute(input: ParserInput): ParserResult {
try {
const { content } = input;
const processor = unified()
.use(remarkParse)
.use(remarkFrontmatter, ['yaml']);
const ast = processor.parse(content) as Root;
const blocks: Array<{ yaml: string; block: 'frontmatter' | number; startLine: number }> = [];
const sections: string[] = [];
const headingsWithLines: Array<{ text: string; line: number }> = [];
let blockIndex = 0;
visit(ast, (node) => {
if (node.type === 'yaml') {
const yamlNode = node as Yaml;
blocks.push({
yaml: yamlNode.value,
block: 'frontmatter',
startLine: node.position?.start.line ?? 1
});
}
if (node.type === 'code') {
const codeNode = node as Code;
if (codeNode.lang === 'yaml' || codeNode.lang === 'yml') {
blocks.push({
yaml: codeNode.value,
block: blockIndex,
startLine: node.position?.start.line ?? 1
});
blockIndex++;
}
}
if (node.type === 'heading') {
const heading = node as Heading;
if (heading.depth === 2) {
const text = this.extractHeadingText(heading.children);
if (text) {
sections.push(text);
headingsWithLines.push({ text, line: node.position?.start.line ?? 1 });
}
}
}
});
// Slice content into sections
const contentLines = content.split('\n');
const documentSections: Array<{ heading: string; content: string }> = [];
const firstHeading = headingsWithLines[0];
if (firstHeading) {
// Prelude (content before first H2)
const firstHeadingLine = firstHeading.line;
if (firstHeadingLine > 1) {
documentSections.push({
heading: '',
content: contentLines.slice(0, firstHeadingLine - 1).join('\n')
});
}
for (let i = 0; i < headingsWithLines.length; i++) {
const current = headingsWithLines[i];
if (!current) continue;
const next = headingsWithLines[i + 1];
const startIdx = current.line - 1;
const endIdx = next ? next.line - 1 : contentLines.length;
documentSections.push({
heading: current.text,
content: contentLines.slice(startIdx, endIdx).join('\n')
});
}
} else {
// No H2 headings found, entire file is one section
documentSections.push({
heading: '',
content: content
});
}
if (blocks.length === 0) {
return {
success: false,
error: {
code: 'NO_YAML_FOUND',
message: 'No YAML content found. Expected frontmatter (---) or fenced yaml code blocks.',
recoverable: true,
},
};
}
return this.mergeCodeBlocks(blocks, sections, documentSections);
} catch (error) {
return {
success: false,
error: {
code: 'UNKNOWN_ERROR',
message: error instanceof Error ? error.message : String(error),
recoverable: false,
},
};
}
}
/**
* Merge multiple code blocks into a single ParsedDesignSystem.
* Detects duplicate top-level sections across blocks.
*/
private mergeCodeBlocks(blocks: Array<{ yaml: string; block: 'frontmatter' | number; startLine: number }>, sections: string[], documentSections: Array<{ heading: string; content: string }>): ParserResult {
const merged: Record<string, unknown> = {};
const sourceMap = new Map<string, SourceLocation>();
const seenSections = new Map<string, 'frontmatter' | number>();
for (const block of blocks) {
let parsed: Record<string, unknown>;
try {
parsed = YAML.parse(block.yaml) as Record<string, unknown>;
if (!parsed || typeof parsed !== 'object') continue;
} catch (error) {
return {
success: false,
error: {
code: 'YAML_PARSE_ERROR',
message: error instanceof Error ? error.message : String(error),
recoverable: true,
},
};
}
// Check for duplicate top-level sections
for (const key of Object.keys(parsed)) {
const previousBlock = seenSections.get(key);
if (previousBlock !== undefined) {
const prevDesc = previousBlock === 'frontmatter' ? 'frontmatter' : `code block ${previousBlock + 1}`;
const currDesc = block.block === 'frontmatter' ? 'frontmatter' : `code block ${block.block + 1}`;
return {
success: false,
error: {
code: 'DUPLICATE_SECTION',
message: `Section '${key}' is defined in both ${prevDesc} and ${currDesc}.`,
recoverable: true,
},
};
}
seenSections.set(key, block.block);
sourceMap.set(key, { line: block.startLine, column: 0, block: block.block });
}
Object.assign(merged, parsed);
}
return {
success: true,
data: this.toDesignSystem(merged, sourceMap, sections, documentSections),
};
}
/**
* Map a raw parsed object to the ParsedDesignSystem interface.
*/
private toDesignSystem(raw: Record<string, unknown>, sourceMap: Map<string, SourceLocation>, sections: string[], documentSections: Array<{ heading: string; content: string }>): ParsedDesignSystem {
return {
version: typeof raw['version'] === 'string' ? raw['version'] : undefined,
name: typeof raw['name'] === 'string' ? raw['name'] : undefined,
description: typeof raw['description'] === 'string' ? raw['description'] : undefined,
colors: raw['colors'] as Record<string, string> | undefined,
typography: raw['typography'] as Record<string, Record<string, string | number>> | undefined,
rounded: raw['rounded'] as Record<string, string> | undefined,
spacing: raw['spacing'] as Record<string, string> | undefined,
components: raw['components'] as Record<string, Record<string, string>> | undefined,
sourceMap,
sections,
documentSections,
rawValues: raw,
};
}
private extractHeadingText(children: PhrasingContent[]): string {
return children
.map(c => 'value' in c ? c.value : '')
.join('')
.trim();
}
}
@@ -0,0 +1,33 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { ParserInputSchema } from './spec.js';
describe('ParserInputSchema', () => {
it('rejects empty content', () => {
const result = ParserInputSchema.safeParse({ content: '' });
expect(result.success).toBe(false);
});
it('accepts non-empty content', () => {
const result = ParserInputSchema.safeParse({ content: '---\nname: test\n---' });
expect(result.success).toBe(true);
});
it('rejects missing content field', () => {
const result = ParserInputSchema.safeParse({});
expect(result.success).toBe(false);
});
});
+88
View File
@@ -0,0 +1,88 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { z } from 'zod';
// ── INPUT ──────────────────────────────────────────────────────────
export const ParserInputSchema = z.object({
/** Raw DESIGN.md content (or standalone YAML string) */
content: z.string().min(1, 'Content must not be empty'),
});
export type ParserInput = z.infer<typeof ParserInputSchema>;
// ── ERROR CODES ────────────────────────────────────────────────────
export const ParserErrorCode = z.enum([
'EMPTY_CONTENT',
'NO_YAML_FOUND',
'YAML_PARSE_ERROR',
'DUPLICATE_SECTION',
'UNKNOWN_ERROR',
]);
// ── OUTPUT ──────────────────────────────────────────────────────────
export interface SourceLocation {
line: number;
column: number;
block: 'frontmatter' | number;
}
/** Raw, unresolved parsed output — mirrors the YAML schema */
export interface ParsedDesignSystem {
version?: string | undefined;
name?: string | undefined;
description?: string | undefined;
colors?: Record<string, any> | undefined;
typography?: Record<string, Record<string, any>> | undefined;
rounded?: Record<string, any> | undefined;
spacing?: Record<string, any> | undefined;
components?: Record<string, Record<string, any>> | undefined;
sourceMap: Map<string, SourceLocation>;
/** Markdown heading names found in the document (e.g., 'Colors', 'Typography') */
sections?: string[] | undefined;
/** Full content of each section, including heading and body. */
documentSections?: Array<{ heading: string; content: string }> | undefined;
/** Raw YAML values for all top-level keys (known and unknown), used by lint rules. */
rawValues?: Record<string, unknown> | undefined;
}
/** Canonical top-level YAML keys per the DESIGN.md schema. */
export const SCHEMA_KEYS = [
'version',
'name',
'description',
'colors',
'typography',
'rounded',
'spacing',
'components',
] as const;
export type SchemaKey = typeof SCHEMA_KEYS[number];
// ── RESULT ─────────────────────────────────────────────────────────
export type ParserResult =
| { success: true; data: ParsedDesignSystem }
| {
success: false;
error: {
code: z.infer<typeof ParserErrorCode>;
message: string;
recoverable: boolean;
};
};
// ── INTERFACE ──────────────────────────────────────────────────────
export interface ParserSpec {
execute(input: ParserInput): ParserResult;
}
+208
View File
@@ -0,0 +1,208 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { writeFileSync, unlinkSync } from 'node:fs';
import {
loadSpecConfig,
getSpecConfig,
STANDARD_UNITS,
SECTIONS,
TYPOGRAPHY_PROPERTIES,
COMPONENT_SUB_TOKENS,
CORE_COLOR_ROLES,
RECOMMENDED_TOKENS,
EXAMPLES,
CANONICAL_ORDER,
SECTION_ALIASES,
resolveAlias,
VALID_TYPOGRAPHY_PROPS,
VALID_COMPONENT_SUB_TOKENS,
} from './spec-config.js';
// ── Loader robustness ─────────────────────────────────────────────────
describe('spec-config loader', () => {
it('throws when file does not exist', () => {
expect(() => loadSpecConfig('non-existent.yaml')).toThrow();
});
it('throws when YAML is malformed', () => {
const path = '__test_malformed.yaml';
writeFileSync(path, 'invalid: yaml: :');
try {
expect(() => loadSpecConfig(path)).toThrow();
} finally {
unlinkSync(path);
}
});
it('throws when required fields are missing', () => {
const path = '__test_incomplete.yaml';
writeFileSync(path, 'version: alpha\nunits: [px]');
try {
expect(() => loadSpecConfig(path)).toThrow();
} finally {
unlinkSync(path);
}
});
it('throws when sections array is empty', () => {
const path = '__test_empty_sections.yaml';
writeFileSync(path, [
'version: alpha',
'units: [px]',
'sections: []',
'typography_properties: [{name: x, type: y}]',
'component_sub_tokens: [{name: x, type: y}]',
'color_roles: [primary]',
'recommended_tokens: {a: [b]}',
'examples:',
' colors: {a: "#000"}',
' typography: {a: {fontFamily: x}}',
' components: {a: {bg: x}}',
].join('\n'));
try {
expect(() => loadSpecConfig(path)).toThrow();
} finally {
unlinkSync(path);
}
});
it('does not write to stdout or stderr', () => {
const originalLog = console.log;
const originalError = console.error;
const logs: string[] = [];
console.log = (...args: unknown[]) => logs.push(args.join(' '));
console.error = (...args: unknown[]) => logs.push(args.join(' '));
try {
loadSpecConfig();
expect(logs.length).toBe(0);
} finally {
console.log = originalLog;
console.error = originalError;
}
});
});
// ── Lazy loading ──────────────────────────────────────────────────────
describe('spec-config lazy loading', () => {
it('getSpecConfig returns a valid config object', () => {
const config = getSpecConfig();
expect(config.version).toBeString();
expect(config.units.length).toBeGreaterThan(0);
expect(config.sections.length).toBeGreaterThan(0);
});
it('getSpecConfig returns the same cached instance on subsequent calls', () => {
const first = getSpecConfig();
const second = getSpecConfig();
expect(first).toBe(second);
});
});
// ── Structural invariants ─────────────────────────────────────────────
// These never need updating when values change.
// They catch real bugs: duplicates, empty arrays, collision.
describe('spec-config structural invariants', () => {
it('sections are non-empty', () => {
expect(SECTIONS.length).toBeGreaterThan(0);
});
it('section canonical names are unique', () => {
const names = SECTIONS.map(s => s.canonical);
expect(new Set(names).size).toBe(names.length);
});
it('no alias collides with a canonical name', () => {
const canonicals = new Set(SECTIONS.map(s => s.canonical));
const aliases = SECTIONS.flatMap(s => s.aliases ?? []);
for (const alias of aliases) {
expect(canonicals.has(alias)).toBe(false);
}
});
it('aliases are unique across all sections', () => {
const aliases = SECTIONS.flatMap(s => s.aliases ?? []);
expect(new Set(aliases).size).toBe(aliases.length);
});
it('typography property names are unique', () => {
const names = TYPOGRAPHY_PROPERTIES.map(p => p.name);
expect(new Set(names).size).toBe(names.length);
});
it('component sub-token names are unique', () => {
const names = COMPONENT_SUB_TOKENS.map(p => p.name);
expect(new Set(names).size).toBe(names.length);
});
it('color roles are unique', () => {
expect(new Set(CORE_COLOR_ROLES).size).toBe(CORE_COLOR_ROLES.length);
});
it('units are non-empty and unique', () => {
expect(STANDARD_UNITS.length).toBeGreaterThan(0);
expect(new Set(STANDARD_UNITS).size).toBe(STANDARD_UNITS.length);
});
it('recommended token categories are non-empty', () => {
for (const [category, tokens] of Object.entries(RECOMMENDED_TOKENS)) {
expect(tokens.length).toBeGreaterThan(0);
}
});
it('examples covers colors, typography, and components', () => {
expect(Object.keys(EXAMPLES.colors).length).toBeGreaterThan(0);
expect(Object.keys(EXAMPLES.typography).length).toBeGreaterThan(0);
expect(Object.keys(EXAMPLES.components).length).toBeGreaterThan(0);
});
});
// ── Derived constants ─────────────────────────────────────────────────
describe('spec-config derived constants', () => {
it('CANONICAL_ORDER length matches SECTIONS length', () => {
expect(CANONICAL_ORDER.length).toBe(SECTIONS.length);
});
it('resolveAlias returns canonical for known alias', () => {
// Pick the first alias we can find
const sectionWithAlias = SECTIONS.find(s => s.aliases && s.aliases.length > 0);
const alias = sectionWithAlias?.aliases?.[0];
if (alias) {
expect(resolveAlias(alias)).toBe(sectionWithAlias.canonical);
}
});
it('resolveAlias returns input for unknown heading', () => {
expect(resolveAlias('NonExistentSection')).toBe('NonExistentSection');
});
it('VALID_TYPOGRAPHY_PROPS length matches TYPOGRAPHY_PROPERTIES', () => {
expect(VALID_TYPOGRAPHY_PROPS.length).toBe(TYPOGRAPHY_PROPERTIES.length);
});
it('VALID_COMPONENT_SUB_TOKENS length matches COMPONENT_SUB_TOKENS', () => {
expect(VALID_COMPONENT_SUB_TOKENS.length).toBe(COMPONENT_SUB_TOKENS.length);
});
it('SECTION_ALIASES maps every alias to a canonical name', () => {
for (const [alias, canonical] of Object.entries(SECTION_ALIASES)) {
expect(CANONICAL_ORDER).toContain(canonical);
}
});
});
+198
View File
@@ -0,0 +1,198 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse } from 'yaml';
import { z } from 'zod';
/**
* DESIGN.md Spec Configuration
*
* THE single source of truth for the DESIGN.md format specification.
* Both the linter and the spec generator read from this file.
*
* To change what the spec says:
* 1. Edit spec-config.yaml
* 2. Run `bun run spec:gen` to regenerate docs/spec.md
* 3. Run `bun test` to verify linter alignment
*/
// ── Schema ────────────────────────────────────────────────────────────
const PropertyDefSchema = z.object({
name: z.string(),
type: z.string(),
description: z.string().optional(),
});
const ConfigSchema = z.object({
version: z.string(),
limits: z.object({
max_token_nesting_depth: z.number().default(20),
max_reference_depth: z.number().default(10),
}).default({}),
units: z.array(z.string()).min(1),
sections: z.array(z.object({
canonical: z.string(),
aliases: z.array(z.string()).optional(),
})).min(1),
typography_properties: z.array(PropertyDefSchema).min(1),
component_sub_tokens: z.array(PropertyDefSchema).min(1),
color_roles: z.array(z.string()).min(1),
recommended_tokens: z.record(z.string(), z.array(z.string())),
examples: z.object({
colors: z.record(z.string(), z.string()),
typography: z.record(z.string(), z.record(z.string(), z.union([z.string(), z.number()]))),
components: z.record(z.string(), z.record(z.string(), z.string())),
}),
});
// ── Load & Validate ──────────────────────────────────────────────────
export function loadSpecConfig(filePath?: string) {
const currentDir = dirname(fileURLToPath(import.meta.url));
const yamlPath = filePath ? resolve(filePath) : resolve(currentDir, './spec-config.yaml');
const raw = parse(readFileSync(yamlPath, 'utf-8'));
return ConfigSchema.parse(raw);
}
// ── Lazy singleton ───────────────────────────────────────────────────
// Config is loaded on first access, not at module evaluation time.
// This prevents redundant file reads and provides a clean entry point
// for programmatic consumers who want to defer loading.
type ParsedConfig = ReturnType<typeof loadSpecConfig>;
let _cachedConfig: ParsedConfig | undefined;
/** Return the parsed spec config, loading and caching it on first call. */
export function getSpecConfig(): ParsedConfig {
if (!_cachedConfig) {
_cachedConfig = loadSpecConfig();
}
return _cachedConfig;
}
// ── Interfaces ───────────────────────────────────────────────────────
export interface SectionDef {
/** The canonical section heading. */
canonical: string;
/** Acceptable alternative headings that resolve to this section. */
aliases?: readonly string[] | undefined;
}
export interface TypographyPropertyDef {
/** Property name as it appears in YAML. */
name: string;
/** Human-readable type for the spec document. */
type: string;
/** Extended description for the spec (appears after the type). */
description?: string | undefined;
}
export interface ComponentSubTokenDef {
/** Sub-token property name. */
name: string;
/** The type displayed in the spec (e.g., 'Color', 'Dimension'). */
type: string;
/** Extended description for the spec (appears after the type). */
description?: string | undefined;
}
// ── Constant exports ─────────────────────────────────────────────────
// These are eagerly initialized from the lazy singleton on first import.
// The singleton cache ensures the YAML file is read exactly once.
const config = getSpecConfig();
/** Current spec version. Appears in the schema and the front matter example. */
export const SPEC_VERSION = config.version;
/** Performance and safety limits for the model handler. */
export const MAX_TOKEN_NESTING_DEPTH = config.limits.max_token_nesting_depth;
export const MAX_REFERENCE_DEPTH = config.limits.max_reference_depth;
/** Units the spec formally supports for Dimension values. */
export const STANDARD_UNITS = config.units;
export type StandardUnit = (typeof STANDARD_UNITS)[number];
export const SECTIONS = config.sections;
export const TYPOGRAPHY_PROPERTIES: readonly TypographyPropertyDef[] = config.typography_properties;
export const COMPONENT_SUB_TOKENS: readonly ComponentSubTokenDef[] = config.component_sub_tokens;
/** Core color roles that every design system should define. */
export const CORE_COLOR_ROLES = config.color_roles;
/** Non-normative recommended token names, organized by category. */
export const RECOMMENDED_TOKENS = config.recommended_tokens;
/** Canonical examples that appear in the generated spec document. */
export const EXAMPLES = config.examples;
// ── Derived constants ─────────────────────────────────────────────────
/** Ordered list of canonical section names. */
export const CANONICAL_ORDER = SECTIONS.map(s => s.canonical);
/** Map of alias → canonical name. */
export const SECTION_ALIASES: Record<string, string> = Object.fromEntries(
SECTIONS.flatMap(s =>
(s.aliases ?? []).map(alias => [alias, s.canonical])
)
);
/** Resolve a section heading to its canonical name. */
export function resolveAlias(heading: string): string {
return SECTION_ALIASES[heading] ?? heading;
}
/** Valid typography property names (for linter validation). */
export const VALID_TYPOGRAPHY_PROPS = TYPOGRAPHY_PROPERTIES.map(p => p.name);
/** Valid component sub-token names (for linter validation). */
export const VALID_COMPONENT_SUB_TOKENS = COMPONENT_SUB_TOKENS.map(p => p.name);
// ── Aggregate type ────────────────────────────────────────────────────
/** All config values bundled as a single object for renderer injection. */
export interface SpecConfig {
SPEC_VERSION: typeof SPEC_VERSION;
MAX_TOKEN_NESTING_DEPTH: typeof MAX_TOKEN_NESTING_DEPTH;
MAX_REFERENCE_DEPTH: typeof MAX_REFERENCE_DEPTH;
STANDARD_UNITS: typeof STANDARD_UNITS;
SECTIONS: typeof SECTIONS;
TYPOGRAPHY_PROPERTIES: typeof TYPOGRAPHY_PROPERTIES;
COMPONENT_SUB_TOKENS: typeof COMPONENT_SUB_TOKENS;
CORE_COLOR_ROLES: typeof CORE_COLOR_ROLES;
RECOMMENDED_TOKENS: typeof RECOMMENDED_TOKENS;
EXAMPLES: typeof EXAMPLES;
}
/** Build a SpecConfig from the module's exports. */
export const SPEC_CONFIG: SpecConfig = {
SPEC_VERSION,
MAX_TOKEN_NESTING_DEPTH,
MAX_REFERENCE_DEPTH,
STANDARD_UNITS,
SECTIONS,
TYPOGRAPHY_PROPERTIES,
COMPONENT_SUB_TOKENS,
CORE_COLOR_ROLES,
RECOMMENDED_TOKENS,
EXAMPLES,
};
+149
View File
@@ -0,0 +1,149 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# DESIGN.md Spec Configuration
# This file is the single source of truth for the DESIGN.md format specification.
# Edit this file, then run `bun run spec:gen` to regenerate docs/spec.md.
version: alpha
# Performance and safety limits for the model handler.
limits:
max_token_nesting_depth: 20
max_reference_depth: 10
units:
- px
- em
- rem
sections:
- canonical: Overview
aliases:
- Brand & Style
- canonical: Colors
- canonical: Typography
- canonical: Layout
aliases:
- Layout & Spacing
- canonical: Elevation & Depth
aliases:
- Elevation
- canonical: Shapes
- canonical: Components
- canonical: "Do's and Don'ts"
typography_properties:
- name: fontFamily
type: string
- name: fontSize
type: Dimension
- name: fontWeight
type: number
description: "A numeric font weight value (e.g., `400`, `700`). In YAML, this may be expressed as either a bare number or a quoted string; both are equivalent."
- name: lineHeight
type: "Dimension | number"
description: "Accepts either a Dimension (e.g., `24px`, `1.5rem`) or a unitless number (e.g., `1.6`). A unitless number represents a multiplier of the element's `fontSize`, which is the recommended CSS practice."
- name: letterSpacing
type: Dimension
- name: fontFeature
type: string
description: "configures\n [`font-feature-settings`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/font-feature-settings)."
- name: fontVariation
type: string
description: "configures\n [`font-variation-settings`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/font-variation-settings)."
component_sub_tokens:
- name: backgroundColor
type: Color
- name: textColor
type: Color
- name: typography
type: Typography
- name: rounded
type: Dimension
- name: padding
type: Dimension
- name: size
type: Dimension
- name: height
type: Dimension
- name: width
type: Dimension
color_roles:
- primary
- secondary
- tertiary
- neutral
recommended_tokens:
colors:
- primary
- secondary
- tertiary
- neutral
- surface
- on-surface
- error
typography:
- headline-display
- headline-lg
- headline-md
- body-lg
- body-md
- body-sm
- label-lg
- label-md
- label-sm
rounded:
- none
- sm
- md
- lg
- xl
- full
examples:
colors:
primary: "#1A1C1E"
secondary: "#6C7278"
tertiary: "#B8422E"
neutral: "#F7F5F2"
typography:
h1:
fontFamily: Public Sans
fontSize: 48px
fontWeight: 600
lineHeight: 1.1
letterSpacing: "-0.02em"
body-md:
fontFamily: Public Sans
fontSize: 16px
fontWeight: 400
lineHeight: 1.6
label-caps:
fontFamily: Space Grotesk
fontSize: 12px
fontWeight: 500
lineHeight: 1.0
letterSpacing: "0.1em"
components:
button-primary:
backgroundColor: "{colors.primary-60}"
textColor: "{colors.primary-20}"
rounded: "{rounded.md}"
padding: 12px
button-primary-hover:
backgroundColor: "{colors.primary-70}"
@@ -0,0 +1,88 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { compileMdx } from './compiler.js';
import { SPEC_CONFIG } from '../spec-config.js';
import * as renderers from './renderers.js';
describe('compileMdx', () => {
it('passes plain markdown through unchanged', async () => {
const input = '# Hello\n\nThis is a paragraph.\n';
const result = await compileMdx(input, {});
expect(result).toBe('# Hello\n\nThis is a paragraph.\n');
});
it('evaluates inline expressions', async () => {
const input = 'The answer is {1 + 1}.\n';
const result = await compileMdx(input, {});
expect(result).toContain('The answer is 2.');
});
it('evaluates expressions with scope variables', async () => {
const input = 'Valid units: {UNITS.join(", ")}.\n';
const result = await compileMdx(input, { UNITS: ['px', 'rem'] });
expect(result).toContain('Valid units: px, rem.');
});
it('evaluates block expressions that produce multi-line content', async () => {
const input = '# Items\n\n{ITEMS.map((item, i) => `${i + 1}. ${item}`).join("\\n")}\n';
const result = await compileMdx(input, { ITEMS: ['Alpha', 'Beta'] });
expect(result).toContain('1. Alpha');
expect(result).toContain('2. Beta');
});
it('strips import statements from output', async () => {
const input = 'import { X } from "./foo"\n\n# Title\n';
const result = await compileMdx(input, {});
expect(result).not.toContain('import');
expect(result).toContain('# Title');
});
it('preserves expressions inside fenced code blocks', async () => {
const input = '```yaml\ncolors:\n primary: "{colors.primary}"\n```\n';
const result = await compileMdx(input, {});
expect(result).toContain('{colors.primary}');
});
it('compiles the full spec.mdx with spec-config scope', async () => {
const mdxPath = resolve(import.meta.dir, 'spec.mdx');
const source = await readFile(mdxPath, 'utf-8');
const cfg = SPEC_CONFIG;
const scope = {
...cfg,
frontmatterExample: () => renderers.frontmatterExample(cfg),
colorsExample: () => renderers.colorsExample(cfg),
typographyExample: () => renderers.typographyExample(cfg),
componentsExample: () => renderers.componentsExample(cfg),
typographyPropertyList: () => renderers.typographyPropertyList(cfg),
sectionOrderList: () => renderers.sectionOrderList(cfg),
componentSubTokenList: () => renderers.componentSubTokenList(cfg),
recommendedTokens: () => renderers.recommendedTokens(cfg),
};
const result = await compileMdx(source, scope);
// Verify key config-driven content appears
expect(result).toContain('px, em, rem');
expect(result).toContain('**Overview**');
expect(result).toContain('**Components**');
expect(result).toContain('backgroundColor');
expect(result).toContain('`headline-display`');
expect(result).toContain('#1A1C1E');
});
});
@@ -0,0 +1,60 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkMdx from 'remark-mdx';
import remarkStringify from 'remark-stringify';
import { visit } from 'unist-util-visit';
import type { Root } from 'mdast';
export async function compileMdx(source: string, scope: Record<string, unknown>): Promise<string> {
const tree = unified()
.use(remarkParse)
.use(remarkMdx)
.parse(source) as Root;
// Evaluate MDX expression nodes and replace with text
visit(tree, (node, index, parent) => {
if (node.type === 'mdxTextExpression' || node.type === 'mdxFlowExpression') {
const expr = (node as any).value as string;
const fn = new Function(...Object.keys(scope), `return ${expr}`);
const result = String(fn(...Object.values(scope)));
if (node.type === 'mdxTextExpression') {
// Inline: replace with text node
(node as any).type = 'text';
(node as any).value = result;
} else {
// Block: use html node to preserve markdown formatting literally
(node as any).type = 'html';
(node as any).value = result;
}
}
});
// Remove import/export statements from the tree
visit(tree, 'mdxjsEsm', (_node, index, parent) => {
if (parent && index !== undefined) {
parent.children.splice(index, 1);
return index; // revisit this index since we removed a node
}
});
const file = unified()
.use(remarkStringify)
.stringify(tree);
return String(file);
}
@@ -0,0 +1,92 @@
#!/usr/bin/env bun
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* Generate docs/spec.md from docs/spec.mdx + spec-config.ts.
*
* Usage:
* bun run packages/linter/src/spec-gen/generate.ts
* bun run packages/linter/src/spec-gen/generate.ts --check
*/
import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { compileMdx } from './compiler.js';
import { SPEC_CONFIG } from '../spec-config.js';
import * as renderers from './renderers.js';
const ROOT = resolve(import.meta.dir, '../../../../../');
const MDX_PATH = resolve(import.meta.dir, 'spec.mdx');
const OUTPUT_PATH = resolve(ROOT, 'docs/spec.md');
const isCheck = process.argv.includes('--check');
async function main() {
const source = await readFile(MDX_PATH, 'utf-8');
// Scope: raw config values + renderer functions
const cfg = SPEC_CONFIG;
const scope = {
...cfg,
// Renderer functions — pre-bound to config so MDX calls are clean
frontmatterExample: () => renderers.frontmatterExample(cfg),
colorsExample: () => renderers.colorsExample(cfg),
typographyExample: () => renderers.typographyExample(cfg),
componentsExample: () => renderers.componentsExample(cfg),
typographyPropertyList: () => renderers.typographyPropertyList(cfg),
sectionOrderList: () => renderers.sectionOrderList(cfg),
componentSubTokenList: () => renderers.componentSubTokenList(cfg),
recommendedTokens: () => renderers.recommendedTokens(cfg),
};
const generated = await compileMdx(source, scope);
// Prepend header comment
const header = `<!-- Generated from spec.mdx + spec-config.ts | version: ${cfg.SPEC_VERSION} -->\n<!-- Do not edit directly. Run \`bun run spec:gen\` to regenerate. -->\n\n`;
const content = header + generated;
if (isCheck) {
const existing = await readFile(OUTPUT_PATH, 'utf-8');
// Strip header for comparison (contains no timestamp, but future-proof)
const stripHeader = (s: string) => s.replace(/^<!--.*-->\n/gm, '');
const existingBody = stripHeader(existing);
const generatedBody = stripHeader(content);
if (existingBody === generatedBody) {
console.log('✅ docs/spec.md is up to date.');
process.exit(0);
} else {
console.error('❌ docs/spec.md is out of date. Run `bun run spec:gen` to regenerate.');
const existingLines = existingBody.split('\n');
const generatedLines = generatedBody.split('\n');
for (let i = 0; i < Math.max(existingLines.length, generatedLines.length); i++) {
if (existingLines[i] !== generatedLines[i]) {
console.error(` First difference at line ${i + 1}:`);
console.error(` - existing: ${existingLines[i]?.slice(0, 100)}`);
console.error(` + generated: ${generatedLines[i]?.slice(0, 100)}`);
break;
}
}
process.exit(1);
}
}
await writeFile(OUTPUT_PATH, content);
console.log(`✅ Generated docs/spec.md (${content.split('\n').length} lines)`);
}
main();
@@ -0,0 +1,123 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* Spec renderers: pure functions that turn spec-config data into
* markdown fragments. Used by the MDX compiler via scope injection.
*
* Each function returns a ready-to-embed markdown string.
*/
import type { SpecConfig, TypographyPropertyDef, SectionDef, ComponentSubTokenDef } from '../spec-config.js';
// ── YAML code block helpers ─────────────────────────────────────
/** Render a fenced yaml code block from key-value entries. */
function yamlBlock(lines: string[]): string {
return ['```yaml', ...lines, '```'].join('\n');
}
function yamlEntries(entries: Record<string, string>, indent = 2): string[] {
return Object.entries(entries).map(
([k, v]) => `${' '.repeat(indent)}${k}: "${v}"`
);
}
function yamlObject(entries: Record<string, string | number>, indent = 4): string[] {
return Object.entries(entries).map(([k, v]) => {
const val = typeof v === 'string' && v.startsWith('{') ? `"${v}"` : v;
return `${' '.repeat(indent)}${k}: ${val}`;
});
}
// ── Public renderers ────────────────────────────────────────────
/** Front matter example (overview section). */
export function frontmatterExample(config: SpecConfig): string {
const [typoName, typoProps] = Object.entries(config.EXAMPLES.typography)[0]!;
return yamlBlock([
'---',
`version: ${config.SPEC_VERSION}`,
'name: Daylight Prestige',
'colors:',
...yamlEntries(
Object.fromEntries(Object.entries(config.EXAMPLES.colors).slice(0, 3)) as Record<string, string>
),
'typography:',
` ${typoName}:`,
...yamlObject(typoProps as Record<string, string | number>),
'---',
]);
}
/** Colors YAML example. */
export function colorsExample(config: SpecConfig): string {
return yamlBlock(['colors:', ...yamlEntries(config.EXAMPLES.colors)]);
}
/** Typography YAML example. */
export function typographyExample(config: SpecConfig): string {
const lines = ['typography:'];
for (const [name, props] of Object.entries(config.EXAMPLES.typography)) {
lines.push(` ${name}:`);
lines.push(...yamlObject(props as Record<string, string | number>));
}
return yamlBlock(lines);
}
/** Components YAML example. */
export function componentsExample(config: SpecConfig): string {
const lines = ['components:'];
for (const [name, props] of Object.entries(config.EXAMPLES.components)) {
lines.push(` ${name}:`);
lines.push(...yamlObject(props as Record<string, string | number>));
}
return yamlBlock(lines);
}
/** Typography property list (for the schema section). */
export function typographyPropertyList(config: SpecConfig): string {
return config.TYPOGRAPHY_PROPERTIES.map((p: TypographyPropertyDef) =>
p.description
? `- \`${p.name}\` (${p.type}) - ${p.description}`
: `- \`${p.name}\` (${p.type})`
).join('\n');
}
/** Numbered section order list with aliases. */
export function sectionOrderList(config: SpecConfig): string {
return config.SECTIONS.map((s: SectionDef, i: number) => {
const aliases = s.aliases?.length
? ` (also: ${s.aliases.map((a: string) => `"${a}"`).join(', ')})`
: '';
return `${i + 1}. **${s.canonical}**${aliases}`;
}).join('\n');
}
/** Component sub-token property list. */
export function componentSubTokenList(config: SpecConfig): string {
return config.COMPONENT_SUB_TOKENS
.map((t: ComponentSubTokenDef) => `- ${t.name}: \\<${t.type}\\>`)
.join('\n');
}
/** Recommended token names grouped by category. */
export function recommendedTokens(config: SpecConfig): string {
return Object.entries(config.RECOMMENDED_TOKENS)
.map(([cat, tokens]) => {
const label = cat.charAt(0).toUpperCase() + cat.slice(1);
return `**${label}:** ${(tokens as readonly string[]).map((t: string) => `\`${t}\``).join(', ')}`;
})
.join('\n\n');
}
@@ -0,0 +1,66 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { getRulesTable, getSpecContent } from './spec-helpers.js';
import type { RuleDescriptor } from '../linter/rules/types.js';
describe('getRulesTable', () => {
it('returns a markdown table with rule details', () => {
const rules: RuleDescriptor[] = [
{
name: 'rule-1',
severity: 'error',
description: 'Description 1',
run: (_state: any) => [],
},
{
name: 'rule-2',
severity: 'warning',
description: 'Description 2',
run: (_state: any) => [],
},
];
const table = getRulesTable(rules);
expect(table).toContain('| Rule | Severity | What it checks |');
expect(table).toContain('| rule-1 | error | Description 1 |');
expect(table).toContain('| rule-2 | warning | Description 2 |');
});
});
describe('getSpecContent', () => {
it('returns spec content with expected heading', () => {
const content = getSpecContent();
expect(content).toContain('# DESIGN.md Format');
});
it('returns consistent content on repeated calls', () => {
const first = getSpecContent();
const second = getSpecContent();
expect(first).toBe(second);
});
it('content has substantial length (not a stub)', () => {
const content = getSpecContent();
// The spec doc is a real document, at least a few KB
expect(content.length).toBeGreaterThan(1000);
});
it('accepts an explicit specPath override', () => {
// This tests the explicit-path contract. If someone passes a path,
// it should use that path exactly — no guessing.
expect(() => getSpecContent('/nonexistent/fake.md')).toThrow();
});
});
@@ -0,0 +1,76 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { RuleDescriptor } from '../linter/rules/types.js';
/**
* Load the DESIGN.md format specification document.
*
* @param specPath - Explicit absolute path to spec.md. When provided, this
* path is used directly with no fallback. Useful for tests and codegen
* scripts that know exactly where the file lives.
*
* When no path is given, resolution uses two deterministic strategies:
* 1. Bundle path: <currentDir>/spec.md — the build copies docs/spec.md here.
* 2. Dev path: <repo>/docs/spec.md — relative from src/linter/spec-gen/.
*
* This replaces the previous 5-candidate shotgun approach with clear,
* auditable paths that work across OSes and execution contexts.
*/
export function getSpecContent(specPath?: string): string {
// Explicit path: use it or fail. No guessing.
if (specPath) {
return readFileSync(specPath, 'utf-8');
}
const currentDir = dirname(fileURLToPath(import.meta.url));
// Strategy 1: Bundled spec.md alongside the executing module.
// After `bun run build`, spec.md is copied to dist/ and dist/linter/.
const bundledPath = resolve(currentDir, 'spec.md');
try {
return readFileSync(bundledPath, 'utf-8');
} catch {
// Not a bundle context — fall through to dev path.
}
// Strategy 2: Development — spec.md lives at <repo>/docs/spec.md.
// From src/linter/spec-gen/ that's ../../../docs/spec.md (3 levels up to packages/cli/).
// Then 2 more to the repo root, then into docs/.
const devPath = resolve(currentDir, '../../../../../docs/spec.md');
try {
return readFileSync(devPath, 'utf-8');
} catch {
throw new Error(
`Failed to load spec.md.\n` +
` Bundled path: ${bundledPath}\n` +
` Dev path: ${devPath}\n` +
`If running from a built bundle, ensure the build script copies docs/spec.md into dist/.`
);
}
}
export function getRulesTable(rules: RuleDescriptor[]): string {
let table = '| Rule | Severity | What it checks |\n';
table += '|------|----------|----------------|\n';
for (const rule of rules) {
table += `| ${rule.name} | ${rule.severity} | ${rule.description} |\n`;
}
return table;
}
+289
View File
@@ -0,0 +1,289 @@
import { SPEC_VERSION, STANDARD_UNITS, SECTIONS, TYPOGRAPHY_PROPERTIES, COMPONENT_SUB_TOKENS, CORE_COLOR_ROLES, RECOMMENDED_TOKENS, EXAMPLES } from '../spec-config.js'
import { frontmatterExample, colorsExample, typographyExample, componentsExample, typographyPropertyList, sectionOrderList, componentSubTokenList, recommendedTokens } from './renderers.js'
# DESIGN.md Format
DESIGN.md is a self-contained, plain-text representation of a design system. It defines the visual identity of a brand and product, thereby ensuring that these stylistic choices can be followed across design sessions and between different AI agents and tools. As a human-readable, open-format document, it serves as a living source of truth that both humans and AI can understand and refine.
A DESIGN.md file contains two parts: An optional YAML frontmatter, and a markdown body. The YAML front matter contains machine-readable design tokens. The markdown body sections provide human-readable design rationale and guidance. Prose may use descriptive color names (e.g., "Midnight Forest Green") that correspond to systematic token names (e.g., `primary`). The tokens are the normative values; the prose provides context for how to apply them.
# Design Tokens
DESIGN.md may embed design tokens in a structured format. The system that we use to describe design tokens is inspired by the
[Design Token JSON spec](https://www.designtokens.org/tr/2025.10/format/#abstract). Specifically, we adopt the concept of typed token groups (colors, typography, spacing) and the `{path.to.token}` reference syntax for cross-referencing values.
These tokens are easily converted from or to `tokens.json`, Figma variables, and Tailwind theme configs.
Design tokens are embedded as YAML front matter at the beginning of the file. The front matter block must begin with a line containing exactly `---` and end with a line containing exactly `---`. The YAML content between these delimiters is parsed according to the schema defined below.
Example:
{frontmatterExample()}
## Schema
Below is the schema for the design tokens defined in the front matter:
```yaml
version: <string> # optional, current version: "alpha"
name: <string>
description: <string> # optional
colors:
<token-name>: <Color>
typography:
<token-name>: <Typography>
rounded:
<scale-level>: <Dimension>
spacing:
<scale-level>: <Dimension | number>
components:
<component-name>:
<token-name>: <string|token reference>
```
The `<scale-level>` placeholder represents a named level in a sizing or spacing scale. Common level names include `xs`, `sm`, `md`, `lg`, `xl`, and `full`. Any descriptive string key is valid.
**Color**: A color value is any valid CSS color string. Supported formats include:
- Hex: `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`
- Named colors: `red`, `cornflowerblue`, `transparent`
- Functional: `rgb()`, `rgba()`, `hsl()`, `hsla()`, `hwb()`
- Wide-gamut: `oklch()`, `oklab()`, `lch()`, `lab()`
- Mixing: `color-mix(in srgb, ...)`
All color values are internally converted to sRGB for WCAG contrast checking. The original format is preserved for display and export.
Hex notation (`#RRGGBB`) remains the recommended default for simplicity and broad tooling support.
{typographyPropertyList()}
**Dimension**: A dimension value is a string with a unit suffix. Valid units are: {STANDARD_UNITS.join(', ')}.
**Token References**: A token reference must be wrapped in curly braces, and contain an object path to another value in the YAML tree. For most token groups, the reference must point to a primitive value (e.g., `colors.primary-60`), not a group (e.g., `colors`). Within the `components` section, references to composite values (e.g., `{typography.label-md}`) are permitted.
# Sections
Every `DESIGN.md` follows the same structure. Sections can be omitted if they're not relevant to your project, but those present should appear in the sequence listed below. All sections use `<h2>` (`##`) headings. An optional `<h1>` heading may appear for document titling purposes but is not parsed as a section.
### Section Order
{sectionOrderList()}
### Prose and Tokens
## Overview
Also known as "Brand & Style".
This section is a holistic description of a product's look and feel. It defines the brand personality, target audience, and the emotional response the UI should evoke, such as whether it should feel playful or professional, dense or spacious. It serves as foundational context for guiding the agent's high-level stylistic decisions when a specific rule or token isn't explicitly defined.
## Colors
This section defines the color palettes for the design system.
At least the `primary` color palette must be defined, and additional color palettes may be defined as needed.
When there are multiple color palettes, the design system may assign a semantic role for each palette. A common convention is to name the palettes in this order: `primary`, `secondary`, `tertiary`, and `neutral`.
Example:
```markdown
## Colors
The palette is rooted in high-contrast neutrals and a single, evocative accent color.
- **Primary (#1A1C1E):** A deep ink used for headlines and core text to provide
maximum readability and a sense of permanence.
- **Secondary (#6C7278):** A sophisticated slate used primarily for utilitarian
elements like borders, captions, and metadata.
- **Tertiary (#B8422E):** A vibrant earthy red as the sole driver for
interaction, used exclusively for primary actions and critical highlights.
- **Neutral (#F7F5F2):** A warm limestone that serves as the foundation for all
pages, providing a softer, more organic feel than pure white.
```
### Design Tokens
The `colors` section defines all color design tokens. The color tokens should be derived from the key color palettes defined in the markdown prose. The exact mapping from color palettes to color tokens may follow any consistent naming convention.
It is a
map\<string, Color\>, that maps the name of the color token to its value.
{colorsExample()}
## Typography
This section defines typography levels.
Most design systems have 9 - 15 typography levels. The design system may prescribe a role for each typography level.
A common naming convention for typography levels is to use semantic categories such as `headline`, `display`, `body`, `label`, `caption`. Each category may further be divided into different sizes, such as `small`, `medium`, and `large`.
Example:
```markdown
## Typography
The typography strategy leverages two distinct weights of **Public Sans** for
the narrative and **Space Grotesk** for technical data.
- **Headlines:** Set in Public Sans Semi-Bold to establish an institutional
and trustworthy voice.
- **Body:** Public Sans Regular at 16px ensures contemporary professionalism
and long-form readability.
- **Labels:** Space Grotesk is used for all technical data, timestamps, and
metadata. Its geometric construction evokes the precision of a digital
stopwatch. Labels are strictly uppercase with generous letter spacing.
```
### Design Tokens
The `typography` section defines the precise font properties for the typography design tokens.
It is a
map\<string, Typography\>
{typographyExample()}
## Layout
Also known as "Layout & Spacing".
This section describes the layout and spacing strategy.
Many design systems follow a grid-based layout. Others, like Liquid Glass, use margins, safe areas, and dynamic padding.
Example:
```markdown
## Layout
The layout follows a **Fluid Grid** model for mobile devices and a
**Fixed-Max-Width Grid** for desktop (max 1200px).
A strict 8px spacing scale (with a 4px half-step for micro-adjustments) is used to maintain a consistent rhythm. Components are grouped using "containment" principles, where related items are housed in cards with generous internal padding (24px) to emphasize the soft, approachable nature of the brand.
```
### Design Tokens
The spacing section defines the spacing design tokens. These may include spacing units that are useful for implementing the layout model. For example, a fixed grid layout may have spacing units for column spans, gutters, and margins.
It is a
map\<string, Dimension | number\> that maps the spacing scale identifier to a dimension value or a unitless number (e.g., column counts or ratios).
```yaml
spacing:
base: 16px
xs: 4px
sm: 8px
md: 16px
lg: 32px
xl: 64px
gutter: 24px
margin: 32px
```
## Elevation & Depth
Also known as "Elevation".
This section describes how visual hierarchy is conveyed based on the design style. If elevation is used, it defines the required styling (spread, blur, color). For flat designs, this section explains the alternative methods used to convey visual hierarchy (e.g., borders, color contrast).
Example:
```markdown
## Elevation & Depth
Depth is achieved through **Tonal Layers** rather than heavy shadows. The
background uses a soft off-white or very light green, while primary content sits on pure white cards.
```
## Shapes
This section describes how visual elements are shaped.
Example:
```markdown
## Shapes
The shape language is defined by **Architectural Sharpness**. All interactive
elements, containers, and inputs utilize a minimal **4px corner radius**. This
provides just enough softness to feel modern while maintaining a rigid,
engineered aesthetic.
```
### Design Tokens
The `rounded` section defines the design tokens for rounded corners used in
buttons, cards, and other rectangular shapes.
It is a map\<string, Dimension\>.
```yaml
rounded:
sm: 4px
md: 8px
lg: 12px
full: 9999px
```
## Components
This section provides style guidance for component atoms within the design system. The following are common component types. Design systems are encouraged to define additional components relevant to their domain.
- **Buttons**: Covers primary, secondary, and tertiary variants, including sizing, padding, and states.
- **Chips**: Covers selection chips, filter chips, and action chips.
- **Lists**: Covers styling for list items, dividers, and leading/trailing elements.
- **Tooltips**: Covers positioning, colors, and timing.
- **Checkboxes**: Covers checked, unchecked, and indeterminate states.
- **Radio buttons**: Covers selected and unselected states.
- **Input fields**: Covers text inputs, text areas, labels, helper text, and error states.
> **Note:** The components specification is actively evolving. The current structure provides intentional flexibility for domain-specific component definitions while the spec matures.
### Design Tokens
The components section defines a collection of design tokens used to ensure consistent styling of common components. It's a map\<string, map\<string, string\>\> that maps a component identifier to a group of sub token names and values. The design token values may be literal values, or references to previously defined design tokens.
**Variants**. A component may have a variant for different UI states such as active, hover, pressed, etc. Those variant components may be defined under a different but related key, for example, "button-primary", "button-primary-hover", "button-primary-active". The agent will consider all variants and make the appropriate styling decisions.
{componentsExample()}
### Component Property Tokens
Each component has a set of properties that are themselves design tokens:
{componentSubTokenList()}
## Do's and Don'ts
This section provides practical guidelines and common pitfalls. These act as guardrails when creating designs.
```markdown
## Do's and Don'ts
- Do use the primary color only for the single most important action per screen
- Don't mix rounded and sharp corners in the same view
- Do maintain WCAG AA contrast ratios (4.5:1 for normal text)
- Don't use more than two font weights on a single screen
```
# Recommended Token Names (Non-Normative)
The following names are commonly used across design systems. They are not required but are provided as guidance for consistency.
{recommendedTokens()}
# Consumer Behavior for Unknown Content
When a DESIGN.md consumer encounters content not defined by this spec:
| Scenario | Behavior | Example |
|---|---|---|
| Unknown section heading | Preserve; do not error | `## Iconography` |
| Unknown color token name | Accept if value is valid | `surface-container-high: '#ede7dd'` |
| Unknown typography token name | Accept as valid typography | `telemetry-data` |
| Unknown spacing value | Accept; store as string if not a valid dimension | `grid-columns: '5'` |
| Unknown component property | Accept with warning | `borderColor` |
| Duplicate section heading | Error; reject the file | Two `## Colors` headings |
@@ -0,0 +1,115 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { TailwindEmitterHandler } from './handler.js';
import { ModelHandler } from '../model/handler.js';
import type { ParsedDesignSystem } from '../parser/spec.js';
const emitter = new TailwindEmitterHandler();
const modelHandler = new ModelHandler();
function buildState(overrides: Partial<ParsedDesignSystem> = {}) {
const parsed: ParsedDesignSystem = { sourceMap: new Map(), ...overrides };
const result = modelHandler.execute(parsed);
const hasErrors = result.findings.some(d => d.severity === 'error');
if (hasErrors) {
throw new Error(`Model build failed: ${result.findings.map(d => d.message).join(', ')}`);
}
return result.designSystem;
}
describe('TailwindEmitterHandler', () => {
// ── Cycle 22: Colors map to theme.extend.colors ─────────────────
describe('colors mapping', () => {
it('maps resolved colors to theme.extend.colors', () => {
const state = buildState({
colors: { primary: '#647D66', secondary: '#ff0000' },
});
const result = emitter.execute(state);
if (!result.success) throw new Error('Expected success');
const config = result.data;
expect(config.theme.extend.colors?.['primary']).toBe('#647d66');
expect(config.theme.extend.colors?.['secondary']).toBe('#ff0000');
});
});
// ── Cycle 23: Typography maps to fontFamily + fontSize ──────────
describe('typography mapping', () => {
it('maps typography scales to fontFamily and fontSize', () => {
const state = buildState({
typography: {
'headline-lg': {
fontFamily: 'Google Sans Display',
fontSize: '42px',
fontWeight: 500,
lineHeight: '50px',
letterSpacing: '1.2px',
},
'body-lg': {
fontFamily: 'Roboto',
fontSize: '14px',
fontWeight: 400,
lineHeight: '20px',
},
},
});
const result = emitter.execute(state);
if (!result.success) throw new Error('Expected success');
const config = result.data;
// fontFamily
expect(config.theme.extend.fontFamily?.['headline-lg']).toContain('Google Sans Display');
expect(config.theme.extend.fontFamily?.['body-lg']).toContain('Roboto');
// fontSize with metadata tuple
const hlFontSize = config.theme.extend.fontSize?.['headline-lg'];
expect(hlFontSize).toBeDefined();
expect(hlFontSize?.[0]).toBe('42px');
expect(hlFontSize?.[1]?.['lineHeight']).toBe('50px');
expect(hlFontSize?.[1]?.['letterSpacing']).toBe('1.2px');
});
});
// ── Cycle 24: Rounded + spacing map correctly ───────────────────
describe('dimensions mapping', () => {
it('maps rounded to borderRadius and spacing to spacing', () => {
const state = buildState({
rounded: { regular: '4px', lg: '8px', full: '9999px' },
spacing: { 'gutter-s': '8px', 'gutter-l': '16px' },
});
const result = emitter.execute(state);
if (!result.success) throw new Error('Expected success');
const config = result.data;
expect(config.theme.extend.borderRadius?.['regular']).toBe('4px');
expect(config.theme.extend.borderRadius?.['lg']).toBe('8px');
expect(config.theme.extend.borderRadius?.['full']).toBe('9999px');
expect(config.theme.extend.spacing?.['gutter-s']).toBe('8px');
expect(config.theme.extend.spacing?.['gutter-l']).toBe('16px');
});
});
// ── Empty state produces empty config ─────────────────────────────
describe('empty state', () => {
it('produces a valid config with empty extend sections', () => {
const state = buildState({});
const result = emitter.execute(state);
if (!result.success) throw new Error('Expected success');
const config = result.data;
expect(config.theme.extend).toBeDefined();
});
});
});
@@ -0,0 +1,83 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { TailwindEmitterSpec, TailwindEmitterResult } from './spec.js';
import type { DesignSystemState, ResolvedDimension } from '../model/spec.js';
/**
* Pure function mapping DesignSystemState → Tailwind theme.extend config.
* No side effects.
*/
export class TailwindEmitterHandler implements TailwindEmitterSpec {
execute(state: DesignSystemState): TailwindEmitterResult {
return {
success: true,
data: {
theme: {
extend: {
colors: this.mapColors(state),
fontFamily: this.mapFontFamilies(state),
fontSize: this.mapFontSizes(state),
borderRadius: this.mapDimensions(state.rounded),
spacing: this.mapDimensions(state.spacing),
},
},
}
};
}
private mapColors(state: DesignSystemState): Record<string, string> {
const result: Record<string, string> = {};
for (const [name, color] of state.colors) {
result[name] = color.hex;
}
return result;
}
private mapFontFamilies(state: DesignSystemState): Record<string, string[]> {
const result: Record<string, string[]> = {};
for (const [name, typo] of state.typography) {
if (typo.fontFamily) {
result[name] = [typo.fontFamily];
}
}
return result;
}
private mapFontSizes(state: DesignSystemState): Record<string, [string, Record<string, string>]> {
const result: Record<string, [string, Record<string, string>]> = {};
for (const [name, typo] of state.typography) {
if (typo.fontSize) {
const meta: Record<string, string> = {};
if (typo.lineHeight) meta['lineHeight'] = this.dimToString(typo.lineHeight);
if (typo.letterSpacing) meta['letterSpacing'] = this.dimToString(typo.letterSpacing);
if (typo.fontWeight !== undefined) meta['fontWeight'] = String(typo.fontWeight);
result[name] = [this.dimToString(typo.fontSize), meta];
}
}
return result;
}
private mapDimensions(dims: Map<string, { value: number; unit: string }>): Record<string, string> {
const result: Record<string, string> = {};
for (const [name, dim] of dims) {
result[name] = this.dimToString(dim);
}
return result;
}
private dimToString(dim: { value: number; unit: string }): string {
return `${dim.value}${dim.unit}`;
}
}
+55
View File
@@ -0,0 +1,55 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { z } from 'zod';
import type { Config } from 'tailwindcss';
import type { DesignSystemState } from '../model/spec.js';
// ── TAILWIND CONFIG SCHEMA ──────────────────────────────────────────
export const TailwindThemeExtendSchema = z.object({
colors: z.record(z.string()).optional(),
fontFamily: z.record(z.array(z.string())).optional(),
fontSize: z.record(z.tuple([z.string(), z.record(z.string())])).optional(),
borderRadius: z.record(z.string()).optional(),
spacing: z.record(z.string()).optional(),
});
export type TailwindThemeExtend = z.infer<typeof TailwindThemeExtendSchema>;
export const TailwindEmitterResultSchema = z.discriminatedUnion('success', [
z.object({
success: z.literal(true),
data: z.object({
theme: z.object({
extend: TailwindThemeExtendSchema
})
})
}),
z.object({
success: z.literal(false),
error: z.object({
code: z.string(),
message: z.string()
})
})
]);
export type TailwindEmitterResult = z.infer<typeof TailwindEmitterResultSchema>;
// ── INTERFACE ──────────────────────────────────────────────────────
export interface TailwindEmitterSpec {
execute(state: DesignSystemState): TailwindEmitterResult;
}
@@ -0,0 +1,47 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { lint } from '../../lint.js';
import { TailwindV4EmitterHandler } from './handler.js';
import { serializeToCss } from './serialize.js';
describe('Tailwind v4 export against real fixtures', () => {
it('produces a valid @theme block from examples/paws-and-paths/DESIGN.md', () => {
const fixturePath = join(import.meta.dir, '../../../../../../examples/paws-and-paths/DESIGN.md');
const content = readFileSync(fixturePath, 'utf8');
const report = lint(content);
const handler = new TailwindV4EmitterHandler();
const result = handler.execute(report.designSystem);
if (!result.success) throw new Error(`Handler failed: ${result.error.message}`);
const css = serializeToCss(result.data.theme);
// Wraps in @theme block
expect(css.startsWith('@theme {\n')).toBe(true);
expect(css.endsWith('}\n')).toBe(true);
// Contains expected v4 CSS variable prefixes (paws-and-paths has many colors + typography + spacing)
expect(css).toContain('--color-');
expect(css).toContain('--spacing-');
expect(css).toContain('--radius-');
// Non-empty body
const bodyLines = css.split('\n').filter(l => l.trim().startsWith('--'));
expect(bodyLines.length).toBeGreaterThan(10);
});
});
@@ -0,0 +1,165 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { TailwindV4EmitterHandler } from './handler.js';
import { ModelHandler } from '../../model/handler.js';
import type { ParsedDesignSystem } from '../../parser/spec.js';
const emitter = new TailwindV4EmitterHandler();
const modelHandler = new ModelHandler();
function buildState(overrides: Partial<ParsedDesignSystem> = {}) {
const parsed: ParsedDesignSystem = { sourceMap: new Map(), ...overrides };
const result = modelHandler.execute(parsed);
const hasErrors = result.findings.some(d => d.severity === 'error');
if (hasErrors) {
throw new Error(`Model build failed: ${result.findings.map(d => d.message).join(', ')}`);
}
return result.designSystem;
}
describe('TailwindV4EmitterHandler', () => {
describe('colors mapping', () => {
it('maps resolved colors to theme.colors keyed by token name', () => {
const state = buildState({
colors: { primary: '#647D66', secondary: '#ff0000' },
});
const result = emitter.execute(state);
if (!result.success) throw new Error('Expected success');
expect(result.data.theme.colors?.['primary']).toBe('#647d66');
expect(result.data.theme.colors?.['secondary']).toBe('#ff0000');
});
});
describe('typography mapping', () => {
it('splits typography into four separate categories', () => {
const state = buildState({
typography: {
'headline-lg': {
fontFamily: 'Google Sans Display',
fontSize: '42px',
fontWeight: 500,
lineHeight: '50px',
letterSpacing: '1.2px',
},
},
});
const result = emitter.execute(state);
if (!result.success) throw new Error('Expected success');
const theme = result.data.theme;
expect(theme.fontFamily?.['headline-lg']).toBe('"Google Sans Display"');
expect(theme.fontSize?.['headline-lg']).toBe('42px');
expect(theme.lineHeight?.['headline-lg']).toBe('50px');
expect(theme.letterSpacing?.['headline-lg']).toBe('1.2px');
expect(theme.fontWeight?.['headline-lg']).toBe('500');
});
it('only populates categories for fields present on the token', () => {
const state = buildState({
typography: {
'body-lg': {
fontFamily: 'Roboto',
fontSize: '14px',
fontWeight: 400,
lineHeight: '20px',
},
},
});
const result = emitter.execute(state);
if (!result.success) throw new Error('Expected success');
const theme = result.data.theme;
expect(theme.fontFamily?.['body-lg']).toBe('"Roboto"');
expect(theme.fontSize?.['body-lg']).toBe('14px');
expect(theme.lineHeight?.['body-lg']).toBe('20px');
expect(theme.fontWeight?.['body-lg']).toBe('400');
expect(theme.letterSpacing?.['body-lg']).toBeUndefined();
});
it('escapes embedded quotes and backslashes in font-family values', () => {
const state = buildState({
typography: {
fancy: { fontFamily: 'Fancy "Font"', fontSize: '16px' },
},
});
const result = emitter.execute(state);
if (!result.success) throw new Error('Expected success');
expect(result.data.theme.fontFamily?.['fancy']).toBe('"Fancy \\"Font\\""');
});
});
describe('dimensions mapping', () => {
it('maps rounded to borderRadius and spacing to spacing', () => {
const state = buildState({
rounded: { regular: '4px', lg: '8px', full: '9999px' },
spacing: { 'gutter-s': '8px', 'gutter-l': '16px' },
});
const result = emitter.execute(state);
if (!result.success) throw new Error('Expected success');
const theme = result.data.theme;
expect(theme.borderRadius?.['regular']).toBe('4px');
expect(theme.borderRadius?.['lg']).toBe('8px');
expect(theme.borderRadius?.['full']).toBe('9999px');
expect(theme.spacing?.['gutter-s']).toBe('8px');
expect(theme.spacing?.['gutter-l']).toBe('16px');
});
});
describe('empty state', () => {
it('returns success with an empty theme object', () => {
const state = buildState({});
const result = emitter.execute(state);
if (!result.success) throw new Error('Expected success');
expect(result.data.theme).toBeDefined();
});
});
describe('invalid token names', () => {
it('fails when a color token name contains a dot', () => {
const state = buildState({
colors: { primary: '#000000' },
});
// Inject an invalid name directly into the Map to simulate an invalid token
state.colors.set('primary.surface', {
type: 'color', hex: '#ffffff', r: 255, g: 255, b: 255, luminance: 1,
});
const result = emitter.execute(state);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe('INVALID_TOKEN_NAME');
expect(result.error.message).toContain('primary.surface');
});
it('allows Tailwind-style dimension token names that start with a digit', () => {
const state = buildState({});
state.spacing.set('2xs', { type: 'dimension', value: 2, unit: 'px' });
state.rounded.set('4xl', { type: 'dimension', value: 32, unit: 'px' });
const result = emitter.execute(state);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.theme.spacing?.['2xs']).toBe('2px');
expect(result.data.theme.borderRadius?.['4xl']).toBe('32px');
});
it('fails when a token name contains a space', () => {
const state = buildState({});
state.rounded.set('has space', { type: 'dimension', value: 4, unit: 'px' });
const result = emitter.execute(state);
expect(result.success).toBe(false);
});
});
});
@@ -0,0 +1,106 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { TailwindV4EmitterSpec, TailwindV4EmitterResult, TailwindV4ThemeData } from './spec.js';
import type { DesignSystemState, ResolvedDimension } from '../../model/spec.js';
const VALID_TOKEN_NAME = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/;
/**
* Pure function mapping DesignSystemState → Tailwind v4 theme data.
* Token names are validated against CSS-identifier rules. Font-family values
* are wrapped in double quotes, with embedded `"` and `\` escaped.
*/
export class TailwindV4EmitterHandler implements TailwindV4EmitterSpec {
execute(state: DesignSystemState): TailwindV4EmitterResult {
const theme: TailwindV4ThemeData = {};
// Validate every token name before emitting anything.
const allNames: string[] = [
...state.colors.keys(),
...state.typography.keys(),
...state.rounded.keys(),
...state.spacing.keys(),
];
for (const name of allNames) {
if (!VALID_TOKEN_NAME.test(name)) {
return {
success: false,
error: {
code: 'INVALID_TOKEN_NAME',
message: `Token name "${name}" is not a valid CSS identifier for Tailwind v4 export (must match /^[a-zA-Z0-9][a-zA-Z0-9-]*$/).`,
},
};
}
}
// Colors
if (state.colors.size > 0) {
const colors: Record<string, string> = {};
for (const [name, color] of state.colors) {
colors[name] = color.hex;
}
theme.colors = colors;
}
// Typography — split into 4 sibling categories
const fontFamily: Record<string, string> = {};
const fontSize: Record<string, string> = {};
const lineHeight: Record<string, string> = {};
const letterSpacing: Record<string, string> = {};
const fontWeight: Record<string, string> = {};
for (const [name, typo] of state.typography) {
if (typo.fontFamily) fontFamily[name] = cssStringLiteral(typo.fontFamily);
if (typo.fontSize) fontSize[name] = dimToString(typo.fontSize);
if (typo.lineHeight) lineHeight[name] = dimToString(typo.lineHeight);
if (typo.letterSpacing) letterSpacing[name] = dimToString(typo.letterSpacing);
if (typo.fontWeight !== undefined) fontWeight[name] = String(typo.fontWeight);
}
if (Object.keys(fontFamily).length > 0) theme.fontFamily = fontFamily;
if (Object.keys(fontSize).length > 0) theme.fontSize = fontSize;
if (Object.keys(lineHeight).length > 0) theme.lineHeight = lineHeight;
if (Object.keys(letterSpacing).length > 0) theme.letterSpacing = letterSpacing;
if (Object.keys(fontWeight).length > 0) theme.fontWeight = fontWeight;
// borderRadius + spacing
if (state.rounded.size > 0) {
theme.borderRadius = mapDimensions(state.rounded);
}
if (state.spacing.size > 0) {
theme.spacing = mapDimensions(state.spacing);
}
return { success: true, data: { theme } };
}
}
function mapDimensions(dims: Map<string, ResolvedDimension>): Record<string, string> {
const out: Record<string, string> = {};
for (const [name, dim] of dims) {
out[name] = dimToString(dim);
}
return out;
}
function dimToString(dim: ResolvedDimension): string {
return `${dim.value}${dim.unit}`;
}
/**
* Wrap a string value in double quotes, escaping embedded `\` and `"`.
* Produces a CSS-safe string literal suitable for font-family values.
*/
function cssStringLiteral(value: string): string {
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
@@ -0,0 +1,96 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { serializeToCss } from './serialize.js';
import type { TailwindV4ThemeData } from './spec.js';
describe('serializeToCss', () => {
it('emits an empty @theme block for empty data', () => {
const out = serializeToCss({});
expect(out).toBe('@theme {\n}\n');
});
it('emits a single color declaration', () => {
const data: TailwindV4ThemeData = { colors: { primary: '#647d66' } };
expect(serializeToCss(data)).toBe('@theme {\n --color-primary: #647d66;\n}\n');
});
it('uses the correct CSS-variable prefix per category', () => {
const data: TailwindV4ThemeData = {
colors: { primary: '#000000' },
fontFamily: { 'headline-lg': '"Roboto"' },
fontSize: { 'headline-lg': '42px' },
lineHeight: { 'headline-lg': '50px' },
letterSpacing: { 'headline-lg': '1.2px' },
fontWeight: { 'headline-lg': '500' },
borderRadius: { regular: '4px' },
spacing: { 'gutter-s': '8px' },
};
const out = serializeToCss(data);
expect(out).toContain('--color-primary: #000000;');
expect(out).toContain('--font-headline-lg: "Roboto";');
expect(out).toContain('--text-headline-lg: 42px;');
expect(out).toContain('--leading-headline-lg: 50px;');
expect(out).toContain('--tracking-headline-lg: 1.2px;');
expect(out).toContain('--font-weight-headline-lg: 500;');
expect(out).toContain('--radius-regular: 4px;');
expect(out).toContain('--spacing-gutter-s: 8px;');
});
it('emits categories in fixed order: colors → fontFamily → fontSize → lineHeight → letterSpacing → fontWeight → borderRadius → spacing', () => {
const data: TailwindV4ThemeData = {
spacing: { s: '8px' },
colors: { primary: '#000000' },
borderRadius: { r: '4px' },
fontFamily: { f: '"X"' },
};
const out = serializeToCss(data);
const colorIdx = out.indexOf('--color-primary');
const fontFamilyIdx = out.indexOf('--font-f');
const radiusIdx = out.indexOf('--radius-r');
const spacingIdx = out.indexOf('--spacing-s');
expect(colorIdx).toBeLessThan(fontFamilyIdx);
expect(fontFamilyIdx).toBeLessThan(radiusIdx);
expect(radiusIdx).toBeLessThan(spacingIdx);
});
it('skips empty categories (no blank lines)', () => {
const data: TailwindV4ThemeData = {
colors: { primary: '#000000' },
fontFamily: {},
spacing: { s: '8px' },
};
const out = serializeToCss(data);
// no blank body lines
expect(out).not.toMatch(/\n\s*\n/);
expect(out).toBe('@theme {\n --color-primary: #000000;\n --spacing-s: 8px;\n}\n');
});
it('preserves insertion order of keys within a category', () => {
const colors: Record<string, string> = {};
colors['zulu'] = '#000000';
colors['alpha'] = '#ffffff';
const out = serializeToCss({ colors });
expect(out.indexOf('--color-zulu')).toBeLessThan(out.indexOf('--color-alpha'));
});
it('emits font-family values verbatim (already quoted)', () => {
const data: TailwindV4ThemeData = {
fontFamily: { body: '"Google Sans Display"' },
};
const out = serializeToCss(data);
expect(out).toContain('--font-body: "Google Sans Display";');
});
});
@@ -0,0 +1,45 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { TailwindV4ThemeData } from './spec.js';
// Category → CSS-variable prefix. Iteration order of this array is the output order.
const CATEGORIES: ReadonlyArray<readonly [keyof TailwindV4ThemeData, string]> = [
['colors', '--color-'],
['fontFamily', '--font-'],
['fontSize', '--text-'],
['lineHeight', '--leading-'],
['letterSpacing', '--tracking-'],
['fontWeight', '--font-weight-'],
['borderRadius', '--radius-'],
['spacing', '--spacing-'],
];
/**
* Serialize a Tailwind v4 theme data object to a CSS `@theme { ... }` block string.
* Pure function — no I/O. Values are emitted verbatim (font-family quoting must
* be done by the handler before calling this).
*/
export function serializeToCss(data: TailwindV4ThemeData): string {
const lines: string[] = [];
for (const [category, prefix] of CATEGORIES) {
const entries = data[category];
if (!entries) continue;
for (const [name, value] of Object.entries(entries)) {
lines.push(` ${prefix}${name}: ${value};`);
}
}
if (lines.length === 0) return '@theme {\n}\n';
return `@theme {\n${lines.join('\n')}\n}\n`;
}
@@ -0,0 +1,55 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { z } from 'zod';
import type { DesignSystemState } from '../../model/spec.js';
// ── TAILWIND v4 THEME DATA SCHEMA ────────────────────────────────────
// Category-keyed record of CSS-variable name → value strings.
// The serializer flattens this into an `@theme { ... }` block.
export const TailwindV4ThemeDataSchema = z.object({
colors: z.record(z.string()).optional(),
fontFamily: z.record(z.string()).optional(),
fontSize: z.record(z.string()).optional(),
lineHeight: z.record(z.string()).optional(),
letterSpacing: z.record(z.string()).optional(),
fontWeight: z.record(z.string()).optional(),
borderRadius: z.record(z.string()).optional(),
spacing: z.record(z.string()).optional(),
});
export type TailwindV4ThemeData = z.infer<typeof TailwindV4ThemeDataSchema>;
export const TailwindV4EmitterResultSchema = z.discriminatedUnion('success', [
z.object({
success: z.literal(true),
data: z.object({
theme: TailwindV4ThemeDataSchema,
}),
}),
z.object({
success: z.literal(false),
error: z.object({
code: z.string(),
message: z.string(),
}),
}),
]);
export type TailwindV4EmitterResult = z.infer<typeof TailwindV4EmitterResultSchema>;
// ── INTERFACE ──────────────────────────────────────────────────────
export interface TailwindV4EmitterSpec {
execute(state: DesignSystemState): TailwindV4EmitterResult;
}
+167
View File
@@ -0,0 +1,167 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect, spyOn } from 'bun:test';
import { readInput, FileReadError, formatOutput } from './utils.js';
import type { StdinStream } from './utils.js';
function makeStdin(content: string, isTTY: boolean): StdinStream {
async function* gen() { yield Buffer.from(content); }
return Object.assign(gen(), { isTTY });
}
describe('readInput', () => {
it('throws FileReadError when file does not exist', async () => {
const err = await readInput('/nonexistent-path/DESIGN.md').catch(e => e);
expect(err).toBeInstanceOf(FileReadError);
});
it('FileReadError carries the missing file path', async () => {
const err = await readInput('/nonexistent-path/DESIGN.md').catch(e => e);
expect((err as FileReadError).filePath).toBe('/nonexistent-path/DESIGN.md');
});
it('FileReadError carries the underlying OS error message', async () => {
const err = await readInput('/nonexistent-path/DESIGN.md').catch(e => e);
expect((err as FileReadError).message).toContain('ENOENT');
});
it('friendlyMessage says "not found" for ENOENT', async () => {
const err = await readInput('/nonexistent-path/DESIGN.md').catch(e => e);
expect((err as FileReadError).friendlyMessage).toContain('not found');
});
it('friendlyMessage says "permission denied" for EACCES', () => {
const cause = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
const err = new FileReadError('/some/file.md', cause);
expect(err.friendlyMessage).toContain('permission denied');
expect(err.friendlyMessage).not.toContain('not found');
});
it('friendlyMessage falls back to the raw message for unknown errors', () => {
const cause = Object.assign(new Error('ENOMEM: out of memory'), { code: 'ENOMEM' });
const err = new FileReadError('/some/file.md', cause);
expect(err.friendlyMessage).toContain('ENOMEM');
});
});
describe('readInput stdin ("-")', () => {
it('reads content from a piped stream', async () => {
const result = await readInput('-', makeStdin('hello world', false));
expect(result).toBe('hello world');
});
it('returns empty string for an empty piped stream', async () => {
async function* empty() {}
const result = await readInput('-', Object.assign(empty(), { isTTY: false }));
expect(result).toBe('');
});
it('writes a hint to stderr when stdin is a TTY', async () => {
const stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true);
try {
await readInput('-', makeStdin('some content', true));
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining('Press Ctrl+D')
);
} finally {
stderrSpy.mockRestore();
}
});
it('does not write a hint when stdin is not a TTY', async () => {
const stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true);
try {
await readInput('-', makeStdin('piped content', false));
expect(stderrSpy).not.toHaveBeenCalled();
} finally {
stderrSpy.mockRestore();
}
});
});
describe('formatOutput', () => {
describe('--format markdown', () => {
it('renders a lint report instead of [object Object]', () => {
const lintOutput = {
findings: [
{ severity: 'warning', path: 'colors.surface', message: "'surface' is defined but never referenced." },
{ severity: 'info', message: 'Design system defines 10 colors.' },
],
summary: { errors: 0, warnings: 1, infos: 1 },
};
const result = formatOutput(lintOutput, { format: 'markdown' });
expect(result).not.toContain('[object Object]');
expect(result).toContain('# Lint Report');
expect(result).toContain('**0 errors**');
expect(result).toContain('**1 warnings**');
expect(result).toContain('**1 infos**');
expect(result).toContain("'surface' is defined but never referenced.");
expect(result).toContain('`colors.surface`');
});
it('renders findings without a path', () => {
const lintOutput = {
findings: [
{ severity: 'info', message: 'Token count summary.' },
],
summary: { errors: 0, warnings: 0, infos: 1 },
};
const result = formatOutput(lintOutput, { format: 'markdown' });
expect(result).toContain('- **info**: Token count summary.');
});
it('renders an empty findings section when there are none', () => {
const lintOutput = {
findings: [],
summary: { errors: 0, warnings: 0, infos: 0 },
};
const result = formatOutput(lintOutput, { format: 'markdown' });
expect(result).toContain('# Lint Report');
expect(result).not.toContain('## Findings');
});
it('handles the --format md alias', () => {
const lintOutput = {
findings: [],
summary: { errors: 0, warnings: 0, infos: 0 },
};
const result = formatOutput(lintOutput, { format: 'md' });
expect(result).toContain('# Lint Report');
});
it('preserves legacy fixer shape with string summary', () => {
const fixerOutput = {
summary: 'Fixed 3 issues',
details: 'Some details here',
};
const result = formatOutput(fixerOutput, { format: 'markdown' });
expect(result).toContain('# Fixed 3 issues');
expect(result).toContain('## Details');
});
});
describe('default format (JSON)', () => {
it('returns valid JSON', () => {
const data = { findings: [], summary: { errors: 0, warnings: 0, infos: 0 } };
const result = formatOutput(data, {});
expect(JSON.parse(result)).toEqual(data);
});
});
});
+201
View File
@@ -0,0 +1,201 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { readFileSync } from 'node:fs';
export type StdinStream = AsyncIterable<Buffer | string> & { isTTY?: boolean };
export class FileReadError extends Error {
readonly code = 'FILE_READ_ERROR' as const;
constructor(public readonly filePath: string, cause: unknown) {
super(cause instanceof Error ? cause.message : String(cause), { cause });
this.name = 'FileReadError';
}
get friendlyMessage(): string {
const errCode = (this.cause as { code?: string })?.code;
if (errCode === 'ENOENT') {
return `"${this.filePath}" not found. Create a DESIGN.md file or pass "-" to read from stdin.`;
}
if (errCode === 'EACCES') {
return `"${this.filePath}" could not be read: permission denied.`;
}
return `"${this.filePath}" could not be read: ${this.message}`;
}
}
/**
* Read input from a file path or stdin ("-").
* Throws FileReadError if the file cannot be read.
*/
export async function readInput(filePath: string, stdin: StdinStream = process.stdin): Promise<string> {
if (filePath === '-') {
if (stdin.isTTY) {
process.stderr.write('Reading from stdin… Press Ctrl+D when done.\n');
}
const chunks: Buffer[] = [];
for await (const chunk of stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString('utf-8');
}
try {
return readFileSync(filePath, 'utf-8');
} catch (error) {
throw new FileReadError(filePath, error);
}
}
/**
* Format output as JSON or human-readable text.
*/
export function formatOutput(data: unknown, args: { format?: string }): string {
if (args.format === 'markdown' || args.format === 'md') {
return formatAsMarkdown(data);
}
return JSON.stringify(data, null, 2);
}
function formatAsMarkdown(data: unknown): string {
if (typeof data !== 'object' || data === null) {
return String(data);
}
const obj = data as Record<string, unknown>;
// Lint output: { findings: [...], summary: { errors, warnings, infos } }
if (isLintOutput(obj)) {
return formatLintAsMarkdown(obj);
}
// Legacy fixer/diff shape: { summary: string, details?, patches? }
let result = '';
if (typeof obj.summary === 'string') {
result += `# ${obj.summary}\n\n`;
}
if (obj.details) {
result += `## Details\n\n`;
result += formatAsText(obj.details);
result += '\n';
}
if (obj.patches && Array.isArray(obj.patches) && obj.patches.length > 0) {
result += `## Patches\n\n`;
result += formatAsText(obj.patches);
result += '\n';
}
return result || formatAsText(data);
}
interface LintSummary {
errors: number;
warnings: number;
infos: number;
}
interface LintFinding {
severity: string;
message: string;
path?: string;
}
function isLintOutput(obj: Record<string, unknown>): boolean {
if (!Array.isArray(obj.findings)) return false;
if (typeof obj.summary !== 'object' || obj.summary === null) return false;
const s = obj.summary as Record<string, unknown>;
return typeof s.errors === 'number'
&& typeof s.warnings === 'number'
&& typeof s.infos === 'number';
}
function formatLintAsMarkdown(obj: Record<string, unknown>): string {
const summary = obj.summary as LintSummary;
const findings = obj.findings as LintFinding[];
let result = '# Lint Report\n\n';
result += `**${summary.errors} errors**, **${summary.warnings} warnings**, **${summary.infos} infos**\n`;
if (findings.length > 0) {
result += '\n## Findings\n\n';
for (const f of findings) {
const location = f.path ? ` \`${f.path}\`:` : ':';
result += `- **${f.severity}**${location} ${f.message}\n`;
}
}
return result;
}
function formatAsText(data: unknown, indent = 0): string {
if (data === null || data === undefined) return 'null';
if (typeof data === 'string') return data;
if (typeof data === 'number' || typeof data === 'boolean') return String(data);
if (Array.isArray(data)) {
return data.map(item => `${' '.repeat(indent)}- ${formatAsText(item, indent + 1)}`).join('\n');
}
if (typeof data === 'object') {
return Object.entries(data as Record<string, unknown>)
.map(([key, val]) => {
const valStr = typeof val === 'object' && val !== null
? '\n' + formatAsText(val, indent + 1)
: ' ' + formatAsText(val, indent + 1);
return `${' '.repeat(indent)}${key}:${valStr}`;
})
.join('\n');
}
return String(data);
}
/**
* Serialize a Map-based DesignSystemState to plain objects for JSON output.
*/
export function serializeDesignSystem(state: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(state)) {
if (value instanceof Map) {
result[key] = Object.fromEntries(value);
} else {
result[key] = value;
}
}
return result;
}
/**
* Diff two Maps — returns added, removed, and modified keys.
*/
export function diffMaps<V>(
before: Map<string, V>,
after: Map<string, V>,
): { added: string[]; removed: string[]; modified: string[] } {
const added: string[] = [];
const removed: string[] = [];
const modified: string[] = [];
for (const key of after.keys()) {
if (!before.has(key)) {
added.push(key);
} else if (JSON.stringify(before.get(key)) !== JSON.stringify(after.get(key))) {
modified.push(key);
}
}
for (const key of before.keys()) {
if (!after.has(key)) {
removed.push(key);
}
}
return { added, removed, modified };
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { VERSION } from './version.js';
const currentDir = dirname(fileURLToPath(import.meta.url));
describe('VERSION', () => {
it('matches package.json version exactly', () => {
const pkgPath = resolve(currentDir, '../package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
expect(VERSION).toBe(pkg.version);
});
it('is a valid semver string', () => {
expect(VERSION).toMatch(/^\d+\.\d+\.\d+/);
});
it('is not the fallback value', () => {
// If the path resolution is broken, VERSION would fall back to '0.0.0'.
// This test catches "works on my machine" failures where the relative
// path from the executing module to package.json is wrong.
expect(VERSION).not.toBe('0.0.0');
});
});
+40
View File
@@ -0,0 +1,40 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* Package version, sourced from the nearest package.json at runtime.
*
* Path resolution is stable across all execution contexts:
* Source (src/version.ts) → ../package.json = packages/cli/package.json
* Bundle (dist/index.js) → ../package.json = packages/cli/package.json
* Installed (node_modules/…) → ../package.json = <pkg root>/package.json
* npx cache → ../package.json = <cache pkg>/package.json
*
* npm always includes package.json in the published tarball, so '../package.json'
* relative to the dist/ entry point is universally valid.
*/
export const VERSION: string = (() => {
try {
const currentDir = dirname(fileURLToPath(import.meta.url));
const pkgPath = resolve(currentDir, '../package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
return pkg.version ?? '0.0.0';
} catch {
return '0.0.0';
}
})();

Some files were not shown because too many files have changed in this diff Show More