diff --git a/docs/spec.md b/docs/spec.md index 41b6d085..719d97b2 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -9,7 +9,7 @@ A DESIGN.md file contains two parts: An optional YAML frontmatter, and a markdow # 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.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. @@ -41,19 +41,19 @@ typography: Below is the schema for the design tokens defined in the front matter: ```yaml -version: # optional, current version: "alpha" -name: -description: # optional -colors: - : -typography: - : -rounded: - : -spacing: - : -components: - : +version: # optional, current version: "alpha" +name: +description: # optional +colors: + : +typography: + : +rounded: + : +spacing: + : +components: + : : ``` @@ -61,11 +61,11 @@ The `` placeholder represents a named level in a sizing or spacing **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, ...)` +- 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. @@ -119,17 +119,17 @@ When there are multiple color palettes, the design system may assign a semantic 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 +## 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. ``` @@ -137,7 +137,7 @@ The palette is rooted in high-contrast neutrals and a single, evocative accent c 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 +It is a map\, that maps the name of the color token to its value. ```yaml @@ -159,17 +159,17 @@ A common naming convention for typography levels is to use semantic categories s 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 +## 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. ``` @@ -177,7 +177,7 @@ the narrative and **Space Grotesk** for technical data. The `typography` section defines the precise font properties for the typography design tokens. -It is a +It is a map\ ```yaml @@ -212,11 +212,11 @@ Many design systems follow a grid-based layout. Others, like Liquid Glass, use m Example: ```markdown -## Layout - -The layout follows a **Fluid Grid** model for mobile devices and a -**Fixed-Max-Width Grid** for desktop (max 1200px). - +## 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. ``` @@ -224,18 +224,18 @@ A strict 8px spacing scale (with a 4px half-step for micro-adjustments) is used 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 +It is a map\ 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 +spacing: + base: 16px + xs: 4px + sm: 8px + md: 16px + lg: 32px + xl: 64px + gutter: 24px margin: 32px ``` @@ -248,9 +248,9 @@ This section describes how visual hierarchy is conveyed based on the design styl Example: ```markdown -## Elevation & Depth - -Depth is achieved through **Tonal Layers** rather than heavy shadows. The +## 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. ``` @@ -261,26 +261,26 @@ 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, +## 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 +The `rounded` section defines the design tokens for rounded corners used in buttons, cards, and other rectangular shapes. It is a map\. ```yaml -rounded: - sm: 4px - md: 8px - lg: 12px +rounded: + sm: 4px + md: 8px + lg: 12px full: 9999px ``` @@ -333,11 +333,11 @@ Each component has a set of properties that are themselves design tokens: 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) +## 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 ``` @@ -355,11 +355,11 @@ The following names are commonly used across design systems. They are not requir 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` | +| 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 | diff --git a/packages/cli/src/linter/spec-config.test.ts b/packages/cli/src/linter/spec-config.test.ts index 0115b371..a54d6ff8 100644 --- a/packages/cli/src/linter/spec-config.test.ts +++ b/packages/cli/src/linter/spec-config.test.ts @@ -1,208 +1,233 @@ -// 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); - } - }); -}); +// 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, + PRIMITIVE_TYPES, +} 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]', + 'types: {Color: {description: x}}', + '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); + } + }); +}); + +// ── PRIMITIVE_TYPES ─────────────────────────────────────────────────── + +describe('spec-config PRIMITIVE_TYPES', () => { + it('contains Color and Dimension entries', () => { + expect(PRIMITIVE_TYPES).toHaveProperty('Color'); + expect(PRIMITIVE_TYPES).toHaveProperty('Dimension'); + }); + + it('every type has a non-empty description', () => { + for (const [, def] of Object.entries(PRIMITIVE_TYPES)) { + expect(def.description.length).toBeGreaterThan(0); + } + }); + + it('Color has at least one format entry', () => { + expect(PRIMITIVE_TYPES['Color']!.formats?.length).toBeGreaterThan(0); + }); + + it('Dimension has no formats list (units come from STANDARD_UNITS)', () => { + expect(PRIMITIVE_TYPES['Dimension']!.formats).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/linter/spec-config.ts b/packages/cli/src/linter/spec-config.ts index b146a47b..76b7e2b7 100644 --- a/packages/cli/src/linter/spec-config.ts +++ b/packages/cli/src/linter/spec-config.ts @@ -1,198 +1,219 @@ -// 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; -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 = 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, -}; +// 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 TypeDefSchema = z.object({ + description: z.string(), + formats: z.array(z.string()).optional(), + note: 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), + types: z.record(z.string(), TypeDefSchema), + 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; +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; +} + +export interface TypeDef { + /** Human-readable description of the type. */ + description: string; + /** List of accepted format strings, rendered as bullets. */ + formats?: readonly string[]; + /** Follow-up note rendered as a separate paragraph. May contain \n\n for multiple paragraphs. */ + note?: string; +} + +// ── 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; + +/** Primitive type definitions (Color, Dimension, etc.) for the spec document. */ +export const PRIMITIVE_TYPES: Record = config.types; + +// ── 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 = 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; + PRIMITIVE_TYPES: typeof PRIMITIVE_TYPES; +} + +/** 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, + PRIMITIVE_TYPES, +}; diff --git a/packages/cli/src/linter/spec-config.yaml b/packages/cli/src/linter/spec-config.yaml index 67dfa143..92cbb801 100644 --- a/packages/cli/src/linter/spec-config.yaml +++ b/packages/cli/src/linter/spec-config.yaml @@ -1,149 +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. - -# 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}" +# 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 + +types: + Color: + description: "A color value is any valid CSS color string. Supported formats include:" + formats: + - "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, ...)`" + note: | + 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. + Dimension: + description: "A dimension value is a string with a unit suffix." + +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}" diff --git a/packages/cli/src/linter/spec-gen/compiler.test.ts b/packages/cli/src/linter/spec-gen/compiler.test.ts index 656c796d..e55a2a17 100644 --- a/packages/cli/src/linter/spec-gen/compiler.test.ts +++ b/packages/cli/src/linter/spec-gen/compiler.test.ts @@ -1,88 +1,91 @@ -// 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'); - }); -}); +// 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), + typeDefinitions: (typeName?: string) => renderers.typeDefinitions(cfg, typeName), + }; + + 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'); + expect(result).toContain('**Color**'); + expect(result).toContain('oklch()'); + }); +}); diff --git a/packages/cli/src/linter/spec-gen/generate.ts b/packages/cli/src/linter/spec-gen/generate.ts index b622ec37..0bb9a8c6 100644 --- a/packages/cli/src/linter/spec-gen/generate.ts +++ b/packages/cli/src/linter/spec-gen/generate.ts @@ -1,92 +1,93 @@ -#!/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 = `\n\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(); +#!/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), + typeDefinitions: (typeName?: string) => renderers.typeDefinitions(cfg, typeName), + }; + + const generated = await compileMdx(source, scope); + + // Prepend header comment + const header = `\n\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(); diff --git a/packages/cli/src/linter/spec-gen/renderers.test.ts b/packages/cli/src/linter/spec-gen/renderers.test.ts new file mode 100644 index 00000000..d103c7ca --- /dev/null +++ b/packages/cli/src/linter/spec-gen/renderers.test.ts @@ -0,0 +1,75 @@ +// 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 { typeDefinitions } from './renderers.js'; +import { SPEC_CONFIG } from '../spec-config.js'; + +describe('typeDefinitions', () => { + it('includes **Color** with its description', () => { + const result = typeDefinitions(SPEC_CONFIG); + expect(result).toContain('**Color**'); + expect(result).toContain('A color value is any valid CSS color string'); + }); + + it('renders Color formats as bullet list', () => { + const result = typeDefinitions(SPEC_CONFIG); + expect(result).toContain('- Hex:'); + expect(result).toContain('- Wide-gamut:'); + expect(result).toContain('- Mixing:'); + }); + + it('includes **Dimension** with its description', () => { + const result = typeDefinitions(SPEC_CONFIG); + expect(result).toContain('**Dimension**'); + expect(result).toContain('A dimension value is a string with a unit suffix'); + }); + + it('appends STANDARD_UNITS inline to Dimension', () => { + const result = typeDefinitions(SPEC_CONFIG); + expect(result).toContain('Valid units are:'); + for (const unit of SPEC_CONFIG.STANDARD_UNITS) { + expect(result).toContain(unit); + } + }); + + it('does not render units as a bullet list for Dimension', () => { + const result = typeDefinitions(SPEC_CONFIG); + const dimensionStart = result.indexOf('**Dimension**'); + const afterDimension = result.slice(dimensionStart); + expect(afterDimension).not.toMatch(/\n- px/); + expect(afterDimension).not.toMatch(/\n- em/); + }); + + it('renders Color note paragraph', () => { + const result = typeDefinitions(SPEC_CONFIG); + expect(result).toContain('internally converted to sRGB'); + expect(result).toContain('recommended default'); + }); + + it('filters to a single type when typeName is provided', () => { + const colorOnly = typeDefinitions(SPEC_CONFIG, 'Color'); + expect(colorOnly).toContain('**Color**'); + expect(colorOnly).not.toContain('**Dimension**'); + + const dimensionOnly = typeDefinitions(SPEC_CONFIG, 'Dimension'); + expect(dimensionOnly).toContain('**Dimension**'); + expect(dimensionOnly).not.toContain('**Color**'); + }); + + it('returns empty string for unknown typeName', () => { + const result = typeDefinitions(SPEC_CONFIG, 'NonExistent'); + expect(result).toBe(''); + }); +}); diff --git a/packages/cli/src/linter/spec-gen/renderers.ts b/packages/cli/src/linter/spec-gen/renderers.ts index c7cfb985..c12c4bc0 100644 --- a/packages/cli/src/linter/spec-gen/renderers.ts +++ b/packages/cli/src/linter/spec-gen/renderers.ts @@ -1,123 +1,143 @@ -// 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, indent = 2): string[] { - return Object.entries(entries).map( - ([k, v]) => `${' '.repeat(indent)}${k}: "${v}"` - ); -} - -function yamlObject(entries: Record, 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 - ), - 'typography:', - ` ${typoName}:`, - ...yamlObject(typoProps as Record), - '---', - ]); -} - -/** 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)); - } - 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)); - } - 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'); -} +// 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, TypeDef } 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, indent = 2): string[] { + return Object.entries(entries).map( + ([k, v]) => `${' '.repeat(indent)}${k}: "${v}"` + ); +} + +function yamlObject(entries: Record, 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 + ), + 'typography:', + ` ${typoName}:`, + ...yamlObject(typoProps as Record), + '---', + ]); +} + +/** 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)); + } + 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)); + } + 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'); +} + +/** Primitive type definitions (Color, Dimension, etc.) for the schema section. */ +export function typeDefinitions(config: SpecConfig, typeName?: string): string { + const entries = typeName + ? Object.entries(config.PRIMITIVE_TYPES).filter(([n]) => n === typeName) + : Object.entries(config.PRIMITIVE_TYPES); + return entries.map(([name, def]: [string, TypeDef]) => { + let block = `**${name}**: ${def.description}`; + if (def.formats?.length) { + block += '\n\n' + def.formats.map((f: string) => `- ${f}`).join('\n'); + } + if (name === 'Dimension') { + block += ` Valid units are: ${config.STANDARD_UNITS.join(', ')}.`; + } + if (def.note) { + block += '\n\n' + def.note.trim(); + } + return block; + }).join('\n\n'); +} diff --git a/packages/cli/src/linter/spec-gen/spec.mdx b/packages/cli/src/linter/spec-gen/spec.mdx index 44064d5c..78a828db 100644 --- a/packages/cli/src/linter/spec-gen/spec.mdx +++ b/packages/cli/src/linter/spec-gen/spec.mdx @@ -1,289 +1,279 @@ -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: # optional, current version: "alpha" -name: -description: # optional -colors: - : -typography: - : -rounded: - : -spacing: - : -components: - : - : -``` - -The `` 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 `

` (`##`) headings. An optional `

` 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\, 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\ - -{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\ 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\. - -```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\\> 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 | +import { SPEC_VERSION, 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, typeDefinitions } 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: # optional, current version: "alpha" +name: +description: # optional +colors: + : +typography: + : +rounded: + : +spacing: + : +components: + : + : +``` + +The `` 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. + +{typeDefinitions('Color')} + +{typographyPropertyList()} + +{typeDefinitions('Dimension')} + +**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 `

` (`##`) headings. An optional `

` 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\, 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\ + +{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\ 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\. + +```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\\> 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 |