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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,14 +317,15 @@ 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 |
|:-----|:---------|:---------------|
| `broken-ref` | error | Token references (`{colors.primary}`) that don't resolve to any defined token |
| `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 |
Expand Down
231 changes: 231 additions & 0 deletions docs/plans/2026-08-07-001-feat-shadow-elevation-tokens-plan.md

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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\<string, Shadow>, 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.
Expand Down Expand Up @@ -339,6 +372,7 @@ Each component has a set of properties that are themselves design tokens:
- size: \<Dimension\>
- height: \<Dimension\>
- width: \<Dimension\>
- boxShadow: \<Shadow\>

## Do's and Don'ts

Expand Down
8 changes: 8 additions & 0 deletions examples/paws-and-paths/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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}"
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
35 changes: 34 additions & 1 deletion packages/cli/src/linter/css-vars/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@
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>): DesignSystemState {
return {
colors: new Map(),
typography: new Map(),
rounded: new Map(),
spacing: new Map(),
shadows: new Map(),
components: new Map(),
symbolTable: new Map(),
...overrides,
Expand Down Expand Up @@ -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',
);
});
});
21 changes: 20 additions & 1 deletion packages/cli/src/linter/css-vars/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<string, ResolvedShadow>,
): 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',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/linter/dtcg/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function emptyState(overrides?: Partial<DesignSystemState>): DesignSystemState {
typography: new Map(),
rounded: new Map(),
spacing: new Map(),
shadows: new Map(),
components: new Map(),
symbolTable: new Map(),
...overrides,
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/linter/linter/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -33,6 +34,7 @@ export const DEFAULT_RULE_DESCRIPTORS: RuleDescriptor[] = [
missingPrimaryRule,
contrastCheckRule,
orphanedTokensRule,
shadowOrphanedRule,
tokenSummaryRule,
missingSectionsRule,
missingTypographyRule,
Expand Down Expand Up @@ -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';
Expand Down
14 changes: 2 additions & 12 deletions packages/cli/src/linter/linter/rules/orphaned-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string>();
for (const [, comp] of state.components) {
for (const [, value] of comp.properties) {
if (typeof value === 'object' && value !== null && 'type' in value) {
for (const [key, symValue] of state.symbolTable) {
if (symValue === value) {
referencedPaths.add(key);
}
}
}
}
}
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`,
Expand Down
37 changes: 37 additions & 0 deletions packages/cli/src/linter/linter/rules/reference-utils.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const referencedPaths = new Set<string>();
for (const [, comp] of state.components) {
for (const [, value] of comp.properties) {
if (typeof value === 'object' && value !== null && 'type' in value) {
for (const [key, symValue] of state.symbolTable) {
if (symValue === value) {
referencedPaths.add(key);
}
}
}
}
}
return referencedPaths;
}
56 changes: 56 additions & 0 deletions packages/cli/src/linter/linter/rules/shadow-orphaned.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
46 changes: 46 additions & 0 deletions packages/cli/src/linter/linter/rules/shadow-orphaned.ts
Original file line number Diff line number Diff line change
@@ -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,
};
Loading