From 4159a8e35fcf84c7bb15736f9856a8b4390371b5 Mon Sep 17 00:00:00 2001 From: xkxx Date: Wed, 30 Sep 2026 20:50:32 -0700 Subject: [PATCH 1/3] feat(spec): support and document grouped tokens across token categories --- README.md | 14 ++- docs/spec.md | 92 ++++++++++++++++++- packages/cli/src/linter/index.test.ts | 79 ++++++++++++++++ packages/cli/src/linter/model/handler.test.ts | 68 ++++++++++++++ packages/cli/src/linter/model/handler.ts | 48 +++++++++- packages/cli/src/linter/spec-gen/spec.mdx | 91 +++++++++++++++++- 6 files changed, 379 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ed855c52..078ec371 100644 --- a/README.md +++ b/README.md @@ -116,24 +116,34 @@ description: # optional omitted: # optional, list of sections to intentionally omit colors: : + : # optional nested group (e.g., light, dark, primary) + : typography: : + : # optional nested group (e.g., sm, md, lg, xl) + : rounded: : + : # optional nested group + : spacing: : + : # optional nested group (e.g., sm, md, lg, xl) + : components: : : ``` +Top-level token categories (`colors`, `typography`, `rounded`, `spacing`) support nested YAML sub-maps for theme modes (`light`, `dark`), responsive breakpoints (recommended: `sm`, `md`, `lg`, `xl` for mobile, tablet, laptop, and desktop, respectively), and hierarchical token families. + ### Token Types | Type | Format | Example | |:-----|:-------|:--------| | Color | Any CSS color (hex, `rgb()`, `oklch()`, named, etc.) | `"#1A1C1E"`, `"oklch(62% 0.18 250)"` | | Dimension | number + unit (`px`, `em`, `rem`) | `48px`, `-0.02em` | -| Token Reference | `{path.to.token}` | `{colors.primary}` | +| Token Reference | `{path.to.token}` | `{colors.primary}`, `{colors.light.surface}`, `{typography.sm.headline-lg}` | | Typography | object with `fontFamily`, `fontSize`, `fontWeight`, `lineHeight`, `letterSpacing`, `fontFeature`, `fontVariation` | See example above | ### Section Order @@ -177,6 +187,8 @@ Variants (hover, active, pressed) are expressed as separate component entries wi | Unknown section heading | Preserve; do not error | | Unknown color token name | Accept if value is valid | | Unknown typography token name | Accept as valid typography | +| Grouped token sub-map | Flatten to dot-separated token path | +| Flat and grouped token name collision | Error; reject the conflicting token | | Unknown component property | Accept with warning | | Duplicate section heading | Error; reject the file | diff --git a/docs/spec.md b/docs/spec.md index 5995e548..16d1f780 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -47,12 +47,20 @@ description: # optional omitted: # optional colors: : + : # optional nested group (e.g., light, dark, primary) + : typography: : + : # optional nested group (e.g., sm, md, lg, xl) + : rounded: : + : # optional nested group + : spacing: : + : # optional nested group (e.g., sm, md, lg, xl) + : components: : : @@ -95,7 +103,15 @@ Hex notation (`#RRGGBB`) remains the recommended default for simplicity and broa reason: "No rounded corners defined in brand book" ``` -**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. +**Grouped Tokens**: Top-level token categories (`colors`, `typography`, `rounded`, and `spacing`) accept nested YAML sub-maps to organize tokens into logical groups. Common grouping patterns include: + +* **Theme modes**: Grouping mode-dependent color tokens under `light` and `dark` sub-maps while keeping mode-agnostic brand roles at the top level. +* **Responsive breakpoints**: Grouping breakpoint-specific `typography` or `spacing` scales under breakpoint sub-maps. For responsive design, the recommended (but not required) group names are `sm`, `md`, `lg`, and `xl` (corresponding to mobile, tablet, laptop, and desktop, respectively). +* **Token families**: Grouping related scales or semantic subsets (e.g., `colors.primary.light`, `spacing.inset.md`). + +Grouped tokens are flattened internally to dot-separated paths (e.g., `colors.light.surface`, `typography.sm.headline-lg`, `spacing.lg.gutter`) and can be nested up to 20 levels deep. Mixing flat hyphenated keys (e.g., `colors.primary-light`) and nested grouped keys (e.g., `colors.primary.light`) that produce identical flattened CSS custom property names (`--color-primary-light`) is rejected as an error by the linter, as are duplicate token paths. + +**Token References**: A token reference must be wrapped in curly braces, and contain a dot-separated object path to another value in the YAML tree (including grouped tokens, such as `{colors.light.surface}`, `{typography.sm.headline-lg}`, or `{spacing.lg.gutter}`). For most token groups, the reference must point to a primitive value (e.g., `colors.primary-60`), not a group (e.g., `colors` or `colors.light`). Within the `components` section, references to composite values (e.g., `{typography.label-md}` or `{typography.sm.headline-lg}`) are permitted. # Sections @@ -150,7 +166,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 -map\, that maps the name of the color token to its value. +map\ (with optional nested group sub-maps), that maps the name or dot-separated group path of the color token to its value. ```yaml colors: @@ -160,6 +176,27 @@ colors: neutral: "#F7F5F2" ``` +For adaptive light and dark themes, mode-agnostic brand and semantic roles can remain at the top level while mode-dependent surface and text tokens are grouped under optional `light` and `dark` sub-maps: + +```yaml +colors: + primary: "#647D66" + on-primary: "#FFFFFF" + secondary: "#FF8A65" + on-secondary: "#FFFFFF" + error: "#BA1A1A" + light: + surface: "#FAFDF7" + on-surface: "#1A1C19" + surface-container: "#FFFFFF" + surface-container-low: "#F4F7F1" + dark: + surface: "#10130E" + on-surface: "#E2E4DE" + surface-container: "#1A1C19" + surface-container-low: "#14170F" +``` + ## Typography This section defines typography levels. @@ -190,7 +227,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 -map\ +map\ (with optional nested group sub-maps). ```yaml typography: @@ -213,6 +250,29 @@ typography: letterSpacing: 0.1em ``` +For responsive typography, tokens can be grouped under breakpoint sub-maps while breakpoint-agnostic styles remain at the top level. The recommended (but not required) group names for responsive design are `sm`, `md`, `lg`, and `xl` (mobile, tablet, laptop, and desktop, respectively): + +```yaml +typography: + body-md: + fontFamily: Public Sans + fontSize: 16px + fontWeight: 400 + lineHeight: 1.6 + sm: + headline-lg: + fontFamily: Public Sans + fontSize: 32px + fontWeight: 600 + lineHeight: 1.15 + lg: + headline-lg: + fontFamily: Public Sans + fontSize: 48px + fontWeight: 600 + lineHeight: 1.1 +``` + ## Layout Also known as "Layout & Spacing". @@ -237,7 +297,7 @@ 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 -map\ that maps the spacing scale identifier to a dimension value or a unitless number (e.g., column counts or ratios). +map\ (with optional nested group sub-maps) that maps the spacing scale identifier or group path to a dimension value or a unitless number (e.g., column counts or ratios). ```yaml spacing: @@ -251,6 +311,22 @@ spacing: margin: 32px ``` +For responsive layout metrics, spacing tokens can also be grouped under breakpoint sub-maps (recommended: `sm`, `md`, `lg`, and `xl` for mobile, tablet, laptop, and desktop, respectively): + +```yaml +spacing: + base: 16px + xs: 4px + sm: + gutter: 16px + margin: 16px + columns: 4 + lg: + gutter: 24px + margin: 32px + columns: 12 +``` + ## Elevation & Depth Also known as "Elevation". @@ -286,7 +362,7 @@ engineered aesthetic. The `rounded` section defines the design tokens for rounded corners used in buttons, cards, and other rectangular shapes. -It is a map\. +It is a map\ (with optional nested group sub-maps). ```yaml rounded: @@ -363,6 +439,10 @@ The following names are commonly used across design systems. They are not requir **Rounded:** `none`, `sm`, `md`, `lg`, `xl`, `full` +**Theme Mode Groups:** `light`, `dark` + +**Responsive Breakpoint Groups:** `sm` (mobile), `md` (tablet), `lg` (laptop), `xl` (desktop) + # Consumer Behavior for Unknown Content When a DESIGN.md consumer encounters content not defined by this spec: @@ -373,5 +453,7 @@ When a DESIGN.md consumer encounters content not defined by this spec: | 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'` | +| Grouped token sub-map | Flatten to dot-separated token path | `colors.light.surface`, `typography.sm.headline-lg` | +| Flat and grouped token name collision | Error; reject the conflicting token | `colors.primary-light` and `colors.primary.light` | | Unknown component property | Accept with warning | `borderColor` | | Duplicate section heading | Error; reject the file | Two `## Colors` headings | diff --git a/packages/cli/src/linter/index.test.ts b/packages/cli/src/linter/index.test.ts index ba12e98b..c6bc7616 100644 --- a/packages/cli/src/linter/index.test.ts +++ b/packages/cli/src/linter/index.test.ts @@ -163,4 +163,83 @@ motion: ); expect(unknownKeyFindings).toEqual([]); }); + + it('processes grouped tokens across colors, typography, spacing, and rounded', () => { + const content = `--- +name: Adaptive Design System + +colors: + primary: "#647D66" + on-primary: "#FFFFFF" + light: + surface: "#FAFDF7" + on-surface: "#1A1C19" + dark: + surface: "#10130E" + on-surface: "#E2E4DE" + +typography: + body-md: + fontFamily: Public Sans + fontSize: 16px + fontWeight: 400 + lineHeight: 1.6 + sm: + headline-lg: + fontFamily: Public Sans + fontSize: 32px + fontWeight: 600 + lineHeight: 1.15 + lg: + headline-lg: + fontFamily: Public Sans + fontSize: 48px + fontWeight: 600 + lineHeight: 1.1 + +rounded: + sm: 4px + control: + pill: 9999px + +spacing: + base: 16px + sm: + gutter: 16px + lg: + gutter: 24px + +components: + button-primary: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" + typography: "{typography.sm.headline-lg}" + rounded: "{rounded.control.pill}" + padding: "{spacing.sm.gutter}" + card-light: + backgroundColor: "{colors.light.surface}" + textColor: "{colors.light.on-surface}" + card-dark: + backgroundColor: "{colors.dark.surface}" + textColor: "{colors.dark.on-surface}" +--- + +## Overview + +Adaptive design system with grouped theme and responsive tokens. +`; + + const result = lint(content); + + expect(result.summary.errors).toBe(0); + expect(result.designSystem.colors.size).toBe(6); + expect(result.designSystem.typography.size).toBe(3); + expect(result.designSystem.rounded.size).toBe(2); + expect(result.designSystem.spacing.size).toBe(3); + expect(result.designSystem.typography.get('sm.headline-lg')?.fontSize?.value).toBe(32); + expect(result.designSystem.typography.get('lg.headline-lg')?.fontSize?.value).toBe(48); + expect(result.designSystem.spacing.get('sm.gutter')?.value).toBe(16); + expect(result.designSystem.spacing.get('lg.gutter')?.value).toBe(24); + }); }); + diff --git a/packages/cli/src/linter/model/handler.test.ts b/packages/cli/src/linter/model/handler.test.ts index e4023074..eb9e3f86 100644 --- a/packages/cli/src/linter/model/handler.test.ts +++ b/packages/cli/src/linter/model/handler.test.ts @@ -489,6 +489,74 @@ describe('ModelHandler', () => { expect(result.designSystem.typography.get('headline')?.fontFamily).toBe('Inter'); expect(result.findings.some(f => f.path === 'typography.headline.fontFamily')).toBe(false); }); + + it('successfully parses grouped typography declarations across responsive breakpoints', () => { + const result = handler.execute(makeParsed({ + typography: { + 'body-md': { + fontFamily: 'Public Sans', + fontSize: '16px', + fontWeight: 400, + lineHeight: 1.6, + }, + sm: { + 'headline-lg': { + fontFamily: 'Public Sans', + fontSize: '32px', + fontWeight: 700, + lineHeight: 1.15, + }, + }, + lg: { + 'headline-lg': { + fontFamily: 'Public Sans', + fontSize: '48px', + fontWeight: 700, + lineHeight: 1.1, + }, + }, + }, + })); + + expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0); + expect(result.findings.filter(f => f.severity === 'warning')).toHaveLength(0); + expect(result.designSystem.typography.has('body-md')).toBe(true); + expect(result.designSystem.typography.has('sm.headline-lg')).toBe(true); + expect(result.designSystem.typography.has('lg.headline-lg')).toBe(true); + expect(result.designSystem.typography.get('sm.headline-lg')?.fontSize?.value).toBe(32); + expect(result.designSystem.typography.get('lg.headline-lg')?.fontSize?.value).toBe(48); + expect(result.designSystem.symbolTable.has('typography.sm.headline-lg')).toBe(true); + }); + + it('emits diagnostic for duplicate token path in typography', () => { + const result = handler.execute(makeParsed({ + typography: { + sm: { + 'headline-lg': { fontFamily: 'Public Sans', fontSize: '32px' }, + }, + 'sm.headline-lg': { fontFamily: 'Public Sans', fontSize: '36px' }, + }, + })); + const errors = result.findings.filter(f => f.severity === 'error'); + expect(errors.length).toBe(1); + expect(errors[0]!.path).toBe('typography.sm.headline-lg'); + expect(errors[0]!.message).toBe("Duplicate token path 'typography.sm.headline-lg' detected."); + }); + + it('emits diagnostic when grouped typography token flattens to an existing token name', () => { + const result = handler.execute(makeParsed({ + typography: { + 'sm-headline-lg': { fontFamily: 'Public Sans', fontSize: '32px' }, + sm: { + 'headline-lg': { fontFamily: 'Public Sans', fontSize: '36px' }, + }, + }, + })); + const errors = result.findings.filter(f => f.severity === 'error'); + expect(errors.length).toBe(1); + expect(errors[0]!.path).toBe('typography.sm.headline-lg'); + expect(errors[0]!.message).toBe("Grouped typography token flattens to 'sm-headline-lg', which is already defined."); + }); }); describe('rounded validation', () => { diff --git a/packages/cli/src/linter/model/handler.ts b/packages/cli/src/linter/model/handler.ts index 9ea1193b..4c1684e8 100644 --- a/packages/cli/src/linter/model/handler.ts +++ b/packages/cli/src/linter/model/handler.ts @@ -80,11 +80,14 @@ export class ModelHandler implements ModelSpec { // Typography if (input.typography) { - for (const [name, props] of Object.entries(input.typography)) { + const isCollision = buildCollisionGuard('typography', findings); + forEachTypographyLeaf(input.typography, (name, props) => { + if (isCollision(name)) return; + const resolved = parseTypography(props, `typography.${name}`, findings); typography.set(name, resolved); symbolTable.set(`typography.${name}`, resolved); - } + }, '', 0, findings, 'typography'); } // Rounded @@ -488,4 +491,45 @@ function forEachLeaf( fn(fullPath, value); } } +} + +function isTypographyGroup(obj: Record): boolean { + const entries = Object.entries(obj); + if (entries.length === 0) return false; + if (entries.some(([key]) => TYPOGRAPHY_PROP_SET.has(key))) return false; + return entries.every( + ([, val]) => val !== null && typeof val === 'object' && !Array.isArray(val), + ); +} + +function forEachTypographyLeaf( + obj: Record, + fn: (path: string, value: Record) => void, + prefix = '', + depth = 0, + findings?: Finding[], + rootPath?: string, +) { + if (depth > MAX_TOKEN_NESTING_DEPTH) { + if (findings && rootPath) { + if (!findings.some((f) => f.path === rootPath && f.message.includes('nesting depth'))) { + findings.push({ + severity: 'error', + path: rootPath, + message: `Token nesting depth exceeds maximum allowed depth of ${MAX_TOKEN_NESTING_DEPTH}.`, + }); + } + } + return; + } + for (const [key, value] of Object.entries(obj)) { + const fullPath = prefix ? `${prefix}.${key}` : key; + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + if (isTypographyGroup(value)) { + forEachTypographyLeaf(value, fn, fullPath, depth + 1, findings, rootPath); + } else { + fn(fullPath, value); + } + } + } } \ No newline at end of file diff --git a/packages/cli/src/linter/spec-gen/spec.mdx b/packages/cli/src/linter/spec-gen/spec.mdx index c9e417d6..e7532864 100644 --- a/packages/cli/src/linter/spec-gen/spec.mdx +++ b/packages/cli/src/linter/spec-gen/spec.mdx @@ -31,12 +31,20 @@ description: # optional omitted: # optional colors: : + : # optional nested group (e.g., light, dark, primary) + : typography: : + : # optional nested group (e.g., sm, md, lg, xl) + : rounded: : + : # optional nested group + : spacing: : + : # optional nested group (e.g., sm, md, lg, xl) + : components: : : @@ -59,7 +67,14 @@ The `` placeholder represents a named level in a sizing or spacing - section: rounded reason: "No rounded corners defined in brand book" ``` -**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. +**Grouped Tokens**: Top-level token categories (`colors`, `typography`, `rounded`, and `spacing`) accept nested YAML sub-maps to organize tokens into logical groups. Common grouping patterns include: +* **Theme modes**: Grouping mode-dependent color tokens under `light` and `dark` sub-maps while keeping mode-agnostic brand roles at the top level. +* **Responsive breakpoints**: Grouping breakpoint-specific `typography` or `spacing` scales under breakpoint sub-maps. For responsive design, the recommended (but not required) group names are `sm`, `md`, `lg`, and `xl` (corresponding to mobile, tablet, laptop, and desktop, respectively). +* **Token families**: Grouping related scales or semantic subsets (e.g., `colors.primary.light`, `spacing.inset.md`). + +Grouped tokens are flattened internally to dot-separated paths (e.g., `colors.light.surface`, `typography.sm.headline-lg`, `spacing.lg.gutter`) and can be nested up to 20 levels deep. Mixing flat hyphenated keys (e.g., `colors.primary-light`) and nested grouped keys (e.g., `colors.primary.light`) that produce identical flattened CSS custom property names (`--color-primary-light`) is rejected as an error by the linter, as are duplicate token paths. + +**Token References**: A token reference must be wrapped in curly braces, and contain a dot-separated object path to another value in the YAML tree (including grouped tokens, such as `{colors.light.surface}`, `{typography.sm.headline-lg}`, or `{spacing.lg.gutter}`). For most token groups, the reference must point to a primitive value (e.g., `colors.primary-60`), not a group (e.g., `colors` or `colors.light`). Within the `components` section, references to composite values (e.g., `{typography.label-md}` or `{typography.sm.headline-lg}`) are permitted. # Sections @@ -107,10 +122,31 @@ 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 -map\, that maps the name of the color token to its value. +map\ (with optional nested group sub-maps), that maps the name or dot-separated group path of the color token to its value. {colorsExample()} +For adaptive light and dark themes, mode-agnostic brand and semantic roles can remain at the top level while mode-dependent surface and text tokens are grouped under optional `light` and `dark` sub-maps: + +```yaml +colors: + primary: "#647D66" + on-primary: "#FFFFFF" + secondary: "#FF8A65" + on-secondary: "#FFFFFF" + error: "#BA1A1A" + light: + surface: "#FAFDF7" + on-surface: "#1A1C19" + surface-container: "#FFFFFF" + surface-container-low: "#F4F7F1" + dark: + surface: "#10130E" + on-surface: "#E2E4DE" + surface-container: "#1A1C19" + surface-container-low: "#14170F" +``` + ## Typography This section defines typography levels. @@ -141,10 +177,33 @@ the narrative and **Space Grotesk** for technical data. The `typography` section defines the precise font properties for the typography design tokens. It is a -map\ +map\ (with optional nested group sub-maps). {typographyExample()} +For responsive typography, tokens can be grouped under breakpoint sub-maps while breakpoint-agnostic styles remain at the top level. The recommended (but not required) group names for responsive design are `sm`, `md`, `lg`, and `xl` (mobile, tablet, laptop, and desktop, respectively): + +```yaml +typography: + body-md: + fontFamily: Public Sans + fontSize: 16px + fontWeight: 400 + lineHeight: 1.6 + sm: + headline-lg: + fontFamily: Public Sans + fontSize: 32px + fontWeight: 600 + lineHeight: 1.15 + lg: + headline-lg: + fontFamily: Public Sans + fontSize: 48px + fontWeight: 600 + lineHeight: 1.1 +``` + ## Layout Also known as "Layout & Spacing". @@ -169,7 +228,7 @@ 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 -map\ that maps the spacing scale identifier to a dimension value or a unitless number (e.g., column counts or ratios). +map\ (with optional nested group sub-maps) that maps the spacing scale identifier or group path to a dimension value or a unitless number (e.g., column counts or ratios). ```yaml spacing: @@ -183,6 +242,22 @@ spacing: margin: 32px ``` +For responsive layout metrics, spacing tokens can also be grouped under breakpoint sub-maps (recommended: `sm`, `md`, `lg`, and `xl` for mobile, tablet, laptop, and desktop, respectively): + +```yaml +spacing: + base: 16px + xs: 4px + sm: + gutter: 16px + margin: 16px + columns: 4 + lg: + gutter: 24px + margin: 32px + columns: 12 +``` + ## Elevation & Depth Also known as "Elevation". @@ -218,7 +293,7 @@ engineered aesthetic. The `rounded` section defines the design tokens for rounded corners used in buttons, cards, and other rectangular shapes. -It is a map\. +It is a map\ (with optional nested group sub-maps). ```yaml rounded: @@ -275,6 +350,10 @@ The following names are commonly used across design systems. They are not requir {recommendedTokens()} +**Theme Mode Groups:** `light`, `dark` + +**Responsive Breakpoint Groups:** `sm` (mobile), `md` (tablet), `lg` (laptop), `xl` (desktop) + # Consumer Behavior for Unknown Content When a DESIGN.md consumer encounters content not defined by this spec: @@ -285,5 +364,7 @@ When a DESIGN.md consumer encounters content not defined by this spec: | 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'` | +| Grouped token sub-map | Flatten to dot-separated token path | `colors.light.surface`, `typography.sm.headline-lg` | +| Flat and grouped token name collision | Error; reject the conflicting token | `colors.primary-light` and `colors.primary.light` | | Unknown component property | Accept with warning | `borderColor` | | Duplicate section heading | Error; reject the file | Two `## Colors` headings | From 2b42a05ede87b7cb0e8db0311e3fbaf37b2fb261 Mon Sep 17 00:00:00 2001 From: xkxx Date: Wed, 30 Sep 2026 21:04:23 -0700 Subject: [PATCH 2/3] docs(spec): keep Schema section structural and explain grouped token applications in domain sections --- README.md | 8 +++----- docs/spec.md | 16 +++++----------- packages/cli/src/linter/spec-gen/spec.mdx | 15 +++++---------- 3 files changed, 13 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 078ec371..bb00bd79 100644 --- a/README.md +++ b/README.md @@ -116,11 +116,11 @@ description: # optional omitted: # optional, list of sections to intentionally omit colors: : - : # optional nested group (e.g., light, dark, primary) + : # optional nested group : typography: : - : # optional nested group (e.g., sm, md, lg, xl) + : # optional nested group : rounded: : @@ -128,15 +128,13 @@ rounded: : spacing: : - : # optional nested group (e.g., sm, md, lg, xl) + : # optional nested group : components: : : ``` -Top-level token categories (`colors`, `typography`, `rounded`, `spacing`) support nested YAML sub-maps for theme modes (`light`, `dark`), responsive breakpoints (recommended: `sm`, `md`, `lg`, `xl` for mobile, tablet, laptop, and desktop, respectively), and hierarchical token families. - ### Token Types | Type | Format | Example | diff --git a/docs/spec.md b/docs/spec.md index 16d1f780..302cc208 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -47,11 +47,11 @@ description: # optional omitted: # optional colors: : - : # optional nested group (e.g., light, dark, primary) + : # optional nested group : typography: : - : # optional nested group (e.g., sm, md, lg, xl) + : # optional nested group : rounded: : @@ -59,7 +59,7 @@ rounded: : spacing: : - : # optional nested group (e.g., sm, md, lg, xl) + : # optional nested group : components: : @@ -103,15 +103,9 @@ Hex notation (`#RRGGBB`) remains the recommended default for simplicity and broa reason: "No rounded corners defined in brand book" ``` -**Grouped Tokens**: Top-level token categories (`colors`, `typography`, `rounded`, and `spacing`) accept nested YAML sub-maps to organize tokens into logical groups. Common grouping patterns include: - -* **Theme modes**: Grouping mode-dependent color tokens under `light` and `dark` sub-maps while keeping mode-agnostic brand roles at the top level. -* **Responsive breakpoints**: Grouping breakpoint-specific `typography` or `spacing` scales under breakpoint sub-maps. For responsive design, the recommended (but not required) group names are `sm`, `md`, `lg`, and `xl` (corresponding to mobile, tablet, laptop, and desktop, respectively). -* **Token families**: Grouping related scales or semantic subsets (e.g., `colors.primary.light`, `spacing.inset.md`). - -Grouped tokens are flattened internally to dot-separated paths (e.g., `colors.light.surface`, `typography.sm.headline-lg`, `spacing.lg.gutter`) and can be nested up to 20 levels deep. Mixing flat hyphenated keys (e.g., `colors.primary-light`) and nested grouped keys (e.g., `colors.primary.light`) that produce identical flattened CSS custom property names (`--color-primary-light`) is rejected as an error by the linter, as are duplicate token paths. +**Grouped Tokens**: Top-level token categories (`colors`, `typography`, `rounded`, and `spacing`) accept optional nested YAML sub-maps to organize tokens into logical groups. Grouped tokens are flattened internally to dot-separated paths (e.g., `..`) and can be nested up to 20 levels deep. Mixing flat hyphenated keys (e.g., `colors.primary-light`) and nested grouped keys (e.g., `colors.primary.light`) that produce identical flattened CSS custom property names (`--color-primary-light`) is rejected as an error by the linter, as are duplicate token paths. -**Token References**: A token reference must be wrapped in curly braces, and contain a dot-separated object path to another value in the YAML tree (including grouped tokens, such as `{colors.light.surface}`, `{typography.sm.headline-lg}`, or `{spacing.lg.gutter}`). For most token groups, the reference must point to a primitive value (e.g., `colors.primary-60`), not a group (e.g., `colors` or `colors.light`). Within the `components` section, references to composite values (e.g., `{typography.label-md}` or `{typography.sm.headline-lg}`) are permitted. +**Token References**: A token reference must be wrapped in curly braces, and contain a dot-separated 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` or `colors.light.surface`), not a group (e.g., `colors` or `colors.light`). Within the `components` section, references to composite values (e.g., `{typography.label-md}` or `{typography.sm.headline-lg}`) are permitted. # Sections diff --git a/packages/cli/src/linter/spec-gen/spec.mdx b/packages/cli/src/linter/spec-gen/spec.mdx index e7532864..bdaa7ab9 100644 --- a/packages/cli/src/linter/spec-gen/spec.mdx +++ b/packages/cli/src/linter/spec-gen/spec.mdx @@ -31,11 +31,11 @@ description: # optional omitted: # optional colors: : - : # optional nested group (e.g., light, dark, primary) + : # optional nested group : typography: : - : # optional nested group (e.g., sm, md, lg, xl) + : # optional nested group : rounded: : @@ -43,7 +43,7 @@ rounded: : spacing: : - : # optional nested group (e.g., sm, md, lg, xl) + : # optional nested group : components: : @@ -67,14 +67,9 @@ The `` placeholder represents a named level in a sizing or spacing - section: rounded reason: "No rounded corners defined in brand book" ``` -**Grouped Tokens**: Top-level token categories (`colors`, `typography`, `rounded`, and `spacing`) accept nested YAML sub-maps to organize tokens into logical groups. Common grouping patterns include: -* **Theme modes**: Grouping mode-dependent color tokens under `light` and `dark` sub-maps while keeping mode-agnostic brand roles at the top level. -* **Responsive breakpoints**: Grouping breakpoint-specific `typography` or `spacing` scales under breakpoint sub-maps. For responsive design, the recommended (but not required) group names are `sm`, `md`, `lg`, and `xl` (corresponding to mobile, tablet, laptop, and desktop, respectively). -* **Token families**: Grouping related scales or semantic subsets (e.g., `colors.primary.light`, `spacing.inset.md`). - -Grouped tokens are flattened internally to dot-separated paths (e.g., `colors.light.surface`, `typography.sm.headline-lg`, `spacing.lg.gutter`) and can be nested up to 20 levels deep. Mixing flat hyphenated keys (e.g., `colors.primary-light`) and nested grouped keys (e.g., `colors.primary.light`) that produce identical flattened CSS custom property names (`--color-primary-light`) is rejected as an error by the linter, as are duplicate token paths. +**Grouped Tokens**: Top-level token categories (`colors`, `typography`, `rounded`, and `spacing`) accept optional nested YAML sub-maps to organize tokens into logical groups. Grouped tokens are flattened internally to dot-separated paths (e.g., `..`) and can be nested up to 20 levels deep. Mixing flat hyphenated keys (e.g., `colors.primary-light`) and nested grouped keys (e.g., `colors.primary.light`) that produce identical flattened CSS custom property names (`--color-primary-light`) is rejected as an error by the linter, as are duplicate token paths. -**Token References**: A token reference must be wrapped in curly braces, and contain a dot-separated object path to another value in the YAML tree (including grouped tokens, such as `{colors.light.surface}`, `{typography.sm.headline-lg}`, or `{spacing.lg.gutter}`). For most token groups, the reference must point to a primitive value (e.g., `colors.primary-60`), not a group (e.g., `colors` or `colors.light`). Within the `components` section, references to composite values (e.g., `{typography.label-md}` or `{typography.sm.headline-lg}`) are permitted. +**Token References**: A token reference must be wrapped in curly braces, and contain a dot-separated 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` or `colors.light.surface`), not a group (e.g., `colors` or `colors.light`). Within the `components` section, references to composite values (e.g., `{typography.label-md}` or `{typography.sm.headline-lg}`) are permitted. # Sections From 99768f8f1a038639a9e31644695f2227be74124d Mon Sep 17 00:00:00 2001 From: xkxx Date: Wed, 30 Sep 2026 21:26:46 -0700 Subject: [PATCH 3/3] feat(spec): limit grouped token nesting to a single level --- docs/spec.md | 2 +- packages/cli/src/linter/model/handler.test.ts | 82 ++++++++----------- packages/cli/src/linter/spec-config.ts | 2 +- packages/cli/src/linter/spec-config.yaml | 2 +- packages/cli/src/linter/spec-gen/spec.mdx | 2 +- 5 files changed, 37 insertions(+), 53 deletions(-) diff --git a/docs/spec.md b/docs/spec.md index 302cc208..85c2650b 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -103,7 +103,7 @@ Hex notation (`#RRGGBB`) remains the recommended default for simplicity and broa reason: "No rounded corners defined in brand book" ``` -**Grouped Tokens**: Top-level token categories (`colors`, `typography`, `rounded`, and `spacing`) accept optional nested YAML sub-maps to organize tokens into logical groups. Grouped tokens are flattened internally to dot-separated paths (e.g., `..`) and can be nested up to 20 levels deep. Mixing flat hyphenated keys (e.g., `colors.primary-light`) and nested grouped keys (e.g., `colors.primary.light`) that produce identical flattened CSS custom property names (`--color-primary-light`) is rejected as an error by the linter, as are duplicate token paths. +**Grouped Tokens**: Top-level token categories (`colors`, `typography`, `rounded`, and `spacing`) accept a single level of optional nested YAML sub-maps to organize tokens into logical groups. Grouped tokens are flattened internally to dot-separated paths (e.g., `..`); nesting beyond one group level is rejected as an error. Mixing flat hyphenated keys (e.g., `colors.primary-light`) and nested grouped keys (e.g., `colors.primary.light`) that produce identical flattened CSS custom property names (`--color-primary-light`) is also rejected as an error by the linter, as are duplicate token paths. **Token References**: A token reference must be wrapped in curly braces, and contain a dot-separated 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` or `colors.light.surface`), not a group (e.g., `colors` or `colors.light`). Within the `components` section, references to composite values (e.g., `{typography.label-md}` or `{typography.sm.headline-lg}`) are permitted. diff --git a/packages/cli/src/linter/model/handler.test.ts b/packages/cli/src/linter/model/handler.test.ts index eb9e3f86..cd049074 100644 --- a/packages/cli/src/linter/model/handler.test.ts +++ b/packages/cli/src/linter/model/handler.test.ts @@ -93,42 +93,19 @@ describe('ModelHandler', () => { expect(result.designSystem.symbolTable.has('colors.background.light')).toBe(true); }); - it('successfully parses 3-level nested color declarations', () => { + it('rejects color declarations nested deeper than 1 group level', () => { const result = handler.execute(makeParsed({ colors: { background: { light: { primary: '#fbfaf1', - secondary: '#f0f0f0' } } } })); - expect(result.findings.filter(f => f.severity === 'error').length).toBe(0); - expect(result.designSystem.colors.has('background.light.primary')).toBe(true); - expect(result.designSystem.colors.has('background.light.secondary')).toBe(true); - expect(result.designSystem.colors.get('background.light.primary')?.hex).toBe('#fbfaf1'); - expect(result.designSystem.symbolTable.has('colors.background.light.primary')).toBe(true); - }); - - it('successfully parses 4-level nested color declarations', () => { - const result = handler.execute(makeParsed({ - colors: { - theme: { - surface: { - background: { - base: '#fbfaf1' - } - } - } - } - })); - - expect(result.findings.filter(f => f.severity === 'error').length).toBe(0); - expect(result.designSystem.colors.has('theme.surface.background.base')).toBe(true); - expect(result.designSystem.colors.get('theme.surface.background.base')?.hex).toBe('#fbfaf1'); - expect(result.designSystem.symbolTable.has('colors.theme.surface.background.base')).toBe(true); + expect(result.findings.some(f => f.severity === 'error' && f.path === 'colors' && f.message.includes('nesting depth'))).toBe(true); + expect(result.designSystem.colors.has('background.light.primary')).toBe(false); }); it('emits diagnostic for duplicate token path in colors', () => { @@ -824,36 +801,43 @@ describe('ModelHandler', () => { }); describe('token nesting depth limit', () => { - it('emits error when token nesting depth exceeds 20', () => { - // 22 levels: Level 1..21 are objects, Level 22 is a leaf. - // forEachLeaf will be called for Level 22 with depth 21. - let obj: any = '#ffffff'; - for (let i = 22; i >= 1; i--) { - obj = { [`level${i}`]: obj }; - } - + it('emits error when token nesting depth exceeds 1', () => { const result = handler.execute(makeParsed({ - colors: obj, + colors: { + level1: { + level2: { + leaf: '#ffffff', + }, + }, + } as any, + typography: { + sm: { + mobile: { + 'headline-lg': { fontFamily: 'Public Sans', fontSize: '32px' }, + }, + }, + } as any, })); - expect(result.findings.some((f) => f.message.includes('nesting depth'))).toBe(true); - expect(result.findings.find((f) => f.message.includes('nesting depth'))?.path).toBe('colors'); + expect(result.findings.some((f) => f.path === 'colors' && f.message.includes('nesting depth'))).toBe(true); + expect(result.findings.some((f) => f.path === 'typography' && f.message.includes('nesting depth'))).toBe(true); }); - it('allows nesting up to depth 20', () => { - // 21 levels: Level 1..20 are objects, Level 21 is a leaf. - // forEachLeaf will be called for Level 21 with depth 20. - let obj: any = '#ffffff'; - for (let i = 21; i >= 1; i--) { - obj = { [`level${i}`]: obj }; - } - + it('allows 1 level of group nesting', () => { const result = handler.execute(makeParsed({ - colors: obj, + colors: { + light: { + surface: '#ffffff', + }, + }, + typography: { + sm: { + 'headline-lg': { fontFamily: 'Public Sans', fontSize: '32px' }, + }, + } as any, })); expect(result.findings.some((f) => f.message.includes('nesting depth'))).toBe(false); - // Construct the expected path: level1.level2...level21 - const path = Array.from({ length: 21 }, (_, i) => `level${i + 1}`).join('.'); - expect(result.designSystem.colors.has(path)).toBe(true); + expect(result.designSystem.colors.has('light.surface')).toBe(true); + expect(result.designSystem.typography.has('sm.headline-lg')).toBe(true); }); }); diff --git a/packages/cli/src/linter/spec-config.ts b/packages/cli/src/linter/spec-config.ts index 4b73af52..38e87894 100644 --- a/packages/cli/src/linter/spec-config.ts +++ b/packages/cli/src/linter/spec-config.ts @@ -49,7 +49,7 @@ const TypeDefSchema = z.object({ const ConfigSchema = z.object({ version: z.string(), limits: z.object({ - max_token_nesting_depth: z.number().default(20), + max_token_nesting_depth: z.number().default(1), max_reference_depth: z.number().default(10), }).default({}), units: z.array(z.string()).min(1), diff --git a/packages/cli/src/linter/spec-config.yaml b/packages/cli/src/linter/spec-config.yaml index 6b1e90fd..b2bc7e7f 100644 --- a/packages/cli/src/linter/spec-config.yaml +++ b/packages/cli/src/linter/spec-config.yaml @@ -20,7 +20,7 @@ version: alpha # Performance and safety limits for the model handler. limits: - max_token_nesting_depth: 20 + max_token_nesting_depth: 1 max_reference_depth: 10 units: diff --git a/packages/cli/src/linter/spec-gen/spec.mdx b/packages/cli/src/linter/spec-gen/spec.mdx index bdaa7ab9..f8ab6e55 100644 --- a/packages/cli/src/linter/spec-gen/spec.mdx +++ b/packages/cli/src/linter/spec-gen/spec.mdx @@ -67,7 +67,7 @@ The `` placeholder represents a named level in a sizing or spacing - section: rounded reason: "No rounded corners defined in brand book" ``` -**Grouped Tokens**: Top-level token categories (`colors`, `typography`, `rounded`, and `spacing`) accept optional nested YAML sub-maps to organize tokens into logical groups. Grouped tokens are flattened internally to dot-separated paths (e.g., `..`) and can be nested up to 20 levels deep. Mixing flat hyphenated keys (e.g., `colors.primary-light`) and nested grouped keys (e.g., `colors.primary.light`) that produce identical flattened CSS custom property names (`--color-primary-light`) is rejected as an error by the linter, as are duplicate token paths. +**Grouped Tokens**: Top-level token categories (`colors`, `typography`, `rounded`, and `spacing`) accept a single level of optional nested YAML sub-maps to organize tokens into logical groups. Grouped tokens are flattened internally to dot-separated paths (e.g., `..`); nesting beyond one group level is rejected as an error. Mixing flat hyphenated keys (e.g., `colors.primary-light`) and nested grouped keys (e.g., `colors.primary.light`) that produce identical flattened CSS custom property names (`--color-primary-light`) is also rejected as an error by the linter, as are duplicate token paths. **Token References**: A token reference must be wrapped in curly braces, and contain a dot-separated 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` or `colors.light.surface`), not a group (e.g., `colors` or `colors.light`). Within the `components` section, references to composite values (e.g., `{typography.label-md}` or `{typography.sm.headline-lg}`) are permitted.