diff --git a/README.md b/README.md index ed855c52..a6d98f5e 100644 --- a/README.md +++ b/README.md @@ -317,7 +317,7 @@ npx @google/design.md spec --rules-only --format json ## Linting Rules -The linter runs eleven rules against a parsed DESIGN.md. Each rule produces findings at a fixed severity level. +The linter runs twelve rules against a parsed DESIGN.md. Each rule produces findings at a fixed severity level. | Rule | Severity | What it checks | |:-----|:---------|:---------------| @@ -325,6 +325,7 @@ The linter runs eleven rules against a parsed DESIGN.md. Each rule produces find | `missing-primary` | warning | Colors are defined but no `primary` color exists — agents will auto-generate one | | `contrast-ratio` | warning | Component `backgroundColor`/`textColor` pairs below WCAG AA minimum (4.5:1) | | `orphaned-tokens` | warning | Color tokens defined but never referenced by any component | +| `shadow-orphaned` | warning | Shadow tokens defined but never referenced by any component | | `token-summary` | info | Summary of how many tokens are defined in each section | | `missing-sections` | info | Optional sections (spacing, rounded) absent when other tokens exist | | `missing-typography` | warning | Colors are defined but no typography tokens exist — agents will use default fonts | diff --git a/docs/plans/2026-08-07-001-feat-shadow-elevation-tokens-plan.md b/docs/plans/2026-08-07-001-feat-shadow-elevation-tokens-plan.md new file mode 100644 index 00000000..99665e9e --- /dev/null +++ b/docs/plans/2026-08-07-001-feat-shadow-elevation-tokens-plan.md @@ -0,0 +1,231 @@ +--- +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +execution: code +product_contract_source: ce-plan-bootstrap +title: "feat: Add structured shadows/elevation token category" +type: feat +date: 2026-08-07 +origin: https://github.com/google-labs-code/design.md/issues/92 +target_repo: google-labs-code/design.md +--- + +# feat: Add structured shadows/elevation token category + +**Target repo:** google-labs-code/design.md (this plan is authored and executed against a fork/clone, not the current working repo) + +## Goal Capsule + +Add a `shadows:` token category to the DESIGN.md spec with the same first-class treatment as `colors`, `typography`, `rounded`, and `spacing`: a structured YAML schema, model resolution, lint validation, spec documentation, and an example. Closes [#92](https://github.com/google-labs-code/design.md/issues/92). + +--- + +## Problem Frame + +`packages/cli/src/linter/model/spec.ts` (`DesignSystemState`) currently resolves five token maps: `colors`, `typography`, `rounded`, `spacing`, `components`. `Elevation & Depth` is already a canonical section (`packages/cli/src/linter/spec-config.yaml:58`) but is prose-only — there is no `shadows:` YAML key, no resolved value type, and no lint rule. Authors describing box-shadow-shaped elevation values today have nowhere structured to put them, and `design.md lint` cannot validate them the way it validates `colors` (format, references, contrast) or `rounded`/`spacing` (unit compliance). + +## Requirements + +- R1: `shadows:` is a recognized top-level YAML key, parsed the same way `rounded`/`spacing` are (`forEachLeaf` over a flat or nested token map). +- R2: Each shadow token resolves to a structured composite value (`offsetX`, `offsetY`, `blur`, `spread`, `color`), not a bare string — this is the actual gap versus `rounded`/`spacing` (scalar dimensions) and mirrors how `typography` is already a composite (`ResolvedTypography`). +- R3: A shadow's `color` field accepts either a literal color string or a `{colors.*}` token reference, resolved through the existing reference-resolution pass. +- R4: Lint validates shadow tokens: malformed dimension fields, invalid/unresolvable color, and (analogous to `orphaned-tokens`) shadow tokens no component references. +- R5: `docs/spec.md` documents the new category (regenerated via `bun run spec:gen` from `spec-config.yaml`/`spec.mdx`, never hand-edited). +- R6: At least one example `DESIGN.md` in `examples/` demonstrates `shadows:` usage under a component. + +## Scope Boundaries + +**In scope:** flat `shadows:` token category, composite value shape, reference resolution for the `color` sub-field, lint validation, spec doc, one example. + +**Out of scope / deferred:** +- Multi-layer shadows (arrays of shadow objects per token, like CSS's comma-separated `box-shadow`) — the issue's worked examples are single-layer; note as a future extension in Open Questions. +- Inset/outset variants — not requested in the issue thread. +- A dedicated `elevation` semantic layer (e.g., naming z-index or perceived depth beyond visual shadow values) — the issue title mentions "elevation" as an alias for the section, not a separate token type. + +### Deferred to Follow-Up Work +- Multi-layer shadow arrays (open question, not this PR). + +## Key Technical Decisions + +**KTD1 — Composite value shape, not a string shorthand.** Model `ResolvedShadow` as `{ type: 'shadow', offsetX, offsetY, blur, spread, color }` (dimensions as `ResolvedDimension`, color as `ResolvedColor`), matching `ResolvedTypography`'s composite pattern rather than inventing a single CSS-`box-shadow`-string field. Rationale: keeps each sub-value independently lintable (dimension unit checks, color contrast/reference checks) the same way `typography.fontSize` is checked today — a flat string would need its own mini-parser and lose that reuse. Alternative considered: a raw CSS box-shadow string (`"0px 4px 8px 0px #00000033"`); rejected because it can't reuse `parseDimensionParts`/`isValidColor` per-field and can't support `{colors.*}` token references inside the string without a custom grammar. + +**KTD2 — `color` sub-field supports token references.** Resolve `shadows..color` through the same reference-resolution pass used for `components.*` properties (`{colors.shadow-ambient}` etc.), not just literal hex/rgba. Rationale: shadow color should be able to point at the palette like every other color-consuming field does; skipping this would make `shadows` inconsistent with `components`. + +**KTD3 — New `SHADOW` model error codes, reusing existing dimension/color validators.** Add `INVALID_SHADOW` to `ModelErrorCode` but delegate field-level checks to `parseDimensionParts`/`isValidColor` (already exported from `model/spec.ts`) rather than writing shadow-specific parsing logic. Rationale: consistency with how `rounded`/`spacing` validation already works, and it avoids duplicating dimension-parsing logic. + +## Sources & Research + +- `packages/cli/src/linter/model/spec.ts` — `DesignSystemState`, `ResolvedColor`/`ResolvedDimension`/`ResolvedTypography`, `ModelErrorCode`, `parseDimensionParts`/`isValidColor` helpers. +- `packages/cli/src/linter/model/handler.ts:52,91-135` — how `rounded`/`spacing` are parsed via `forEachLeaf` + `buildCollisionGuard`; comment at line 254 documents the "call once per category" contract this unit follows for `shadows`. +- `packages/cli/src/linter/spec-config.yaml:58-60` — `Elevation & Depth` is already a canonical section (currently prose-only, needs no new section, just token-category wiring). +- `packages/cli/src/linter/linter/rules/` (28 files) — `contrast-ratio.ts` (composite-value lint against `state.components`), `orphaned-tokens.ts` (unused-token pattern to mirror for shadows), `types.ts` (`RuleDescriptor`/`RuleFinding` contract), `index.ts` (rule registration point). +- Issue [#92](https://github.com/google-labs-code/design.md/issues/92) and follow-up comment from `@Emp1500` (expressed interest, no PR followed) — no existing PR references this issue (confirmed via GitHub PR search). + +--- + +## Implementation Units + +### U1. Spec config: add `shadows` category and `Shadow` type + +**Goal:** Register the new token category and its composite type at the single source of truth so both the linter and doc generator pick it up. + +**Requirements:** R1, R2, R5 + +**Dependencies:** none + +**Files:** +- `packages/cli/src/linter/spec-config.yaml` — add a `shadow_properties` array (parallel to existing `typography_properties`): `offsetX`, `offsetY` (type `Dimension`), `blur`, `spread` (type `Dimension`, non-negative), `color` (type `Color | Reference`). Add a `shadows` example entry under `examples:` parallel to `colors`/`typography`. +- `packages/cli/src/linter/spec-config.ts` — extend `ConfigSchema` with `shadow_properties: z.array(PropertyDefSchema).min(1)` and `examples.shadows: z.record(...)`; re-export `VALID_SHADOW_PROPS` the same way `VALID_TYPOGRAPHY_PROPS` is re-exported today. + +**Approach:** Mirror `typography_properties` exactly — same `PropertyDefSchema` shape, same re-export pattern in `model/spec.ts` (`_VALID_SHADOW_PROPS` → `VALID_SHADOW_PROPS`). Do not introduce a new schema primitive. + +**Patterns to follow:** `packages/cli/src/linter/spec-config.yaml:65-83` (`typography_properties`), `spec-config.ts:44-49` (`ConfigSchema` typography/component fields). + +**Test scenarios:** +- Loading `spec-config.yaml` via `loadSpecConfig()` succeeds and `config.shadow_properties` contains exactly `offsetX`, `offsetY`, `blur`, `spread`, `color`. +- Malformed `shadow_properties` entry (missing `type`) fails Zod validation with a clear error — mirrors existing `spec-config.test.ts` coverage for `typography_properties`. + +**Verification:** `bun test packages/cli/src/linter/spec-config.test.ts` passes with new assertions added. + +--- + +### U2. Model: `ResolvedShadow` type and `shadows` resolution in the handler + +**Goal:** Parse a `shadows:` YAML block into `Map` on `DesignSystemState`, resolving each sub-field (including `color` token references) the same way `rounded`/`spacing`/`typography` are resolved today. + +**Requirements:** R1, R2, R3, R4 (data prerequisite) + +**Dependencies:** U1 + +**Files:** +- `packages/cli/src/linter/model/spec.ts` — add `ResolvedShadow` interface (`type: 'shadow'`, `offsetX/offsetY/blur/spread: ResolvedDimension`, `color: ResolvedColor`); add `'INVALID_SHADOW'` to `ModelErrorCode`; add `shadows: Map` to `DesignSystemState`; extend `ResolvedValue` union. +- `packages/cli/src/linter/model/handler.ts` — add a `shadows` resolution block parallel to the `rounded`/`spacing` blocks at lines 91-135: for each `shadows.` entry, resolve each of the 5 sub-fields (dimensions via `parseDimensionParts`, color via `isValidColor` or reference lookup against `symbolTable` for `{colors.*}` syntax), push an `INVALID_SHADOW` finding per malformed sub-field, and set the composite `ResolvedShadow` in both the `shadows` map and `symbolTable` (as `shadows.`) only when all sub-fields resolve. +- `packages/cli/src/linter/model/handler.test.ts` — new test cases. + +**Approach:** Because `color` may be a `{colors.*}` reference, this resolution must run *after* the `colors` block populates `symbolTable` (colors are already resolved earlier in `handler.ts`) — respect the existing resolution order rather than reordering it. Malformed shadows still populate `symbolTable` with the raw value (matching the `rounded`/`spacing` fallback behavior at handler.ts:111-116) so downstream lint rules can still report on them by path. + +**Technical design (directional):** +``` +for name, raw in shadows_block: + offsetX = resolveDimension(raw.offsetX) // reuse parseDimensionParts + offsetY = resolveDimension(raw.offsetY) + blur = resolveDimension(raw.blur) + spread = resolveDimension(raw.spread ?? '0px') // spread optional, defaults 0 + color = isTokenReference(raw.color) + ? symbolTable.get(stripBraces(raw.color)) + : parseCssColor(raw.color) + if all resolved: shadows.set(name, {type:'shadow', offsetX, offsetY, blur, spread, color}) + else: push INVALID_SHADOW finding per bad field; symbolTable.set(`shadows.${name}`, raw) +``` + +**Patterns to follow:** `handler.ts:91-119` (`rounded` block), `handler.ts:254` comment describing the "call once per category" contract — reuse `buildCollisionGuard('shadows', findings)`. + +**Test scenarios:** +- Happy path: `shadows: { card: { offsetX: 0px, offsetY: 4px, blur: 8px, spread: 0px, color: "#00000033" } }` resolves to a `ResolvedShadow` with correct numeric values. +- `color` as a token reference: `color: "{colors.shadow-ambient}"` resolves to the referenced `ResolvedColor` (requires `colors.shadow-ambient` defined earlier in the same file). +- `spread` omitted: defaults to `0px`, no finding. +- Edge case: `offsetX: "not-a-dimension"` → `INVALID_SHADOW` finding at `shadows.card.offsetX`, shadow still recorded in `symbolTable` with raw value. +- Edge case: `color: "{colors.nonexistent}"` → `UNRESOLVED_REFERENCE` finding (reuse existing reference-resolution error path, not a new `INVALID_SHADOW` for this case). +- Collision: two top-level `shadows` keys colliding (existing `buildCollisionGuard` behavior) produces the same collision finding shape as `rounded`. + +**Verification:** `bun test packages/cli/src/linter/model/handler.test.ts` — new cases pass; existing `rounded`/`spacing` cases unaffected. + +--- + +### U3. Parser: accept `shadows:` top-level key + +**Goal:** The YAML parser recognizes `shadows` as a known top-level section so it flows into the model handler instead of `unknownKeys`. + +**Requirements:** R1 + +**Dependencies:** none (parallel to U1/U2, but must land before U2's handler code has real input to consume in integration tests) + +**Files:** +- `packages/cli/src/linter/parser/spec.ts` — add `shadows` to the known-key allowlist alongside `rounded`/`spacing`/`components`. +- `packages/cli/src/linter/parser/handler.ts` — pass through `input.shadows` the same way `input.rounded`/`input.spacing` are passed through today. +- `packages/cli/src/linter/parser/spec.test.ts` — test that a `shadows:` block is not flagged as an unknown key. + +**Approach:** Mirror the exact code path used for `rounded`, which is the simplest existing flat-map category (`spacing` is identical). + +**Patterns to follow:** wherever `parser/handler.ts` special-cases `rounded`/`spacing` vs. falling through to `unknownKeys`. + +**Test scenarios:** +- A YAML doc with a `shadows:` top-level key parses without an `unknown-key` finding. +- A YAML doc with a `shadow:` (singular, typo) top-level key still produces the existing `unknown-key` finding with a Levenshtein-based suggestion pointing at `shadows` (reuses `levenshtein.ts` — no new code needed here, just verify it fires). + +**Verification:** `bun test packages/cli/src/linter/parser/spec.test.ts`. + +--- + +### U4. Lint rule: `shadow-value` validation + +**Goal:** A new lint rule surfaces malformed shadow tokens and unused shadow tokens, analogous to how `contrast-ratio.ts` and `orphaned-tokens.ts` cover `colors`. + +**Requirements:** R4 + +**Dependencies:** U2 + +**Files:** +- `packages/cli/src/linter/linter/rules/shadow-value.ts` (new) — reports `INVALID_SHADOW` model findings as lint findings (pass-through, same shape as how `rounded`/`spacing` dimension errors surface today) plus a check that a shadow token with a non-standard unit (not `px`/`rem`) gets a `warning`, matching the existing `isStandardDimension` vs `isParseableDimension` distinction used elsewhere. +- `packages/cli/src/linter/linter/rules/orphaned-tokens.ts` — extend the existing orphaned-token scan to also walk `state.shadows`, flagging any shadow token no `components.*` property references (the rule already generalizes over token maps; confirm and extend its category list rather than duplicating its logic). +- `packages/cli/src/linter/linter/rules/index.ts` — register `shadow-value` in the rule list. +- `packages/cli/src/linter/linter/rules/shadow-value.test.ts` (new), update `orphaned-tokens.test.ts`. + +**Approach:** Keep `shadow-value.ts` thin — it should mostly re-surface findings the model handler (U2) already computed, matching how existing rules treat `rounded`/`spacing` unit warnings rather than re-implementing validation in the lint layer. + +**Patterns to follow:** `packages/cli/src/linter/linter/rules/contrast-ratio.ts` (composite-value rule reading `state.components`/`ResolvedColor`), `orphaned-tokens.ts` (unused-token detection across categories), `types.ts` (`RuleDescriptor`/`RuleFinding` contract). + +**Test scenarios:** +- A `shadows` token with a non-`px`/`rem` unit on `offsetY` produces a `warning`-severity finding, not an error (mirrors existing dimension-unit leniency). +- A `shadows` token unreferenced by any `components.*` property produces an `orphaned-tokens` finding with path `shadows.`. +- A `shadows` token referenced by a component property is not flagged as orphaned. +- Integration: running `design.md lint` end-to-end (via existing CLI test harness) on a fixture with an invalid shadow reports the finding at the correct path and severity. + +**Verification:** `bun test packages/cli/src/linter/linter/rules/` — new and updated tests pass; `bun test src/linter` full suite (274+ tests per issue #13's PR baseline) still green. + +--- + +### U5. Spec documentation and example + +**Goal:** `docs/spec.md` documents `shadows:` under `Elevation & Depth`, generated (not hand-written) from `spec-config.yaml` + `spec.mdx`; one example `DESIGN.md` demonstrates it. + +**Requirements:** R5, R6 + +**Dependencies:** U1 + +**Files:** +- `packages/cli/src/linter/spec-gen/spec.mdx` — add prose for the `Elevation & Depth` section describing the `shadows:` category, its five sub-fields, and the token-reference behavior for `color` (follow the existing `Typography` prose block's structure and depth). +- `docs/spec.md` — regenerated via `bun run spec:gen`; never hand-edited. +- `examples/paws-and-paths/DESIGN.md` (or whichever example is least token-dense) — add a `shadows:` block with 1-2 tokens and reference one from a `components.*` entry (e.g., a card's `boxShadow`-equivalent), consistent with `component_sub_tokens` if a shadow-referencing sub-token is added in U1/U4 scope — otherwise reference it via a custom/prose mention consistent with PHILOSOPHY.md's "tokens are universal, prose is where design lives" stance. +- `README.md` — if it has a token-category summary table (as issue #101's PR #112 mentions for section order), add `shadows` there too. + +**Approach:** Do not hand-edit `docs/spec.md`; author only `spec.mdx`, then run the generator. Keep prose short and consistent with `PHILOSOPHY.md`'s stated project philosophy (spec defines universal categories; prose is where the design lives) — avoid prescribing exact shadow "meanings," just document the schema and resolution behavior. + +**Patterns to follow:** the `Typography` section of `spec.mdx` for prose depth/structure; `spec-config.ts:29` doc comment describing the edit → `spec:gen` → `bun test` workflow. + +**Test scenarios:** +- `bun run spec:gen` regenerates `docs/spec.md` with no diff drift beyond the new section (i.e., generation is deterministic). +- The updated example `DESIGN.md` passes `design.md lint` with 0 errors (run via existing example-linting test if one exists, e.g. `check-package.ts`'s example checks). + +**Verification:** `bun run spec:gen && git diff --stat docs/spec.md` shows only the expected addition; `bunx @google/design.md lint examples/paws-and-paths/DESIGN.md` (or repo-local equivalent) reports 0 errors on the updated example. + +--- + +## Verification Contract + +- `bun test` (full suite) passes, including all new/updated tests across U1-U4. +- `bun run spec:gen` produces a deterministic, reviewable diff to `docs/spec.md` limited to the new `shadows` documentation. +- `packages/cli/scripts/check-package.ts` package-integrity checks (referenced in the #153 fix, check `#20 CLI spec command valid`-style assertions) still pass — this change touches `spec-config.yaml`/`spec-gen`, the same subsystem #153 fixed, so a regression there would be a direct repeat of that prior bug. +- Updated example `DESIGN.md` lints clean. + +## Definition of Done + +- `shadows:` token category has a schema (U1), model resolution (U2, U3), lint validation (U4), spec docs (U5), and an example (U5). +- All test scenarios above pass. +- No regression to existing `colors`/`typography`/`rounded`/`spacing`/`components` resolution or lint behavior. +- PR opened against `google-labs-code/design.md` referencing and closing `#92`. + +## Open Questions + +- Multi-layer shadows (array of shadow objects per token) — deferred; flag as a natural follow-up in the PR description so maintainers can decide if it belongs in this PR or a follow-up. +- Whether `shadows` needs its own `component_sub_tokens` entry (e.g., a `boxShadow` property referencing `{shadows.*}` from a component) — U5 assumes yes for the example to be meaningful, but the exact sub-token name is a judgment call left to the implementer, following the `backgroundColor`/`textColor` naming convention already in `component_sub_tokens`. diff --git a/docs/spec.md b/docs/spec.md index 5995e548..e146b640 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -266,6 +266,39 @@ 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. ``` +### Design Tokens + +The optional `shadows` section defines structured shadow tokens for design systems that convey elevation through box-shadow-style values. Each token is a composite of offset, blur, spread, and color — the same layered-value approach as `typography`, rather than a single CSS `box-shadow` string. + +It is a +map\, where each `Shadow` has the following properties: + +- `offsetX` (Dimension) - Horizontal offset of the shadow. Negative values shift the shadow left. +- `offsetY` (Dimension) - Vertical offset of the shadow. Negative values shift the shadow up. +- `blur` (Dimension) - Blur radius. Larger values produce a softer, more spread-out shadow. +- `spread` (Dimension) - Spread radius. Optional; defaults to `0px` when omitted. +- `color` (Color | Reference) - The shadow's color, as a literal color value or a `{colors.*}` token reference. + +The `color` property accepts a literal color value or a `{colors.*}` token reference, so a shadow's tint can stay tied to the palette. + +```yaml +shadows: + card: + offsetX: 0px + offsetY: 4px + blur: 8px + spread: 0px + color: #00000033 +``` + +A component references a shadow token the same way it references any other token, via a component sub-token (see [Components](#components)): + +```yaml +components: + card: + boxShadow: "{shadows.card}" +``` + ## Shapes This section describes how visual elements are shaped. @@ -339,6 +372,7 @@ Each component has a set of properties that are themselves design tokens: - size: \ - height: \ - width: \ +- boxShadow: \ ## Do's and Don'ts diff --git a/examples/paws-and-paths/DESIGN.md b/examples/paws-and-paths/DESIGN.md index 72b25afa..a9f4160b 100644 --- a/examples/paws-and-paths/DESIGN.md +++ b/examples/paws-and-paths/DESIGN.md @@ -108,6 +108,13 @@ spacing: xl: 64px gutter: 16px margin: 24px +shadows: + card: + offsetX: 0px + offsetY: 2px + blur: 6px + spread: 0px + color: "#00000026" components: button-primary: backgroundColor: "{colors.primary}" @@ -131,6 +138,7 @@ components: backgroundColor: "{colors.surface-container-lowest}" rounded: "{rounded.xl}" padding: "{spacing.md}" + boxShadow: "{shadows.card}" card-walk-stat: backgroundColor: "{colors.secondary-container}" textColor: "{colors.on-secondary-container}" diff --git a/packages/cli/src/commands/spec.test.ts b/packages/cli/src/commands/spec.test.ts index b7113ec0..9ec8a97e 100644 --- a/packages/cli/src/commands/spec.test.ts +++ b/packages/cli/src/commands/spec.test.ts @@ -89,6 +89,6 @@ describe('spec command', () => { const output = JSON.parse(outputStr); expect(output.spec).toBeDefined(); expect(output.rules).toBeDefined(); - expect(output.rules.length).toBe(11); + expect(output.rules.length).toBe(12); }); }); diff --git a/packages/cli/src/linter/css-vars/handler.test.ts b/packages/cli/src/linter/css-vars/handler.test.ts index bebd472f..9784420a 100644 --- a/packages/cli/src/linter/css-vars/handler.test.ts +++ b/packages/cli/src/linter/css-vars/handler.test.ts @@ -15,7 +15,7 @@ import { describe, test, expect } from 'bun:test'; import { CssVarsEmitterHandler } from './handler.js'; import { serializeCssVars } from './serialize.js'; -import type { DesignSystemState, ResolvedColor, ResolvedDimension } from '../model/spec.js'; +import type { DesignSystemState, ResolvedColor, ResolvedDimension, ResolvedShadow } from '../model/spec.js'; function emptyState(overrides?: Partial): DesignSystemState { return { @@ -23,6 +23,7 @@ function emptyState(overrides?: Partial): DesignSystemState { typography: new Map(), rounded: new Map(), spacing: new Map(), + shadows: new Map(), components: new Map(), symbolTable: new Map(), ...overrides, @@ -132,4 +133,36 @@ describe('CssVarsEmitterHandler', () => { + '}\n', ); }); + + test('shadows emit --shadow-* declarations with space-separated CSS values', () => { + const state = emptyState({ + shadows: new Map([ + ['card', { + type: 'shadow', + offsetX: makeDim(0, 'px'), + offsetY: makeDim(4, 'px'), + blur: makeDim(8, 'px'), + spread: makeDim(0, 'px'), + color: makeColor('#00000033', 0, 0, 0), + }], + ['button', { + type: 'shadow', + offsetX: makeDim(2, 'px'), + offsetY: makeDim(2, 'px'), + blur: makeDim(4, 'px'), + }], + ]), + }); + + const result = handler.execute(state); + expect(result.success).toBe(true); + if (!result.success) return; + + expect(serializeCssVars(result.data.declarations)).toBe( + ':root {\n' + + ' --shadow-card: 0px 4px 8px 0px #00000033;\n' + + ' --shadow-button: 2px 2px 4px 0px transparent;\n' + + '}\n', + ); + }); }); diff --git a/packages/cli/src/linter/css-vars/handler.ts b/packages/cli/src/linter/css-vars/handler.ts index d25b6e99..99bfe4b2 100644 --- a/packages/cli/src/linter/css-vars/handler.ts +++ b/packages/cli/src/linter/css-vars/handler.ts @@ -13,7 +13,7 @@ // limitations under the License. import type { CssVarDeclaration, CssVarsEmitterSpec, CssVarsEmitterResult } from './spec.js'; -import type { DesignSystemState, ResolvedDimension } from '../model/spec.js'; +import type { DesignSystemState, ResolvedDimension, ResolvedShadow } from '../model/spec.js'; /** * Pure function mapping DesignSystemState → CSS custom property declarations. @@ -32,10 +32,29 @@ export class CssVarsEmitterHandler implements CssVarsEmitterSpec { this.mapDimensionGroup(declarations, 'spacing', state.spacing); this.mapDimensionGroup(declarations, 'rounded', state.rounded); + this.mapShadowGroup(declarations, 'shadow', state.shadows); return { success: true, data: { declarations } }; } + private mapShadowGroup( + declarations: CssVarDeclaration[], + group: 'shadow', + shadows: Map, + ): void { + for (const [name, shadow] of shadows) { + const x = shadow.offsetX ? this.dimToString(shadow.offsetX) : '0px'; + const y = shadow.offsetY ? this.dimToString(shadow.offsetY) : '0px'; + const blur = shadow.blur ? this.dimToString(shadow.blur) : '0px'; + const spread = shadow.spread ? this.dimToString(shadow.spread) : '0px'; + const color = shadow.color ? shadow.color.hex.toLowerCase() : 'transparent'; + declarations.push({ + name: `${group}-${this.cssSafe(name)}`, + value: `${x} ${y} ${blur} ${spread} ${color}`, + }); + } + } + private mapDimensionGroup( declarations: CssVarDeclaration[], group: 'spacing' | 'rounded', diff --git a/packages/cli/src/linter/dtcg/handler.test.ts b/packages/cli/src/linter/dtcg/handler.test.ts index 1933852f..e5561b91 100644 --- a/packages/cli/src/linter/dtcg/handler.test.ts +++ b/packages/cli/src/linter/dtcg/handler.test.ts @@ -22,6 +22,7 @@ function emptyState(overrides?: Partial): DesignSystemState { typography: new Map(), rounded: new Map(), spacing: new Map(), + shadows: new Map(), components: new Map(), symbolTable: new Map(), ...overrides, diff --git a/packages/cli/src/linter/linter/rules/index.ts b/packages/cli/src/linter/linter/rules/index.ts index 596a78e4..c4eb08c0 100644 --- a/packages/cli/src/linter/linter/rules/index.ts +++ b/packages/cli/src/linter/linter/rules/index.ts @@ -19,6 +19,7 @@ import { brokenRefRule } from './broken-ref.js'; import { missingPrimaryRule } from './missing-primary.js'; import { contrastCheckRule } from './contrast-ratio.js'; import { orphanedTokensRule } from './orphaned-tokens.js'; +import { shadowOrphanedRule } from './shadow-orphaned.js'; import { tokenSummaryRule } from './token-summary.js'; import { missingSectionsRule } from './missing-sections.js'; import { sectionOrderRule } from './section-order.js'; @@ -33,6 +34,7 @@ export const DEFAULT_RULE_DESCRIPTORS: RuleDescriptor[] = [ missingPrimaryRule, contrastCheckRule, orphanedTokensRule, + shadowOrphanedRule, tokenSummaryRule, missingSectionsRule, missingTypographyRule, @@ -61,6 +63,7 @@ export { brokenRef } from './broken-ref.js'; export { missingPrimary } from './missing-primary.js'; export { contrastCheck } from './contrast-ratio.js'; export { orphanedTokens } from './orphaned-tokens.js'; +export { shadowOrphaned } from './shadow-orphaned.js'; export { tokenSummary } from './token-summary.js'; export { missingSections } from './missing-sections.js'; export { missingTypography } from './missing-typography.js'; diff --git a/packages/cli/src/linter/linter/rules/orphaned-tokens.ts b/packages/cli/src/linter/linter/rules/orphaned-tokens.ts index 55697f6a..1ecd5224 100644 --- a/packages/cli/src/linter/linter/rules/orphaned-tokens.ts +++ b/packages/cli/src/linter/linter/rules/orphaned-tokens.ts @@ -14,6 +14,7 @@ import type { DesignSystemState } from '../../model/spec.js'; import type { RuleDescriptor, RuleFinding } from './types.js'; +import { computeReferencedPaths } from './reference-utils.js'; /** * Reduce a Material Design 3 color token name to its family root. @@ -60,18 +61,7 @@ const MD3_STANDARD_FAMILIES = new Set([ export function orphanedTokens(state: DesignSystemState): RuleFinding[] { if (state.components.size === 0) return []; - const referencedPaths = new Set(); - for (const [, comp] of state.components) { - for (const [, value] of comp.properties) { - if (typeof value === 'object' && value !== null && 'type' in value) { - for (const [key, symValue] of state.symbolTable) { - if (symValue === value) { - referencedPaths.add(key); - } - } - } - } - } + const referencedPaths = computeReferencedPaths(state); // A component referencing one MD3 token implies its semantic siblings are // part of the same in-use group (e.g. `primary` brings `on-primary`, diff --git a/packages/cli/src/linter/linter/rules/reference-utils.ts b/packages/cli/src/linter/linter/rules/reference-utils.ts new file mode 100644 index 00000000..9aa28a87 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/reference-utils.ts @@ -0,0 +1,37 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { DesignSystemState } from '../../model/spec.js'; + +/** + * Compute the set of symbol table paths (e.g. "colors.primary", + * "shadows.card") referenced by any component property. Shared by + * orphan-detection rules so the O(components × properties × symbolTable) + * scan runs once per lint pass instead of once per token category. + */ +export function computeReferencedPaths(state: DesignSystemState): Set { + const referencedPaths = new Set(); + for (const [, comp] of state.components) { + for (const [, value] of comp.properties) { + if (typeof value === 'object' && value !== null && 'type' in value) { + for (const [key, symValue] of state.symbolTable) { + if (symValue === value) { + referencedPaths.add(key); + } + } + } + } + } + return referencedPaths; +} diff --git a/packages/cli/src/linter/linter/rules/shadow-orphaned.test.ts b/packages/cli/src/linter/linter/rules/shadow-orphaned.test.ts new file mode 100644 index 00000000..09e1747c --- /dev/null +++ b/packages/cli/src/linter/linter/rules/shadow-orphaned.test.ts @@ -0,0 +1,56 @@ +// 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 { shadowOrphaned } from './shadow-orphaned.js'; +import { buildState } from './test-helpers.js'; + +describe('shadowOrphaned', () => { + it('emits warning for a shadow not referenced by any component', () => { + const state = buildState({ + shadows: { + card: { offsetX: '0px', offsetY: '4px', blur: '8px', color: '#00000033' }, + unused: { offsetX: '0px', offsetY: '2px', blur: '4px', color: '#00000022' }, + }, + components: { + card: { boxShadow: '{shadows.card}' }, + }, + }); + const findings = shadowOrphaned(state); + expect(findings.some(f => f.message.includes('unused'))).toBe(true); + expect(findings.some(f => f.path === 'shadows.card')).toBe(false); + }); + + it('returns empty when no components exist', () => { + const state = buildState({ + shadows: { card: { offsetX: '0px', offsetY: '4px', blur: '8px', color: '#000000' } }, + }); + expect(shadowOrphaned(state)).toEqual([]); + }); + + it('returns empty when no shadows exist', () => { + const state = buildState({ + components: { button: { backgroundColor: '#ffffff' } }, + }); + expect(shadowOrphaned(state)).toEqual([]); + }); + + it('does not flag a shadow referenced by a component', () => { + const state = buildState({ + shadows: { card: { offsetX: '0px', offsetY: '4px', blur: '8px', color: '#000000' } }, + components: { card: { boxShadow: '{shadows.card}' } }, + }); + expect(shadowOrphaned(state)).toEqual([]); + }); +}); diff --git a/packages/cli/src/linter/linter/rules/shadow-orphaned.ts b/packages/cli/src/linter/linter/rules/shadow-orphaned.ts new file mode 100644 index 00000000..80ef2680 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/shadow-orphaned.ts @@ -0,0 +1,46 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { DesignSystemState } from '../../model/spec.js'; +import type { RuleDescriptor, RuleFinding } from './types.js'; +import { computeReferencedPaths } from './reference-utils.js'; + +/** + * Shadow orphaned tokens — a shadow token defined but never referenced by + * any component property. Unlike `orphaned-tokens` (colors), shadows have no + * MD3-style sibling-family convention, so this is a direct reference check. + */ +export function shadowOrphaned(state: DesignSystemState): RuleFinding[] { + if (state.shadows.size === 0 || state.components.size === 0) return []; + + const referencedPaths = computeReferencedPaths(state); + + const findings: RuleFinding[] = []; + for (const [name] of state.shadows) { + const path = `shadows.${name}`; + if (referencedPaths.has(path)) continue; + findings.push({ + path, + message: `'${name}' is defined but never referenced by any component.`, + }); + } + return findings; +} + +export const shadowOrphanedRule: RuleDescriptor = { + name: 'shadow-orphaned', + severity: 'warning', + description: 'Shadow orphaned tokens — shadow tokens defined but never referenced by any component.', + run: shadowOrphaned, +}; diff --git a/packages/cli/src/linter/linter/rules/types.test.ts b/packages/cli/src/linter/linter/rules/types.test.ts index 5df84454..ef3ec2b6 100644 --- a/packages/cli/src/linter/linter/rules/types.test.ts +++ b/packages/cli/src/linter/linter/rules/types.test.ts @@ -24,6 +24,7 @@ describe('LintRule type', () => { typography: new Map(), rounded: new Map(), spacing: new Map(), + shadows: new Map(), components: new Map(), symbolTable: new Map(), })).toEqual([]); @@ -40,7 +41,7 @@ describe('LintRule type', () => { }); it('has all rules in DEFAULT_RULE_DESCRIPTORS', () => { - expect(DEFAULT_RULE_DESCRIPTORS.length).toBe(11); + expect(DEFAULT_RULE_DESCRIPTORS.length).toBe(12); DEFAULT_RULE_DESCRIPTORS.forEach((rule: RuleDescriptor) => { expect(rule.name).toBeTruthy(); expect(rule.severity).toBeTruthy(); diff --git a/packages/cli/src/linter/linter/rules/unknown-key.test.ts b/packages/cli/src/linter/linter/rules/unknown-key.test.ts index ad8f7d65..e9d2bfa5 100644 --- a/packages/cli/src/linter/linter/rules/unknown-key.test.ts +++ b/packages/cli/src/linter/linter/rules/unknown-key.test.ts @@ -51,6 +51,15 @@ describe('unknownKey', () => { expect(findings[0]!.message).toBe('Unknown key "nam" — did you mean "name"?'); }); + it('warns and suggests "shadows" for "shadow" (distance 1)', () => { + const state = buildState({ + sourceMap: new Map([['shadow', loc]]), + }); + const findings = unknownKey(state); + expect(findings.length).toBe(1); + expect(findings[0]!.message).toBe('Unknown key "shadow" — did you mean "shadows"?'); + }); + it('matches case-insensitively (e.g. "Colors" is treated as known)', () => { const state = buildState({ sourceMap: new Map([['Colors', loc]]), diff --git a/packages/cli/src/linter/model/handler.test.ts b/packages/cli/src/linter/model/handler.test.ts index e4023074..f50adf33 100644 --- a/packages/cli/src/linter/model/handler.test.ts +++ b/packages/cli/src/linter/model/handler.test.ts @@ -804,4 +804,154 @@ describe('ModelHandler', () => { expect(result.designSystem.colors.get('ok')?.hex).toBe('#ffffff'); }); }); + + // ── Shadows (Issue #92) ──────────────────────────────────────────── + describe('shadows', () => { + it('resolves a fully-specified shadow into a composite ResolvedShadow', () => { + const result = handler.execute(makeParsed({ + shadows: { + card: { offsetX: '0px', offsetY: '4px', blur: '8px', spread: '0px', color: '#00000033' }, + }, + })); + expect(result.findings.filter(f => f.severity === 'error')).toEqual([]); + const card = result.designSystem.shadows.get('card'); + expect(card).toBeDefined(); + expect(card!.type).toBe('shadow'); + expect(card!.offsetX).toEqual({ type: 'dimension', value: 0, unit: 'px' }); + expect(card!.offsetY).toEqual({ type: 'dimension', value: 4, unit: 'px' }); + expect(card!.blur).toEqual({ type: 'dimension', value: 8, unit: 'px' }); + expect(card!.spread).toEqual({ type: 'dimension', value: 0, unit: 'px' }); + expect(card!.color?.hex).toBe('#00000033'); + }); + + it('resolves a {colors.*} reference for the color sub-field', () => { + const result = handler.execute(makeParsed({ + colors: { 'shadow-ambient': '#101010' }, + shadows: { + card: { offsetX: '0px', offsetY: '4px', blur: '8px', color: '{colors.shadow-ambient}' }, + }, + })); + expect(result.findings.filter(f => f.severity === 'error')).toEqual([]); + expect(result.designSystem.shadows.get('card')!.color?.hex).toBe('#101010'); + }); + + it('defaults spread to 0px when omitted', () => { + const result = handler.execute(makeParsed({ + shadows: { + card: { offsetX: '0px', offsetY: '4px', blur: '8px', color: '#000000' }, + }, + })); + expect(result.designSystem.shadows.get('card')!.spread).toEqual({ type: 'dimension', value: 0, unit: 'px' }); + }); + + it('emits an error for an invalid dimension sub-field', () => { + const result = handler.execute(makeParsed({ + shadows: { + card: { offsetX: 'not-a-dimension', offsetY: '4px', blur: '8px', color: '#000000' }, + }, + })); + expect(result.findings.some(f => f.path === 'shadows.card.offsetX' && f.severity === 'error')).toBe(true); + }); + + it('emits an error when the color reference does not resolve', () => { + const result = handler.execute(makeParsed({ + shadows: { + card: { offsetX: '0px', offsetY: '4px', blur: '8px', color: '{colors.nonexistent}' }, + }, + })); + expect(result.findings.some(f => f.path === 'shadows.card.color' && f.severity === 'error')).toBe(true); + }); + + it('warns on unrecognized shadow sub-properties', () => { + const result = handler.execute(makeParsed({ + shadows: { + card: { offsetX: '0px', offsetY: '4px', blur: '8px', color: '#000000', inset: true }, + }, + })); + expect(result.findings.some(f => f.path === 'shadows.card.inset' && f.severity === 'warning')).toBe(true); + }); + + it('emits diagnostic when two shadow token names normalize to the same flattened path', () => { + const result = handler.execute(makeParsed({ + shadows: { + 'card.lg': { offsetX: '0px', offsetY: '4px', blur: '8px', color: '#000000' }, + 'card-lg': { offsetX: '2px', offsetY: '2px', blur: '4px', color: '#111111' }, + }, + })); + // Same flattened-name collision guard used by colors/rounded/spacing. + const errors = result.findings.filter(f => f.severity === 'error'); + expect(errors.some(f => f.message.includes('shadows') && f.message.includes('already defined'))).toBe(true); + }); + + it('resolves a shadow color referencing a chained/indirect color alias', () => { + const result = handler.execute(makeParsed({ + colors: { base: '#101010', alias: '{colors.base}' }, + shadows: { + card: { offsetX: '0px', offsetY: '4px', blur: '8px', color: '{colors.alias}' }, + }, + })); + expect(result.findings.filter(f => f.severity === 'error')).toEqual([]); + expect(result.designSystem.shadows.get('card')!.color?.hex).toBe('#101010'); + }); + + it('emits an error when color references a non-color resolved value', () => { + const result = handler.execute(makeParsed({ + rounded: { md: '8px' }, + shadows: { + card: { offsetX: '0px', offsetY: '4px', blur: '8px', color: '{rounded.md}' }, + }, + })); + expect(result.findings.some(f => f.path === 'shadows.card.color' && f.severity === 'error')).toBe(true); + expect(result.designSystem.shadows.get('card')!.color).toBeUndefined(); + }); + + it('resolves a {rounded.*} / {spacing.*} token reference for a dimension sub-field', () => { + const result = handler.execute(makeParsed({ + spacing: { sm: '8px' }, + shadows: { + card: { offsetX: '0px', offsetY: '{spacing.sm}', blur: '8px', color: '#000000' }, + }, + })); + expect(result.findings.filter(f => f.severity === 'error')).toEqual([]); + expect(result.designSystem.shadows.get('card')!.offsetY).toEqual({ type: 'dimension', value: 8, unit: 'px' }); + }); + + it('emits an error when a dimension reference does not resolve to a dimension', () => { + const result = handler.execute(makeParsed({ + colors: { primary: '#ffffff' }, + shadows: { + card: { offsetX: '0px', offsetY: '{colors.primary}', blur: '8px', color: '#000000' }, + }, + })); + expect(result.findings.some(f => f.path === 'shadows.card.offsetY' && f.severity === 'error')).toBe(true); + }); + + it('emits an error instead of throwing when a shadow token is not an object', () => { + const result = handler.execute(makeParsed({ + colors: { valid: '#ffffff' }, + shadows: { broken: null as unknown as Record }, + })); + // The malformed shadow entry does not swallow findings for the rest of the file. + expect(result.findings.some(f => f.path === 'shadows.broken' && f.severity === 'error')).toBe(true); + expect(result.designSystem.colors.get('valid')).toBeDefined(); + }); + + it('emits an error for a non-string dimension sub-field instead of silently dropping it', () => { + const result = handler.execute(makeParsed({ + shadows: { + card: { offsetX: 4, offsetY: '4px', blur: '8px', color: '#000000' } as unknown as Record, + }, + })); + expect(result.findings.some(f => f.path === 'shadows.card.offsetX' && f.severity === 'error')).toBe(true); + }); + + it('emits an error for a non-string color instead of silently dropping it', () => { + const result = handler.execute(makeParsed({ + shadows: { + card: { offsetX: '0px', offsetY: '4px', blur: '8px', color: true } as unknown as Record, + }, + })); + expect(result.findings.some(f => f.path === 'shadows.card.color' && f.severity === 'error')).toBe(true); + }); + }); }); \ No newline at end of file diff --git a/packages/cli/src/linter/model/handler.ts b/packages/cli/src/linter/model/handler.ts index 9ea1193b..1beb1155 100644 --- a/packages/cli/src/linter/model/handler.ts +++ b/packages/cli/src/linter/model/handler.ts @@ -20,12 +20,13 @@ import type { ResolvedColor, ResolvedDimension, ResolvedTypography, + ResolvedShadow, ResolvedValue, ComponentDef, Finding, } from './spec.js'; -import { isValidColor, isParseableDimension, isTokenReference, parseDimensionParts, VALID_TYPOGRAPHY_PROPS } from './spec.js'; +import { isValidColor, isParseableDimension, isTokenReference, parseDimensionParts, VALID_TYPOGRAPHY_PROPS, VALID_SHADOW_PROPS } from './spec.js'; import { parseCssColor } from './color-parser.js'; import { @@ -35,6 +36,7 @@ import { const SCHEMA_KEY_SET: ReadonlySet = new Set(SCHEMA_KEYS); const TYPOGRAPHY_PROP_SET: ReadonlySet = new Set(VALID_TYPOGRAPHY_PROPS); +const SHADOW_PROP_SET: ReadonlySet = new Set(VALID_SHADOW_PROPS); /** * Builds a resolved DesignSystemState from parsed YAML tokens. @@ -51,6 +53,7 @@ export class ModelHandler implements ModelSpec { const typography = new Map(); const rounded = new Map(); const spacing = new Map(); + const shadows = new Map(); // ── Phase 1: Resolve primitive tokens ────────────────────────── // Colors @@ -135,6 +138,21 @@ export class ModelHandler implements ModelSpec { }, '', 0, findings, 'spacing'); } + // Shadows — composite tokens (offsetX/offsetY/blur/spread/color), same + // shape as typography. `color` may be a literal or a {colors.*} + // reference; resolveReference already chases reference chains, so it + // resolves correctly regardless of whether the referenced color was + // itself stored as a raw reference in Phase 1. + if (input.shadows) { + const isCollision = buildCollisionGuard('shadows', findings); + for (const [name, props] of Object.entries(input.shadows)) { + if (isCollision(name)) continue; + const resolved = parseShadow(props, `shadows.${name}`, symbolTable, findings); + shadows.set(name, resolved); + symbolTable.set(`shadows.${name}`, resolved); + } + } + // ── Phase 2: Resolve chained token references ────────────────── // Iterate the symbol table directly (not re-walking raw input) so that // Phase 1 collision decisions are never overwritten. @@ -217,6 +235,7 @@ export class ModelHandler implements ModelSpec { typography, rounded, spacing, + shadows, components, symbolTable, sections: input.sections, @@ -232,6 +251,7 @@ export class ModelHandler implements ModelSpec { typography: new Map(), rounded: new Map(), spacing: new Map(), + shadows: new Map(), components: new Map(), symbolTable: new Map(), }, @@ -420,6 +440,117 @@ function parseTypography(props: Record, path: string, f return result; } +/** + * Parse a shadow properties object into a ResolvedShadow. + * `spread` defaults to 0px when omitted. `color` accepts a literal CSS color + * or a `{colors.*}` (or any) token reference, resolved via the symbol table. + */ +function parseShadow( + props: unknown, + path: string, + symbolTable: Map, + findings: Finding[], +): ResolvedShadow { + const result: ResolvedShadow = { type: 'shadow' }; + + if (typeof props !== 'object' || props === null || Array.isArray(props)) { + findings.push({ + severity: 'error', + path, + message: `Shadow token must be an object with offsetX/offsetY/blur/spread/color properties.`, + }); + return result; + } + const propsObj = props as Record; + + const dimensionProps = ['offsetX', 'offsetY', 'blur', 'spread'] as const; + for (const prop of dimensionProps) { + const raw = propsObj[prop]; + if (raw === undefined) { + if (prop === 'spread') result.spread = { type: 'dimension', value: 0, unit: 'px' }; + continue; + } + if (typeof raw !== 'string') { + findings.push({ + severity: 'error', + path: `${path}.${prop}`, + message: `'${String(raw)}' is not a valid dimension. Expected a string (e.g., "4px").`, + }); + continue; + } + if (isParseableDimension(raw)) { + const parsed = parseDimension(raw); + if (parsed.unit !== 'px' && parsed.unit !== 'rem' && parsed.unit !== 'em') { + findings.push({ + severity: 'error', + path: `${path}.${prop}`, + message: `'${raw}' has an invalid unit '${parsed.unit}'. Only px, rem, and em are allowed.`, + }); + } + result[prop] = parsed; + } else if (isTokenReference(raw)) { + const resolved = resolveReference(symbolTable, raw.slice(1, -1), new Set()); + if (resolved !== null && typeof resolved === 'object' && 'type' in resolved && resolved.type === 'dimension') { + result[prop] = resolved as ResolvedDimension; + } else { + findings.push({ + severity: 'error', + path: `${path}.${prop}`, + message: `'${raw}' does not resolve to a valid dimension.`, + }); + } + } else { + findings.push({ + severity: 'error', + path: `${path}.${prop}`, + message: `'${raw}' is not a valid dimension.`, + }); + } + } + + const rawColor = propsObj['color']; + if (rawColor === undefined) { + // No finding: color is validated for presence elsewhere (component-level usage), matching typography's optional-field pattern. + } else if (typeof rawColor !== 'string') { + findings.push({ + severity: 'error', + path: `${path}.color`, + message: `'${String(rawColor)}' is not a valid color. Expected a string.`, + }); + } else if (isTokenReference(rawColor)) { + const resolved = resolveReference(symbolTable, rawColor.slice(1, -1), new Set()); + if (resolved !== null && typeof resolved === 'object' && 'type' in resolved && resolved.type === 'color') { + result.color = resolved as ResolvedColor; + } else { + findings.push({ + severity: 'error', + path: `${path}.color`, + message: `'${rawColor}' does not resolve to a valid color.`, + }); + } + } else if (isValidColor(rawColor)) { + result.color = parseColor(rawColor); + } else { + findings.push({ + severity: 'error', + path: `${path}.color`, + message: `'${rawColor}' is not a valid color. Expected a CSS color value (e.g., #ffffff, rgb(0 0 0)) or a {colors.*} reference.`, + }); + } + + for (const key of Object.keys(propsObj)) { + if (!SHADOW_PROP_SET.has(key)) { + findings.push({ + severity: 'warning', + path: `${path}.${key}`, + message: `'${key}' is not a recognized shadow property. Valid properties: ${VALID_SHADOW_PROPS.join(', ')}.`, + }); + } + } + + return result; +} + /** * Resolve a token reference with chained resolution and cycle detection. * Returns null if the reference cannot be resolved (not found or circular). diff --git a/packages/cli/src/linter/model/spec.ts b/packages/cli/src/linter/model/spec.ts index c84b09b3..8c82b425 100644 --- a/packages/cli/src/linter/model/spec.ts +++ b/packages/cli/src/linter/model/spec.ts @@ -17,6 +17,7 @@ import type { ParsedDesignSystem, OmittedSection } from '../parser/spec.js'; import { STANDARD_UNITS as _STANDARD_UNITS, VALID_TYPOGRAPHY_PROPS as _VALID_TYPOGRAPHY_PROPS, + VALID_SHADOW_PROPS as _VALID_SHADOW_PROPS, VALID_COMPONENT_SUB_TOKENS as _VALID_COMPONENT_SUB_TOKENS, } from '../spec-config.js'; import { parseCssColor } from './color-parser.js'; @@ -62,10 +63,21 @@ export interface ResolvedTypography { fontVariation?: string | undefined; } -export type ResolvedValue = ResolvedColor | ResolvedDimension | ResolvedTypography | string | number | boolean; +export interface ResolvedShadow { + type: 'shadow'; + offsetX?: ResolvedDimension | undefined; + offsetY?: ResolvedDimension | undefined; + blur?: ResolvedDimension | undefined; + /** Spread radius. Defaults to 0px when omitted from the source token. */ + spread?: ResolvedDimension | undefined; + color?: ResolvedColor | undefined; +} + +export type ResolvedValue = ResolvedColor | ResolvedDimension | ResolvedTypography | ResolvedShadow | string | number | boolean; // ── Re-exported from spec-config (single source of truth) ───────── export const VALID_TYPOGRAPHY_PROPS = _VALID_TYPOGRAPHY_PROPS; +export const VALID_SHADOW_PROPS = _VALID_SHADOW_PROPS; export const VALID_COMPONENT_SUB_TOKENS = _VALID_COMPONENT_SUB_TOKENS; // ── STATE ────────────────────────────────────────────────────────── @@ -77,6 +89,7 @@ export interface DesignSystemState { typography: Map; rounded: Map; spacing: Map; + shadows: Map; components: Map; /** Flat lookup: "colors.primary" → ResolvedColor */ symbolTable: Map; @@ -99,6 +112,7 @@ export const ModelErrorCode = z.enum([ 'INVALID_COLOR', 'INVALID_DIMENSION', 'INVALID_TYPOGRAPHY_PROP', + 'INVALID_SHADOW', 'UNRESOLVED_REFERENCE', 'CIRCULAR_REFERENCE', 'REFERENCE_TO_NON_PRIMITIVE', diff --git a/packages/cli/src/linter/parser/handler.ts b/packages/cli/src/linter/parser/handler.ts index 624fdee1..d021ea7f 100644 --- a/packages/cli/src/linter/parser/handler.ts +++ b/packages/cli/src/linter/parser/handler.ts @@ -219,6 +219,7 @@ export class ParserHandler implements ParserSpec { typography: raw['typography'] as Record> | undefined, rounded: raw['rounded'] as Record | undefined, spacing: raw['spacing'] as Record | undefined, + shadows: raw['shadows'] as Record> | undefined, components: raw['components'] as Record> | undefined, sourceMap, sections, diff --git a/packages/cli/src/linter/parser/spec.ts b/packages/cli/src/linter/parser/spec.ts index d274c7c2..daa2e77b 100644 --- a/packages/cli/src/linter/parser/spec.ts +++ b/packages/cli/src/linter/parser/spec.ts @@ -52,6 +52,7 @@ export interface ParsedDesignSystem { typography?: Record> | undefined; rounded?: Record | undefined; spacing?: Record | undefined; + shadows?: Record> | undefined; components?: Record> | undefined; sourceMap: Map; /** Markdown heading names found in the document (e.g., 'Colors', 'Typography') */ @@ -72,6 +73,7 @@ export const SCHEMA_KEYS = [ 'typography', 'rounded', 'spacing', + 'shadows', 'components', ] as const; diff --git a/packages/cli/src/linter/spec-config.test.ts b/packages/cli/src/linter/spec-config.test.ts index 33c697cb..0d72e946 100644 --- a/packages/cli/src/linter/spec-config.test.ts +++ b/packages/cli/src/linter/spec-config.test.ts @@ -21,6 +21,7 @@ import { SPEC_TYPES, SECTIONS, TYPOGRAPHY_PROPERTIES, + SHADOW_PROPERTIES, COMPONENT_SUB_TOKENS, CORE_COLOR_ROLES, RECOMMENDED_TOKENS, @@ -29,6 +30,7 @@ import { SECTION_ALIASES, resolveAlias, VALID_TYPOGRAPHY_PROPS, + VALID_SHADOW_PROPS, VALID_COMPONENT_SUB_TOKENS, PRIMITIVE_TYPES, } from './spec-config.js'; @@ -153,6 +155,12 @@ describe('spec-config structural invariants', () => { expect(new Set(names).size).toBe(names.length); }); + it('shadow property names are unique and cover the composite fields', () => { + const names = SHADOW_PROPERTIES.map(p => p.name); + expect(new Set(names).size).toBe(names.length); + expect(names).toEqual(['offsetX', 'offsetY', 'blur', 'spread', 'color']); + }); + it('color roles are unique', () => { expect(new Set(CORE_COLOR_ROLES).size).toBe(CORE_COLOR_ROLES.length); }); @@ -176,9 +184,10 @@ describe('spec-config structural invariants', () => { } }); - it('examples covers colors, typography, and components', () => { + it('examples covers colors, typography, shadows, and components', () => { expect(Object.keys(EXAMPLES.colors).length).toBeGreaterThan(0); expect(Object.keys(EXAMPLES.typography).length).toBeGreaterThan(0); + expect(Object.keys(EXAMPLES.shadows).length).toBeGreaterThan(0); expect(Object.keys(EXAMPLES.components).length).toBeGreaterThan(0); }); }); @@ -207,6 +216,10 @@ describe('spec-config derived constants', () => { expect(VALID_TYPOGRAPHY_PROPS.length).toBe(TYPOGRAPHY_PROPERTIES.length); }); + it('VALID_SHADOW_PROPS length matches SHADOW_PROPERTIES', () => { + expect(VALID_SHADOW_PROPS.length).toBe(SHADOW_PROPERTIES.length); + }); + it('VALID_COMPONENT_SUB_TOKENS length matches COMPONENT_SUB_TOKENS', () => { expect(VALID_COMPONENT_SUB_TOKENS.length).toBe(COMPONENT_SUB_TOKENS.length); }); diff --git a/packages/cli/src/linter/spec-config.ts b/packages/cli/src/linter/spec-config.ts index 4b73af52..31cb0516 100644 --- a/packages/cli/src/linter/spec-config.ts +++ b/packages/cli/src/linter/spec-config.ts @@ -59,12 +59,14 @@ const ConfigSchema = z.object({ aliases: z.array(z.string()).optional(), })).min(1), typography_properties: z.array(PropertyDefSchema).min(1), + shadow_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()]))), + shadows: 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())), }), }); @@ -103,7 +105,8 @@ export interface SectionDef { aliases?: readonly string[] | undefined; } -export interface TypographyPropertyDef { +/** A named, typed property with an optional description — shared shape for typography and shadow sub-properties. */ +export interface PropertyDef { /** Property name as it appears in YAML. */ name: string; /** Human-readable type for the spec document. */ @@ -112,6 +115,9 @@ export interface TypographyPropertyDef { description?: string | undefined; } +/** @deprecated Use {@link PropertyDef}. Kept for backward compatibility with existing consumers. */ +export type TypographyPropertyDef = PropertyDef; + export interface ComponentSubTokenDef { /** Sub-token property name. */ name: string; @@ -156,7 +162,9 @@ export const SPEC_TYPES: Record = config.types; export const SECTIONS = config.sections; -export const TYPOGRAPHY_PROPERTIES: readonly TypographyPropertyDef[] = config.typography_properties; +export const TYPOGRAPHY_PROPERTIES: readonly PropertyDef[] = config.typography_properties; + +export const SHADOW_PROPERTIES: readonly PropertyDef[] = config.shadow_properties; export const COMPONENT_SUB_TOKENS: readonly ComponentSubTokenDef[] = config.component_sub_tokens; @@ -192,6 +200,9 @@ export function resolveAlias(heading: string): string { /** Valid typography property names (for linter validation). */ export const VALID_TYPOGRAPHY_PROPS = TYPOGRAPHY_PROPERTIES.map(p => p.name); +/** Valid shadow property names (for linter validation). */ +export const VALID_SHADOW_PROPS = SHADOW_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); @@ -206,6 +217,7 @@ export interface SpecConfig { SPEC_TYPES: typeof SPEC_TYPES; SECTIONS: typeof SECTIONS; TYPOGRAPHY_PROPERTIES: typeof TYPOGRAPHY_PROPERTIES; + SHADOW_PROPERTIES: typeof SHADOW_PROPERTIES; COMPONENT_SUB_TOKENS: typeof COMPONENT_SUB_TOKENS; CORE_COLOR_ROLES: typeof CORE_COLOR_ROLES; RECOMMENDED_TOKENS: typeof RECOMMENDED_TOKENS; @@ -222,6 +234,7 @@ export const SPEC_CONFIG: SpecConfig = { SPEC_TYPES, SECTIONS, TYPOGRAPHY_PROPERTIES, + SHADOW_PROPERTIES, COMPONENT_SUB_TOKENS, CORE_COLOR_ROLES, RECOMMENDED_TOKENS, diff --git a/packages/cli/src/linter/spec-config.yaml b/packages/cli/src/linter/spec-config.yaml index 6b1e90fd..87eef4d9 100644 --- a/packages/cli/src/linter/spec-config.yaml +++ b/packages/cli/src/linter/spec-config.yaml @@ -82,6 +82,23 @@ typography_properties: type: string description: "configures\n [`font-variation-settings`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/font-variation-settings)." +shadow_properties: + - name: offsetX + type: Dimension + description: "Horizontal offset of the shadow. Negative values shift the shadow left." + - name: offsetY + type: Dimension + description: "Vertical offset of the shadow. Negative values shift the shadow up." + - name: blur + type: Dimension + description: "Blur radius. Larger values produce a softer, more spread-out shadow." + - name: spread + type: Dimension + description: "Spread radius. Optional; defaults to `0px` when omitted." + - name: color + type: "Color | Reference" + description: "The shadow's color, as a literal color value or a `{colors.*}` token reference." + component_sub_tokens: - name: backgroundColor type: Color @@ -99,6 +116,8 @@ component_sub_tokens: type: Dimension - name: width type: Dimension + - name: boxShadow + type: Shadow color_roles: - primary @@ -157,6 +176,13 @@ examples: fontWeight: 500 lineHeight: 1.0 letterSpacing: "0.1em" + shadows: + card: + offsetX: 0px + offsetY: 4px + blur: 8px + spread: 0px + color: "#00000033" components: button-primary: backgroundColor: "{colors.primary-60}" diff --git a/packages/cli/src/linter/spec-gen/compiler.test.ts b/packages/cli/src/linter/spec-gen/compiler.test.ts index 16f79bd4..db6049e9 100644 --- a/packages/cli/src/linter/spec-gen/compiler.test.ts +++ b/packages/cli/src/linter/spec-gen/compiler.test.ts @@ -68,8 +68,10 @@ describe('compileMdx', () => { frontmatterExample: () => renderers.frontmatterExample(cfg), colorsExample: () => renderers.colorsExample(cfg), typographyExample: () => renderers.typographyExample(cfg), + shadowsExample: () => renderers.shadowsExample(cfg), componentsExample: () => renderers.componentsExample(cfg), typographyPropertyList: () => renderers.typographyPropertyList(cfg), + shadowPropertyList: () => renderers.shadowPropertyList(cfg), sectionOrderList: () => renderers.sectionOrderList(cfg), componentSubTokenList: () => renderers.componentSubTokenList(cfg), recommendedTokens: () => renderers.recommendedTokens(cfg), diff --git a/packages/cli/src/linter/spec-gen/generate.ts b/packages/cli/src/linter/spec-gen/generate.ts index d7d6e459..54e1104c 100644 --- a/packages/cli/src/linter/spec-gen/generate.ts +++ b/packages/cli/src/linter/spec-gen/generate.ts @@ -44,8 +44,10 @@ async function main() { frontmatterExample: () => renderers.frontmatterExample(cfg), colorsExample: () => renderers.colorsExample(cfg), typographyExample: () => renderers.typographyExample(cfg), + shadowsExample: () => renderers.shadowsExample(cfg), componentsExample: () => renderers.componentsExample(cfg), typographyPropertyList: () => renderers.typographyPropertyList(cfg), + shadowPropertyList: () => renderers.shadowPropertyList(cfg), sectionOrderList: () => renderers.sectionOrderList(cfg), componentSubTokenList: () => renderers.componentSubTokenList(cfg), recommendedTokens: () => renderers.recommendedTokens(cfg), diff --git a/packages/cli/src/linter/spec-gen/renderers.ts b/packages/cli/src/linter/spec-gen/renderers.ts index b8d60f97..860a35dd 100644 --- a/packages/cli/src/linter/spec-gen/renderers.ts +++ b/packages/cli/src/linter/spec-gen/renderers.ts @@ -19,7 +19,7 @@ * Each function returns a ready-to-embed markdown string. */ -import type { SpecConfig, TypographyPropertyDef, SectionDef, ComponentSubTokenDef, TypeDef } from '../spec-config.js'; +import type { SpecConfig, PropertyDef, SectionDef, ComponentSubTokenDef, TypeDef } from '../spec-config.js'; // ── YAML code block helpers ───────────────────────────────────── @@ -76,6 +76,16 @@ export function typographyExample(config: SpecConfig): string { return yamlBlock(lines); } +/** Shadows YAML example. */ +export function shadowsExample(config: SpecConfig): string { + const lines = ['shadows:']; + for (const [name, props] of Object.entries(config.EXAMPLES.shadows)) { + lines.push(` ${name}:`); + lines.push(...yamlObject(props as Record)); + } + return yamlBlock(lines); +} + /** Components YAML example. */ export function componentsExample(config: SpecConfig): string { const lines = ['components:']; @@ -88,7 +98,16 @@ export function componentsExample(config: SpecConfig): string { /** Typography property list (for the schema section). */ export function typographyPropertyList(config: SpecConfig): string { - return config.TYPOGRAPHY_PROPERTIES.map((p: TypographyPropertyDef) => + return config.TYPOGRAPHY_PROPERTIES.map((p: PropertyDef) => + p.description + ? `- \`${p.name}\` (${p.type}) - ${p.description}` + : `- \`${p.name}\` (${p.type})` + ).join('\n'); +} + +/** Shadow property list (for the schema section). */ +export function shadowPropertyList(config: SpecConfig): string { + return config.SHADOW_PROPERTIES.map((p: PropertyDef) => p.description ? `- \`${p.name}\` (${p.type}) - ${p.description}` : `- \`${p.name}\` (${p.type})` diff --git a/packages/cli/src/linter/spec-gen/spec.mdx b/packages/cli/src/linter/spec-gen/spec.mdx index c9e417d6..d4ef4e35 100644 --- a/packages/cli/src/linter/spec-gen/spec.mdx +++ b/packages/cli/src/linter/spec-gen/spec.mdx @@ -198,6 +198,27 @@ 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. ``` +### Design Tokens + +The optional `shadows` section defines structured shadow tokens for design systems that convey elevation through box-shadow-style values. Each token is a composite of offset, blur, spread, and color — the same layered-value approach as `typography`, rather than a single CSS `box-shadow` string. + +It is a +map\, where each `Shadow` has the following properties: + +{shadowPropertyList()} + +The `color` property accepts a literal color value or a `{colors.*}` token reference, so a shadow's tint can stay tied to the palette. + +{shadowsExample()} + +A component references a shadow token the same way it references any other token, via a component sub-token (see [Components](#components)): + +```yaml +components: + card: + boxShadow: "{shadows.card}" +``` + ## Shapes This section describes how visual elements are shaped. diff --git a/packages/cli/src/linter/tailwind/v4/handler.test.ts b/packages/cli/src/linter/tailwind/v4/handler.test.ts index 9ce025cf..e33951c4 100644 --- a/packages/cli/src/linter/tailwind/v4/handler.test.ts +++ b/packages/cli/src/linter/tailwind/v4/handler.test.ts @@ -133,6 +133,47 @@ describe('TailwindV4EmitterHandler', () => { }); }); + describe('shadows mapping', () => { + it('maps resolved shadows to theme.shadow keyed by token name', () => { + const state = buildState({ + shadows: { + card: { + offsetX: '0px', + offsetY: '4px', + blur: '8px', + spread: '2px', + color: '#00000033', + }, + button: { + offsetX: '1px', + offsetY: '2px', + blur: '3px', + }, + }, + }); + const result = emitter.execute(state); + if (!result.success) throw new Error('Expected success'); + const theme = result.data.theme; + + expect(theme.shadow?.['card']).toBe('0px 4px 8px 2px #00000033'); + expect(theme.shadow?.['button']).toBe('1px 2px 3px 0px transparent'); + }); + + it('fails when a shadow token name is not a valid CSS identifier', () => { + const state = buildState({}); + state.shadows.set('has space', { + type: 'shadow', + offsetX: { type: 'dimension', value: 0, unit: 'px' }, + offsetY: { type: 'dimension', value: 4, unit: 'px' }, + blur: { type: 'dimension', value: 8, unit: 'px' }, + spread: { type: 'dimension', value: 0, unit: 'px' }, + color: { type: 'color', hex: '#00000033', r: 0, g: 0, b: 0, luminance: 0 }, + }); + const result = emitter.execute(state); + expect(result.success).toBe(false); + }); + }); + describe('empty state', () => { it('returns success with an empty theme object', () => { const state = buildState({}); diff --git a/packages/cli/src/linter/tailwind/v4/handler.ts b/packages/cli/src/linter/tailwind/v4/handler.ts index 0f2ef477..7342606f 100644 --- a/packages/cli/src/linter/tailwind/v4/handler.ts +++ b/packages/cli/src/linter/tailwind/v4/handler.ts @@ -13,7 +13,7 @@ // limitations under the License. import type { TailwindV4EmitterSpec, TailwindV4EmitterResult, TailwindV4ThemeData } from './spec.js'; -import type { DesignSystemState, ResolvedDimension } from '../../model/spec.js'; +import type { DesignSystemState, ResolvedDimension, ResolvedShadow } from '../../model/spec.js'; const VALID_TOKEN_NAME = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/; @@ -32,6 +32,7 @@ export class TailwindV4EmitterHandler implements TailwindV4EmitterSpec { ...state.typography.keys(), ...state.rounded.keys(), ...state.spacing.keys(), + ...state.shadows.keys(), ]; for (const name of allNames) { if (!VALID_TOKEN_NAME.test(name)) { @@ -81,6 +82,20 @@ export class TailwindV4EmitterHandler implements TailwindV4EmitterSpec { theme.spacing = mapDimensions(state.spacing); } + // Shadows + if (state.shadows.size > 0) { + const shadow: Record = {}; + for (const [name, s] of state.shadows) { + const x = s.offsetX ? dimToString(s.offsetX) : '0px'; + const y = s.offsetY ? dimToString(s.offsetY) : '0px'; + const blur = s.blur ? dimToString(s.blur) : '0px'; + const spread = s.spread ? dimToString(s.spread) : '0px'; + const color = s.color ? s.color.hex.toLowerCase() : 'transparent'; + shadow[name] = `${x} ${y} ${blur} ${spread} ${color}`; + } + theme.shadow = shadow; + } + return { success: true, data: { theme } }; } } diff --git a/packages/cli/src/linter/tailwind/v4/serialize.test.ts b/packages/cli/src/linter/tailwind/v4/serialize.test.ts index e1b89f43..78785c2e 100644 --- a/packages/cli/src/linter/tailwind/v4/serialize.test.ts +++ b/packages/cli/src/linter/tailwind/v4/serialize.test.ts @@ -37,6 +37,7 @@ describe('serializeToCss', () => { fontWeight: { 'headline-lg': '500' }, borderRadius: { regular: '4px' }, spacing: { 'gutter-s': '8px' }, + shadow: { card: '0px 4px 8px 0px #00000033' }, }; const out = serializeToCss(data); expect(out).toContain('--color-primary: #000000;'); @@ -47,23 +48,27 @@ describe('serializeToCss', () => { expect(out).toContain('--font-weight-headline-lg: 500;'); expect(out).toContain('--radius-regular: 4px;'); expect(out).toContain('--spacing-gutter-s: 8px;'); + expect(out).toContain('--shadow-card: 0px 4px 8px 0px #00000033;'); }); - it('emits categories in fixed order: colors → fontFamily → fontSize → lineHeight → letterSpacing → fontWeight → borderRadius → spacing', () => { + it('emits categories in fixed order: colors → fontFamily → fontSize → lineHeight → letterSpacing → fontWeight → borderRadius → spacing → shadow', () => { const data: TailwindV4ThemeData = { spacing: { s: '8px' }, colors: { primary: '#000000' }, borderRadius: { r: '4px' }, fontFamily: { f: '"X"' }, + shadow: { sh: '0px 4px 8px 0px #00000033' }, }; const out = serializeToCss(data); const colorIdx = out.indexOf('--color-primary'); const fontFamilyIdx = out.indexOf('--font-f'); const radiusIdx = out.indexOf('--radius-r'); const spacingIdx = out.indexOf('--spacing-s'); + const shadowIdx = out.indexOf('--shadow-sh'); expect(colorIdx).toBeLessThan(fontFamilyIdx); expect(fontFamilyIdx).toBeLessThan(radiusIdx); expect(radiusIdx).toBeLessThan(spacingIdx); + expect(spacingIdx).toBeLessThan(shadowIdx); }); it('skips empty categories (no blank lines)', () => { diff --git a/packages/cli/src/linter/tailwind/v4/serialize.ts b/packages/cli/src/linter/tailwind/v4/serialize.ts index 49e6a51d..334271c0 100644 --- a/packages/cli/src/linter/tailwind/v4/serialize.ts +++ b/packages/cli/src/linter/tailwind/v4/serialize.ts @@ -24,6 +24,7 @@ const CATEGORIES: ReadonlyArray = ['fontWeight', '--font-weight-'], ['borderRadius', '--radius-'], ['spacing', '--spacing-'], + ['shadow', '--shadow-'], ]; /** diff --git a/packages/cli/src/linter/tailwind/v4/spec.ts b/packages/cli/src/linter/tailwind/v4/spec.ts index 7d0b9f73..90cb01cd 100644 --- a/packages/cli/src/linter/tailwind/v4/spec.ts +++ b/packages/cli/src/linter/tailwind/v4/spec.ts @@ -27,6 +27,7 @@ export const TailwindV4ThemeDataSchema = z.object({ fontWeight: z.record(z.string()).optional(), borderRadius: z.record(z.string()).optional(), spacing: z.record(z.string()).optional(), + shadow: z.record(z.string()).optional(), }); export type TailwindV4ThemeData = z.infer;