diff --git a/docs/spec.md b/docs/spec.md index 41b6d085..9725d862 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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: @@ -327,6 +330,13 @@ Each component has a set of properties that are themselves design tokens: - size: \ - height: \ - width: \ +- focusRingColor: \ +- focusRingWidth: \ +- focusRingOffset: \ + +**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 @@ -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` diff --git a/packages/cli/src/linter/index.ts b/packages/cli/src/linter/index.ts index 2cf5037c..1e8e8ea4 100644 --- a/packages/cli/src/linter/index.ts +++ b/packages/cli/src/linter/index.ts @@ -40,6 +40,9 @@ export { brokenRef, missingPrimary, contrastCheck, + focusRingContrastCheck, + missingOnColorCheck, + onColorContrastCheck, orphanedTokens, tokenSummary, missingSections, diff --git a/packages/cli/src/linter/linter/rules/focus-ring-contrast.test.ts b/packages/cli/src/linter/linter/rules/focus-ring-contrast.test.ts new file mode 100644 index 00000000..b998d73a --- /dev/null +++ b/packages/cli/src/linter/linter/rules/focus-ring-contrast.test.ts @@ -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'); + }); +}); diff --git a/packages/cli/src/linter/linter/rules/focus-ring-contrast.ts b/packages/cli/src/linter/linter/rules/focus-ring-contrast.ts new file mode 100644 index 00000000..dd72ad37 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/focus-ring-contrast.ts @@ -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, +}; diff --git a/packages/cli/src/linter/linter/rules/index.ts b/packages/cli/src/linter/linter/rules/index.ts index 92a525ab..ae2aec72 100644 --- a/packages/cli/src/linter/linter/rules/index.ts +++ b/packages/cli/src/linter/linter/rules/index.ts @@ -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, @@ -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'; diff --git a/packages/cli/src/linter/linter/rules/missing-on-color.test.ts b/packages/cli/src/linter/linter/rules/missing-on-color.test.ts new file mode 100644 index 00000000..85430203 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/missing-on-color.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/linter/linter/rules/missing-on-color.ts b/packages/cli/src/linter/linter/rules/missing-on-color.ts new file mode 100644 index 00000000..ca2afcc5 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/missing-on-color.ts @@ -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, +}; diff --git a/packages/cli/src/linter/linter/rules/on-color-contrast.test.ts b/packages/cli/src/linter/linter/rules/on-color-contrast.test.ts new file mode 100644 index 00000000..bd0ff9eb --- /dev/null +++ b/packages/cli/src/linter/linter/rules/on-color-contrast.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/linter/linter/rules/on-color-contrast.ts b/packages/cli/src/linter/linter/rules/on-color-contrast.ts new file mode 100644 index 00000000..63197ab9 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/on-color-contrast.ts @@ -0,0 +1,73 @@ +// 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_AA_MINIMUM = 4.5; + +const PAIRABLE_ROLES = [ + 'primary', + 'secondary', + 'tertiary', + 'neutral', + 'surface', + 'error', +] as const; + +function resolveToColor(value: ResolvedValue): ResolvedColor | null { + if (typeof value === 'object' && value !== null && 'type' in value && value.type === 'color') { + return value as ResolvedColor; + } + return null; +} + +/** + * on-* color contrast — warns when a background/on-* token pair exists but + * falls below WCAG AA (4.5:1). + */ +export function onColorContrastCheck(state: DesignSystemState): RuleFinding[] { + const findings: RuleFinding[] = []; + + for (const role of PAIRABLE_ROLES) { + const bgValue = state.colors.get(role); + const onValue = state.colors.get(`on-${role}`); + if (!bgValue || !onValue) continue; + + const bgColor = resolveToColor(bgValue); + const onColor = resolveToColor(onValue); + if (!bgColor || !onColor) continue; + + const ratio = contrastRatio(bgColor, onColor); + if (ratio < WCAG_AA_MINIMUM) { + findings.push({ + path: `colors.${role}`, + message: + `'on-${role}' (${onColor.hex}) on '${role}' (${bgColor.hex}) has contrast ratio ` + + `${ratio.toFixed(2)}:1, below WCAG AA minimum of ${WCAG_AA_MINIMUM}:1.`, + }); + } + } + + return findings; +} + +export const onColorContrastRule: RuleDescriptor = { + name: 'on-color-contrast', + severity: 'warning', + description: + 'on-* color contrast — warns when background/on-* token pairs fall below WCAG AA (4.5:1).', + run: onColorContrastCheck, +}; diff --git a/packages/cli/src/linter/linter/rules/types.test.ts b/packages/cli/src/linter/linter/rules/types.test.ts index f33b34d3..149ff6a1 100644 --- a/packages/cli/src/linter/linter/rules/types.test.ts +++ b/packages/cli/src/linter/linter/rules/types.test.ts @@ -40,7 +40,7 @@ describe('LintRule type', () => { }); it('has all rules in DEFAULT_RULE_DESCRIPTORS', () => { - expect(DEFAULT_RULE_DESCRIPTORS.length).toBe(10); + expect(DEFAULT_RULE_DESCRIPTORS.length).toBe(13); DEFAULT_RULE_DESCRIPTORS.forEach((rule: RuleDescriptor) => { expect(rule.name).toBeTruthy(); expect(rule.severity).toBeTruthy(); diff --git a/packages/cli/src/linter/spec-config.yaml b/packages/cli/src/linter/spec-config.yaml index 67dfa143..da904eb5 100644 --- a/packages/cli/src/linter/spec-config.yaml +++ b/packages/cli/src/linter/spec-config.yaml @@ -81,6 +81,15 @@ component_sub_tokens: type: Dimension - name: width type: Dimension + - name: focusRingColor + type: Color + description: "Focus indicator color. Must meet WCAG 2.4.11 (3:1 against adjacent colors). Omit to preserve the browser default." + - name: focusRingWidth + type: Dimension + description: "Focus ring stroke width. Minimum 2px recommended for WCAG 2.4.11 focus appearance." + - name: focusRingOffset + type: Dimension + description: "Gap between the component edge and the focus ring." color_roles: - primary @@ -95,8 +104,11 @@ recommended_tokens: - tertiary - neutral - surface + - on-primary + - on-secondary - on-surface - error + - on-error typography: - headline-display - headline-lg @@ -143,6 +155,9 @@ examples: 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: diff --git a/packages/cli/src/linter/spec-gen/spec.mdx b/packages/cli/src/linter/spec-gen/spec.mdx index 44064d5c..f63acd24 100644 --- a/packages/cli/src/linter/spec-gen/spec.mdx +++ b/packages/cli/src/linter/spec-gen/spec.mdx @@ -256,6 +256,10 @@ Each component has a set of properties that are themselves design tokens: {componentSubTokenList()} +**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 This section provides practical guidelines and common pitfalls. These act as guardrails when creating designs.