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
6 changes: 6 additions & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ The `<scale-level>` placeholder represents a named level in a sizing or spacing
* Named colors: `red`, `cornflowerblue`, `transparent`
* Functional: `rgb()`, `rgba()`, `hsl()`, `hsla()`, `hwb()`
* Wide-gamut: `oklch()`, `oklab()`, `lch()`, `lab()`
* Color spaces: `color(display-p3 ...)`, `color(srgb ...)`, `color(rec2020 ...)`
* Mixing: `color-mix(in srgb, ...)`

All color values are internally converted to sRGB for WCAG contrast checking. The original format is preserved for display and export.
Expand Down Expand Up @@ -245,6 +246,8 @@ Also known as "Elevation".

This section describes how visual hierarchy is conveyed based on the design style. If elevation is used, it defines the required styling (spread, blur, color). For flat designs, this section explains the alternative methods used to convey visual hierarchy (e.g., borders, color contrast).

When depth relies on shadows or tonal layering alone, document a **forced-colors fallback**: borders, outlines, or high-contrast separators that preserve hierarchy in Windows High Contrast Mode and other environments where `@media (forced-colors: active)` disables box shadows. Agents should treat shadow-only elevation as incomplete without an explicit non-shadow fallback.

Example:

```markdown
Expand Down Expand Up @@ -304,6 +307,8 @@ The components section defines a collection of design tokens used to ensure cons

**Variants**. A component may have a variant for different UI states such as active, hover, pressed, etc. Those variant components may be defined under a different but related key, for example, "button-primary", "button-primary-hover", "button-primary-active". The agent will consider all variants and make the appropriate styling decisions.

**Interactive states**. For interactive components (buttons, inputs, chips, checkboxes), define explicit variants for hover, focus (see focus-ring tokens), and disabled states using suffix conventions such as `-hover`, `-active`, `-disabled`, `-selected`, or `-pressed`. Disabled states must not rely on reduced opacity alone — change `backgroundColor`, `textColor`, and/or `borderColor` so non-text boundaries remain perceivable (WCAG 1.4.11). Semantic ARIA states (`aria-selected`, `aria-expanded`, `aria-pressed`) should map to distinct visual variants where possible.

```yaml
components:
button-primary:
Expand All @@ -321,6 +326,7 @@ Each component has a set of properties that are themselves design tokens:

- backgroundColor: \<Color\>
- textColor: \<Color\>
- borderColor: \<Color\>
- typography: \<Typography\>
- rounded: \<Dimension\>
- padding: \<Dimension\>
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(10);
expect(output.rules.length).toBe(14);
});
});
4 changes: 4 additions & 0 deletions packages/cli/src/linter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export {
brokenRef,
missingPrimary,
contrastCheck,
nonTextContrastCheck,
wideGamutPaletteCheck,
missingForcedColorsGuidance,
interactiveStateTokensCheck,
orphanedTokens,
tokenSummary,
missingSections,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/linter/linter/rules/broken-ref.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe('brokenRef', () => {
it('emits warning (not error) for unknown component sub-tokens', () => {
const state = buildState({
colors: { primary: '#ff0000' },
components: { button: { borderColor: '#ff0000' } },
components: { button: { shadowColor: '#ff0000' } },
});
const findings = brokenRef(state);
const subTokenDiag = findings.find(d => d.message.includes('not a recognized'));
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/linter/linter/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import type { Finding } from '../spec.js';
import { brokenRefRule } from './broken-ref.js';
import { missingPrimaryRule } from './missing-primary.js';
import { contrastCheckRule } from './contrast-ratio.js';
import { nonTextContrastCheckRule } from './non-text-contrast.js';
import { wideGamutPaletteCheckRule } from './wide-gamut-palette.js';
import { missingForcedColorsGuidanceRule } from './missing-forced-colors-guidance.js';
import { interactiveStateTokensCheckRule } from './interactive-state-tokens.js';
import { orphanedTokensRule } from './orphaned-tokens.js';
import { tokenSummaryRule } from './token-summary.js';
import { missingSectionsRule } from './missing-sections.js';
Expand All @@ -31,6 +35,10 @@ export const DEFAULT_RULE_DESCRIPTORS: RuleDescriptor[] = [
brokenRefRule,
missingPrimaryRule,
contrastCheckRule,
nonTextContrastCheckRule,
wideGamutPaletteCheckRule,
missingForcedColorsGuidanceRule,
interactiveStateTokensCheckRule,
orphanedTokensRule,
tokenSummaryRule,
missingSectionsRule,
Expand All @@ -57,6 +65,10 @@ export const DEFAULT_RULES: LintRule[] = DEFAULT_RULE_DESCRIPTORS.map(toLintRule
export { brokenRef } from './broken-ref.js';
export { missingPrimary } from './missing-primary.js';
export { contrastCheck } from './contrast-ratio.js';
export { nonTextContrastCheck } from './non-text-contrast.js';
export { wideGamutPaletteCheck } from './wide-gamut-palette.js';
export { missingForcedColorsGuidance } from './missing-forced-colors-guidance.js';
export { interactiveStateTokensCheck } from './interactive-state-tokens.js';
export { orphanedTokens } from './orphaned-tokens.js';
export { tokenSummary } from './token-summary.js';
export { missingSections } from './missing-sections.js';
Expand Down
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 { interactiveStateTokensCheck } from './interactive-state-tokens.js';
import { buildState } from './test-helpers.js';

describe('interactiveStateTokensCheck', () => {
it('warns when button lacks hover and disabled variants', () => {
const state = buildState({
colors: { primary: '#003366', onPrimary: '#ffffff' },
components: {
'button-primary': {
backgroundColor: '{colors.primary}',
textColor: '{colors.onPrimary}',
},
},
});
const findings = interactiveStateTokensCheck(state);
expect(findings.some(f => f.message.includes('-hover'))).toBe(true);
expect(findings.some(f => f.message.includes('-disabled'))).toBe(true);
});

it('warns when disabled mirrors base colors', () => {
const state = buildState({
colors: { primary: '#003366', onPrimary: '#ffffff' },
components: {
'button-primary': {
backgroundColor: '{colors.primary}',
textColor: '{colors.onPrimary}',
},
'button-primary-disabled': {
backgroundColor: '{colors.primary}',
textColor: '{colors.onPrimary}',
},
'button-primary-hover': {
backgroundColor: '{colors.primary}',
textColor: '{colors.onPrimary}',
},
},
});
const findings = interactiveStateTokensCheck(state);
expect(findings.some(f => f.message.includes('same colors'))).toBe(true);
});
});
102 changes: 102 additions & 0 deletions packages/cli/src/linter/linter/rules/interactive-state-tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// 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, ResolvedColor, ResolvedValue } from '../../model/spec.js';
import type { RuleDescriptor, RuleFinding } from './types.js';

const INTERACTIVE_PREFIX =
/^(button|input|chip|checkbox|select|toggle|switch|tab|link|menu-item|list-item|radio|slider|textarea)(-|$)/i;
const STATE_SUFFIX = /-(hover|active|disabled|selected|pressed|focus)$/i;
const COLOR_PROPS = ['backgroundColor', 'textColor', 'borderColor'] as const;

/**
* Interactive state tokens — warns when interactive components lack hover/disabled
* variants or when disabled states mirror base colors (opacity-only pattern).
*/
export function interactiveStateTokensCheck(state: DesignSystemState): RuleFinding[] {
const findings: RuleFinding[] = [];
const names = [...state.components.keys()];
const nameSet = new Set(names);

const interactiveBases = names.filter(n => INTERACTIVE_PREFIX.test(n) && !STATE_SUFFIX.test(n));

for (const base of interactiveBases) {
const hasHover = nameSet.has(`${base}-hover`) || nameSet.has(`${base}-active`);
const hasDisabled = nameSet.has(`${base}-disabled`);

if (!hasHover) {
findings.push({
path: `components.${base}`,
message: `Interactive component '${base}' has no '-hover' or '-active' variant. Define explicit hover/active styling per the Components spec.`,
});
}

if (!hasDisabled) {
findings.push({
path: `components.${base}`,
message: `Interactive component '${base}' has no '-disabled' variant. Disabled states must use distinct colors, not opacity alone.`,
});
}

const disabledName = `${base}-disabled`;
if (nameSet.has(disabledName)) {
const baseComp = state.components.get(base)!;
const disabledComp = state.components.get(disabledName)!;
if (disabledMirrorsBase(baseComp.properties, disabledComp.properties)) {
findings.push({
path: `components.${disabledName}`,
message: `'${disabledName}' uses the same colors as '${base}'. Change backgroundColor, textColor, and/or borderColor for disabled — do not rely on reduced opacity alone.`,
});
}
}
}

return findings;
}

function disabledMirrorsBase(
baseProps: Map<string, ResolvedValue>,
disabledProps: Map<string, ResolvedValue>,
): boolean {
let compared = 0;
for (const prop of COLOR_PROPS) {
const baseVal = baseProps.get(prop);
const disabledVal = disabledProps.get(prop);
if (!baseVal || !disabledVal) continue;
compared++;
if (!colorsEqual(baseVal, disabledVal)) return false;
}
return compared > 0;
}

function colorsEqual(a: ResolvedValue, b: ResolvedValue): boolean {
const colorA = resolveToColor(a);
const colorB = resolveToColor(b);
if (!colorA || !colorB) return a === b;
return colorA.hex === colorB.hex;
}

function resolveToColor(value: ResolvedValue): ResolvedColor | null {
if (typeof value === 'object' && value !== null && 'type' in value && value.type === 'color') {
return value as ResolvedColor;
}
return null;
}

export const interactiveStateTokensCheckRule: RuleDescriptor = {
name: 'interactive-state-tokens',
severity: 'warning',
description: 'Interactive state tokens — warns about missing hover/disabled variants and opacity-only disabled states.',
run: interactiveStateTokensCheck,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// 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 { missingForcedColorsGuidance } from './missing-forced-colors-guidance.js';
import { buildState } from './test-helpers.js';

describe('missingForcedColorsGuidance', () => {
it('warns when Elevation section lacks forced-colors guidance', () => {
const state = buildState({
documentSections: [{ heading: 'Elevation', content: 'Use soft shadows for cards.' }],
});
const findings = missingForcedColorsGuidance(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toMatch(/forced-colors/);
});

it('passes when Elevation documents high-contrast fallbacks', () => {
const state = buildState({
documentSections: [
{
heading: 'Elevation',
content: 'Cards use shadow-sm. In forced-colors mode, add a 1px border fallback.',
},
],
});
expect(missingForcedColorsGuidance(state).length).toBe(0);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// 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';

const FORCED_COLORS_GUIDANCE =
/forced-colors|high[- ]contrast|border fallback|outline fallback|@media\s*\(\s*forced-colors/i;

/**
* Missing forced-colors guidance — notes when an Elevation section exists
* but does not document non-shadow fallbacks for high-contrast mode.
*/
export function missingForcedColorsGuidance(state: DesignSystemState): RuleFinding[] {
const findings: RuleFinding[] = [];
const sections = state.documentSections;
if (!sections || sections.length === 0) return findings;

const elevationSection = sections.find(s => /^elevation$/i.test(s.heading.trim()));
if (!elevationSection) return findings;

if (!FORCED_COLORS_GUIDANCE.test(elevationSection.content)) {
findings.push({
path: 'elevation',
message:
"Elevation section does not document forced-colors or high-contrast fallbacks. Add border/outline guidance for @media (forced-colors: active) when depth relies on shadows.",
});
}

return findings;
}

export const missingForcedColorsGuidanceRule: RuleDescriptor = {
name: 'missing-forced-colors-guidance',
severity: 'info',
description: 'Missing forced-colors guidance — notes when Elevation prose lacks high-contrast fallbacks.',
run: missingForcedColorsGuidance,
};
47 changes: 47 additions & 0 deletions packages/cli/src/linter/linter/rules/non-text-contrast.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// 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 { nonTextContrastCheck } from './non-text-contrast.js';
import { buildState } from './test-helpers.js';

describe('nonTextContrastCheck', () => {
it('emits warning for low border/background contrast', () => {
const state = buildState({
colors: { bg: '#ffffff', border: '#f0f0f0' },
components: {
'input-default': {
backgroundColor: '{colors.bg}',
borderColor: '{colors.border}',
},
},
});
const findings = nonTextContrastCheck(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toMatch(/1\.4\.11/);
});

it('returns empty for sufficient border contrast', () => {
const state = buildState({
colors: { bg: '#ffffff', border: '#767676' },
components: {
'input-default': {
backgroundColor: '{colors.bg}',
borderColor: '{colors.border}',
},
},
});
expect(nonTextContrastCheck(state).length).toBe(0);
});
});
Loading