Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,23 @@ npx @google/design.md export --format dtcg DESIGN.md > tokens.json
| `file` | positional | required | Path to DESIGN.md (or `-` for stdin) |
| `--format` | `tailwind` \| `dtcg` | required | Output format |

### `import`

Generate a `DESIGN.md` from an existing Node.js project by statically analyzing its design sources. Framework detection (Next.js, Nuxt, Vite, SvelteKit, Remix, Astro, Create React App, Gatsby, Angular, Vue CLI, generic Node) is cosmetic — parsing is deterministic and source-based. Sources scanned: `tailwind.config.{js,ts,cjs,mjs}`, global CSS custom properties (`:root { --* }`), and any DTCG `tokens.json` / `design_tokens.json` files. No AI or LLM is involved.

```bash
npx @google/design.md import ./my-app # writes ./my-app/DESIGN.md
npx @google/design.md import ./my-app --dryRun # prints to stdout
npx @google/design.md import ./my-app --format json # NDJSON progress events
```

| Option | Type | Default | Description |
|:-------|:-----|:--------|:------------|
| `input` | positional | required | Path to the project root to scan |
| `--output` | string | `<input>/DESIGN.md` | Where to write the generated DESIGN.md |
| `--dryRun` | boolean | `false` | Print to stdout instead of writing |
| `--format` | `pretty` \| `json` | `pretty` | Progress output style |

### `spec`

Output the DESIGN.md format specification (useful for injecting spec context into agent prompts).
Expand Down
128 changes: 114 additions & 14 deletions bun.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"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/",
"build": "bun build src/index.ts src/linter/index.ts --outdir dist --target node --external ink --external react --external react-devtools-core && bunx 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/",
"dev": "bun run src/index.ts",
"test": "bun test",
"spec:gen": "bun run src/linter/spec-gen/generate.ts",
Expand Down Expand Up @@ -65,6 +65,7 @@
"@types/node": "^20.11.24",
"@types/react": "^19.2.14",
"bun-types": "^1.3.12",
"ink-testing-library": "^4.0.0",
"tailwindcss": "3",
"typescript": "^5.7.3"
}
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/commands/import.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

import { describe, it, expect } from 'bun:test';
import importCommand from './import.js';

describe('import command metadata', () => {
it('has name "import" and required args', async () => {
const meta = typeof importCommand.meta === 'function'
? await importCommand.meta()
: importCommand.meta;
const args = typeof importCommand.args === 'function'
? await importCommand.args()
: importCommand.args;

expect(meta?.name).toBe('import');
expect(args?.input).toBeDefined();
expect(args?.output).toBeDefined();
expect(args?.dryRun).toBeDefined();
expect(args?.format).toBeDefined();
});
});
115 changes: 115 additions & 0 deletions packages/cli/src/commands/import.ts
Original file line number Diff line number Diff line change
@@ -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 { resolve } from 'node:path';
import React from 'react';
import { render } from 'ink';
import { defineCommand } from 'citty';
import { runImport } from '../importer/index.js';
import { ImportProgress } from '../importer/ui.js';
import type { ImportStep } from '../importer/spec.js';
import { sanitizeError } from '../importer/error-sanitize.js';

export default defineCommand({
meta: {
name: 'import',
description:
'Generate DESIGN.md from a Node.js project by scanning framework configs and design tokens.',
},
args: {
input: {
type: 'positional',
description: 'Path to the project root to scan',
required: true,
},
output: {
type: 'string',
description:
'Output path for generated DESIGN.md (default: <input>/DESIGN.md)',
},
dryRun: {
type: 'boolean',
description: 'Print generated DESIGN.md to stdout without writing',
default: false,
},
format: {
type: 'string',
description:
'Progress output: "pretty" (Ink UI) or "json" (machine-readable events)',
default: 'pretty',
},
verbose: {
type: 'boolean',
description:
'Include path-redacted error messages on failure. Default emits only error codes.',
default: false,
},
},
async run({ args }) {
const projectPath = resolve(args.input);
const outputPath = args.output ? resolve(args.output) : undefined;
const dryRun = Boolean(args.dryRun);
const jsonMode = args.format === 'json';

const steps: ImportStep[] = [];
let inkApp: ReturnType<typeof render> | null = null;

const onStep = (step: ImportStep): void => {
steps.push(step);
if (jsonMode) {
process.stdout.write(JSON.stringify(step) + '\n');
return;
}
if (inkApp) {
inkApp.rerender(
React.createElement(ImportProgress, { steps: [...steps], done: false, dryRun }),
);
}
};

if (!jsonMode) {
inkApp = render(
React.createElement(ImportProgress, { steps, done: false, dryRun }),
);
}

try {
const result = await runImport({ projectPath, outputPath, dryRun, onStep });

if (inkApp) {
inkApp.rerender(
React.createElement(ImportProgress, { steps: [...steps], done: true, dryRun }),
);
inkApp.unmount();
}

if (dryRun) {
process.stdout.write(result.markdown);
}

process.exitCode = result.success ? 0 : 1;
} catch (err) {
if (inkApp) inkApp.unmount();
// Default emits only `{error: {code}}` — no freeform message that
// could disclose filesystem layout or internal state. With
// --verbose the message is included but still path-redacted.
process.stderr.write(
JSON.stringify({
error: sanitizeError(err, { includeMessage: Boolean(args.verbose) }),
}) + '\n',
);
process.exitCode = 1;
}
},
});
48 changes: 48 additions & 0 deletions packages/cli/src/importer/color-math.ts
Original file line number Diff line number Diff line change
@@ -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 type { ResolvedColor } from '../linter/model/spec.js';
import { isValidColor } from '../linter/model/spec.js';

function expandShortHex(clean: string): string {
return clean.length === 3 || clean.length === 4
? clean.split('').map((c) => c + c).join('')
: clean;
}

function srgbChannelToLinear(v: number): number {
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}

/**
* Convert a hex color to ResolvedColor (RGB normalized 0-1 + WCAG relative
* luminance). Returns null for non-hex input.
*/
export function hexToResolvedColor(raw: string): ResolvedColor | null {
if (!isValidColor(raw)) return null;
const clean = raw.startsWith('#') ? raw.slice(1) : raw;
const expand = expandShortHex(clean);
const r = parseInt(expand.slice(0, 2), 16) / 255;
const g = parseInt(expand.slice(2, 4), 16) / 255;
const b = parseInt(expand.slice(4, 6), 16) / 255;
const luminance =
0.2126 * srgbChannelToLinear(r) +
0.7152 * srgbChannelToLinear(g) +
0.0722 * srgbChannelToLinear(b);
const out: ResolvedColor = { type: 'color', hex: raw, r, g, b, luminance };
if (expand.length === 8) {
out.a = parseInt(expand.slice(6, 8), 16) / 255;
}
return out;
}
136 changes: 136 additions & 0 deletions packages/cli/src/importer/css-var-parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

import { describe, it, expect } from 'bun:test';
import { parseCssVariablesFromString } from './css-var-parser.js';

describe('parseCssVariablesFromString', () => {
it('classifies --color-* as colors', () => {
const p = parseCssVariablesFromString(':root { --color-primary: #ff0000; --color-bg: #fff; }');
expect(p.colors?.get('color-primary')?.hex).toBe('#ff0000');
expect(p.colors?.get('color-bg')?.hex).toBe('#fff');
});

it('classifies --space-* as spacing and --radius-* as rounded', () => {
const p = parseCssVariablesFromString(':root { --space-md: 16px; --radius-lg: 1rem; }');
expect(p.spacing?.get('space-md')?.value).toBe(16);
expect(p.rounded?.get('radius-lg')?.value).toBe(1);
expect(p.rounded?.get('radius-lg')?.unit).toBe('rem');
});

it('falls back to name heuristic for bare values', () => {
const p = parseCssVariablesFromString(':root { --brand: #112233; --gutter: 8px; }');
expect(p.colors?.get('brand')?.hex).toBe('#112233');
expect(p.spacing?.get('gutter')?.value).toBe(8);
});

it('ignores non-token variables', () => {
const p = parseCssVariablesFromString(':root { --animation-duration: 200ms; --z-modal: 1000; }');
// 200ms is a dimension with unit ms → lands in spacing (generic bucket).
// --z-modal is bare number — not a dimension → ignored.
expect(p.colors?.size ?? 0).toBe(0);
expect(p.spacing?.get('z-modal')).toBeUndefined();
});

it('handles multiple :root blocks', () => {
const p = parseCssVariablesFromString(':root { --color-a: #aaa; } :root { --color-b: #bbb; }');
expect(p.colors?.size).toBe(2);
});

it('ignores var() references and keyword values', () => {
const p = parseCssVariablesFromString(':root { --foo: var(--bar); --auto: auto; }');
expect((p.colors?.size ?? 0) + (p.spacing?.size ?? 0) + (p.rounded?.size ?? 0)).toBe(0);
});

describe('Tailwind v4 @theme blocks', () => {
it('extracts --color-* tokens and strips the prefix', () => {
const p = parseCssVariablesFromString(
'@theme { --color-primary: #112233; --color-dp-border: #2a2d42; }',
);
expect(p.colors?.get('primary')?.hex).toBe('#112233');
expect(p.colors?.get('dp-border')?.hex).toBe('#2a2d42');
});

it('extracts --spacing-* tokens and strips the prefix', () => {
const p = parseCssVariablesFromString('@theme { --spacing-md: 16px; --spacing-bs-1: 2.5px; }');
expect(p.spacing?.get('md')?.value).toBe(16);
expect(p.spacing?.get('bs-1')?.value).toBe(2.5);
});

it('routes --radius-* to rounded', () => {
const p = parseCssVariablesFromString('@theme { --radius-sm: 6px; --radius-lg: 1rem; }');
expect(p.rounded?.get('sm')?.value).toBe(6);
expect(p.rounded?.get('lg')?.unit).toBe('rem');
});

it('captures --font-* families into typography', () => {
const p = parseCssVariablesFromString(
"@theme { --font-sans: 'DM Sans', 'Inter', system-ui, sans-serif; --font-mono: 'JetBrains Mono', monospace; }",
);
expect(p.typography?.get('sans')?.fontFamily).toBe('DM Sans');
expect(p.typography?.get('mono')?.fontFamily).toBe('JetBrains Mono');
});

it('captures --text-*, --leading-*, --tracking-*, --font-weight-* into typography', () => {
const p = parseCssVariablesFromString(
'@theme { --text-base: 16px; --leading-base: 24px; --tracking-tight: -0.02em; --font-weight-bold: 700; }',
);
expect(p.typography?.get('base')?.fontSize?.value).toBe(16);
expect(p.typography?.get('base')?.lineHeight?.value).toBe(24);
expect(p.typography?.get('tight')?.letterSpacing?.value).toBe(-0.02);
expect(p.typography?.get('bold')?.fontWeight).toBe(700);
});

it('skips --breakpoint-* (not a design-system section)', () => {
const p = parseCssVariablesFromString(
'@theme { --breakpoint-sm: 640px; --breakpoint-lg: 1000px; }',
);
expect(p.spacing?.size ?? 0).toBe(0);
expect(p.rounded?.size ?? 0).toBe(0);
});

it('parses @theme inline { } the same as @theme { }', () => {
const p = parseCssVariablesFromString('@theme inline { --color-brand: #f00; }');
expect(p.colors?.get('brand')?.hex).toBe('#f00');
});

it('handles @theme and :root in the same file without duplication', () => {
const p = parseCssVariablesFromString(`
@theme {
--color-primary: #00ff88;
--spacing-md: 16px;
}
:root {
--topbar-h: 48px;
--color-primary: #fallback;
}
`);
// :root contributes a name-heuristic bucket with its raw key
expect(p.spacing?.get('md')?.value).toBe(16);
expect(p.spacing?.get('topbar-h')?.value).toBe(48);
// @theme wins on the bare name, :root adds "color-primary" literally if valid
expect(p.colors?.get('primary')?.hex).toBe('#00ff88');
});

it('parses the dexpaprika-style @theme block end-to-end', () => {
const src = `
@theme {
--color-dp-border: #2a2d42;
--color-dp-body-bg: #050507;
--spacing-bs-1: 2.5px;
--spacing-bs-5: 30px;
--font-sans: 'DM Sans', 'Inter', system-ui, sans-serif;
--breakpoint-sm: 640px;
}
`;
const p = parseCssVariablesFromString(src);
expect(p.colors?.get('dp-border')?.hex).toBe('#2a2d42');
expect(p.colors?.get('dp-body-bg')?.hex).toBe('#050507');
expect(p.spacing?.get('bs-1')?.value).toBe(2.5);
expect(p.spacing?.get('bs-5')?.value).toBe(30);
expect(p.typography?.get('sans')?.fontFamily).toBe('DM Sans');
// breakpoint-sm is ignored
expect(p.spacing?.has('sm')).toBe(false);
});
});
});
Loading