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
12 changes: 11 additions & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,9 @@ components:
button-primary:
backgroundColor: "{colors.primary-60}"
textColor: "{colors.primary-20}"
focusRingColor: "{colors.primary-20}"
focusRingWidth: 2px
focusRingOffset: 2px
rounded: "{rounded.md}"
padding: 12px
button-primary-hover:
Expand All @@ -327,6 +330,13 @@ Each component has a set of properties that are themselves design tokens:
- size: \<Dimension\>
- height: \<Dimension\>
- width: \<Dimension\>
- focusRingColor: \<Color\>
- focusRingWidth: \<Dimension\>
- focusRingOffset: \<Dimension\>

**Focus indicators.** `focusRingColor`, `focusRingWidth`, and `focusRingOffset` are optional. When omitted, consumers must preserve the browser default focus indicator — never remove focus styling for aesthetics. Custom focus rings must meet WCAG 2.4.11 Focus Appearance (minimum 3:1 contrast against adjacent colors; minimum 2 CSS pixels along the perimeter).

**Foreground/background pairs.** When a background color role is defined (e.g., `primary`, `surface`), define a matching `on-*` foreground token (e.g., `on-primary`, `on-surface`) so agents do not guess text colors at implementation time.

## Do's and Don'ts

Expand All @@ -345,7 +355,7 @@ This section provides practical guidelines and common pitfalls. These act as gua

The following names are commonly used across design systems. They are not required but are provided as guidance for consistency.

**Colors:** `primary`, `secondary`, `tertiary`, `neutral`, `surface`, `on-surface`, `error`
**Colors:** `primary`, `secondary`, `tertiary`, `neutral`, `surface`, `on-primary`, `on-secondary`, `on-surface`, `error`, `on-error`

**Typography:** `headline-display`, `headline-lg`, `headline-md`, `body-lg`, `body-md`, `body-sm`, `label-lg`, `label-md`, `label-sm`

Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/linter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export {
brokenRef,
missingPrimary,
contrastCheck,
focusRingContrastCheck,
missingOnColorCheck,
onColorContrastCheck,
orphanedTokens,
tokenSummary,
missingSections,
Expand Down
58 changes: 58 additions & 0 deletions packages/cli/src/linter/linter/rules/focus-ring-contrast.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// 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 { focusRingContrastCheck } from './focus-ring-contrast.js';
import { buildState } from './test-helpers.js';

describe('focusRingContrastCheck', () => {
it('warns when focus ring contrast is below 3:1', () => {
const state = buildState({
colors: { bg: '#ffffff', ring: '#cccccc' },
components: {
'button-primary': {
backgroundColor: '{colors.bg}',
focusRingColor: '{colors.ring}',
},
},
});
const findings = focusRingContrastCheck(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toMatch(/2\.4\.11/);
});

it('passes high-contrast focus ring', () => {
const state = buildState({
colors: { bg: '#ffffff', ring: '#000000' },
components: {
'button-primary': {
backgroundColor: '{colors.bg}',
focusRingColor: '{colors.ring}',
},
},
});
expect(focusRingContrastCheck(state).length).toBe(0);
});

it('warns when focusRingWidth is below 2px', () => {
const state = buildState({
components: {
'button-primary': { focusRingWidth: '1px' },
},
});
const findings = focusRingContrastCheck(state);
expect(findings.length).toBe(1);
expect(findings[0]!.path).toBe('components.button-primary.focusRingWidth');
});
});
84 changes: 84 additions & 0 deletions packages/cli/src/linter/linter/rules/focus-ring-contrast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// 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 { contrastRatio } from '../../model/handler.js';
import type { RuleDescriptor, RuleFinding } from './types.js';

const WCAG_FOCUS_MINIMUM = 3;

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

function parsePx(value: ResolvedValue): number | null {
if (typeof value === 'object' && value !== null && 'type' in value && value.type === 'dimension') {
const dim = value as { value: number; unit: string };
if (dim.unit === 'px') return dim.value;
}
return null;
}

/**
* WCAG 2.4.11 focus appearance — warns when focusRingColor on backgroundColor
* falls below 3:1, or when focusRingWidth is below 2px.
*/
export function focusRingContrastCheck(state: DesignSystemState): RuleFinding[] {
const findings: RuleFinding[] = [];

for (const [compName, comp] of state.components) {
const ringValue = comp.properties.get('focusRingColor');
const bgValue = comp.properties.get('backgroundColor');
const widthValue = comp.properties.get('focusRingWidth');

if (ringValue && bgValue) {
const ringColor = resolveToColor(ringValue);
const bgColor = resolveToColor(bgValue);
if (ringColor && bgColor) {
const ratio = contrastRatio(ringColor, bgColor);
if (ratio < WCAG_FOCUS_MINIMUM) {
findings.push({
path: `components.${compName}`,
message:
`focusRingColor (${ringColor.hex}) on backgroundColor (${bgColor.hex}) has contrast ratio ` +
`${ratio.toFixed(2)}:1, below WCAG 2.4.11 minimum of ${WCAG_FOCUS_MINIMUM}:1.`,
});
}
}
}

if (widthValue) {
const px = parsePx(widthValue);
if (px !== null && px < 2) {
findings.push({
path: `components.${compName}.focusRingWidth`,
message: `focusRingWidth (${px}px) is below the 2px minimum recommended for WCAG 2.4.11 focus appearance.`,
});
}
}
}

return findings;
}

export const focusRingContrastRule: RuleDescriptor = {
name: 'focus-ring-contrast',
severity: 'warning',
description:
'Focus ring contrast — warns when focusRingColor/backgroundColor pairs fall below WCAG 2.4.11 (3:1) or focusRingWidth is below 2px.',
run: focusRingContrastCheck,
};
9 changes: 9 additions & 0 deletions packages/cli/src/linter/linter/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,18 @@ import { sectionOrderRule } from './section-order.js';
import { missingTypographyRule } from './missing-typography.js';
import { unknownKeyRule } from './unknown-key.js';
import { tokenLikeIgnoredRule } from './token-like-ignored.js';
import { focusRingContrastRule } from './focus-ring-contrast.js';
import { missingOnColorRule } from './missing-on-color.js';
import { onColorContrastRule } from './on-color-contrast.js';

/** The default set of lint rule descriptors, in order. */
export const DEFAULT_RULE_DESCRIPTORS: RuleDescriptor[] = [
brokenRefRule,
missingPrimaryRule,
contrastCheckRule,
onColorContrastRule,
missingOnColorRule,
focusRingContrastRule,
orphanedTokensRule,
tokenSummaryRule,
missingSectionsRule,
Expand All @@ -57,6 +63,9 @@ 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 { focusRingContrastCheck, focusRingContrastRule } from './focus-ring-contrast.js';
export { missingOnColorCheck, missingOnColorRule } from './missing-on-color.js';
export { onColorContrastCheck, onColorContrastRule } from './on-color-contrast.js';
export { orphanedTokens } from './orphaned-tokens.js';
export { tokenSummary } from './token-summary.js';
export { missingSections } from './missing-sections.js';
Expand Down
35 changes: 35 additions & 0 deletions packages/cli/src/linter/linter/rules/missing-on-color.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// 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 { missingOnColorCheck } from './missing-on-color.js';
import { buildState } from './test-helpers.js';

describe('missingOnColorCheck', () => {
it('info when primary exists without on-primary', () => {
const state = buildState({
colors: { primary: '#1a1c1e' },
});
const findings = missingOnColorCheck(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toContain('on-primary');
});

it('silent when on-primary is defined', () => {
const state = buildState({
colors: { primary: '#1a1c1e', 'on-primary': '#ffffff' },
});
expect(missingOnColorCheck(state).length).toBe(0);
});
});
58 changes: 58 additions & 0 deletions packages/cli/src/linter/linter/rules/missing-on-color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// 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';

/** Background color roles that should have a matching on-* foreground token. */
const PAIRABLE_ROLES = new Set([
'primary',
'secondary',
'tertiary',
'neutral',
'surface',
'error',
]);

/**
* Missing on-* color — info when a background color role exists without its
* paired foreground token (e.g., primary without on-primary).
*/
export function missingOnColorCheck(state: DesignSystemState): RuleFinding[] {
const findings: RuleFinding[] = [];

for (const role of PAIRABLE_ROLES) {
if (!state.colors.has(role)) continue;
const onToken = `on-${role}`;
if (state.colors.has(onToken)) continue;

findings.push({
path: `colors.${role}`,
severity: 'info',
message:
`Color '${role}' is defined without a paired '${onToken}' foreground token — ` +
'agents may guess text colors at implementation time.',
});
}

return findings;
}

export const missingOnColorRule: RuleDescriptor = {
name: 'missing-on-color',
severity: 'info',
description:
'Missing on-* color — surfaces when a background color role lacks a paired on-* foreground token.',
run: missingOnColorCheck,
};
35 changes: 35 additions & 0 deletions packages/cli/src/linter/linter/rules/on-color-contrast.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// 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 { onColorContrastCheck } from './on-color-contrast.js';
import { buildState } from './test-helpers.js';

describe('onColorContrastCheck', () => {
it('warns when on-primary contrast is too low', () => {
const state = buildState({
colors: { primary: '#ffff00', 'on-primary': '#ffffff' },
});
const findings = onColorContrastCheck(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toMatch(/on-primary/);
});

it('passes high-contrast on-primary pair', () => {
const state = buildState({
colors: { primary: '#1a1c1e', 'on-primary': '#ffffff' },
});
expect(onColorContrastCheck(state).length).toBe(0);
});
});
Loading