diff --git a/docs/spec.md b/docs/spec.md index 41b6d085..e187fea5 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -65,6 +65,7 @@ The `` 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. @@ -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 @@ -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: @@ -321,6 +326,7 @@ Each component has a set of properties that are themselves design tokens: - backgroundColor: \ - textColor: \ +- borderColor: \ - typography: \ - rounded: \ - padding: \ diff --git a/packages/cli/src/commands/spec.test.ts b/packages/cli/src/commands/spec.test.ts index 34ea8f38..5a29108e 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(10); + expect(output.rules.length).toBe(14); }); }); diff --git a/packages/cli/src/linter/index.ts b/packages/cli/src/linter/index.ts index 2cf5037c..7c96f0e2 100644 --- a/packages/cli/src/linter/index.ts +++ b/packages/cli/src/linter/index.ts @@ -40,6 +40,10 @@ export { brokenRef, missingPrimary, contrastCheck, + nonTextContrastCheck, + wideGamutPaletteCheck, + missingForcedColorsGuidance, + interactiveStateTokensCheck, orphanedTokens, tokenSummary, missingSections, diff --git a/packages/cli/src/linter/linter/rules/broken-ref.test.ts b/packages/cli/src/linter/linter/rules/broken-ref.test.ts index 13ac68aa..004b31b7 100644 --- a/packages/cli/src/linter/linter/rules/broken-ref.test.ts +++ b/packages/cli/src/linter/linter/rules/broken-ref.test.ts @@ -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')); diff --git a/packages/cli/src/linter/linter/rules/index.ts b/packages/cli/src/linter/linter/rules/index.ts index 92a525ab..6aab44fa 100644 --- a/packages/cli/src/linter/linter/rules/index.ts +++ b/packages/cli/src/linter/linter/rules/index.ts @@ -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'; @@ -31,6 +35,10 @@ export const DEFAULT_RULE_DESCRIPTORS: RuleDescriptor[] = [ brokenRefRule, missingPrimaryRule, contrastCheckRule, + nonTextContrastCheckRule, + wideGamutPaletteCheckRule, + missingForcedColorsGuidanceRule, + interactiveStateTokensCheckRule, orphanedTokensRule, tokenSummaryRule, missingSectionsRule, @@ -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'; diff --git a/packages/cli/src/linter/linter/rules/interactive-state-tokens.test.ts b/packages/cli/src/linter/linter/rules/interactive-state-tokens.test.ts new file mode 100644 index 00000000..e990e067 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/interactive-state-tokens.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 { 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); + }); +}); diff --git a/packages/cli/src/linter/linter/rules/interactive-state-tokens.ts b/packages/cli/src/linter/linter/rules/interactive-state-tokens.ts new file mode 100644 index 00000000..12db746f --- /dev/null +++ b/packages/cli/src/linter/linter/rules/interactive-state-tokens.ts @@ -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, + disabledProps: Map, +): 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, +}; diff --git a/packages/cli/src/linter/linter/rules/missing-forced-colors-guidance.test.ts b/packages/cli/src/linter/linter/rules/missing-forced-colors-guidance.test.ts new file mode 100644 index 00000000..9aad7036 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/missing-forced-colors-guidance.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/linter/linter/rules/missing-forced-colors-guidance.ts b/packages/cli/src/linter/linter/rules/missing-forced-colors-guidance.ts new file mode 100644 index 00000000..8ae26953 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/missing-forced-colors-guidance.ts @@ -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, +}; diff --git a/packages/cli/src/linter/linter/rules/non-text-contrast.test.ts b/packages/cli/src/linter/linter/rules/non-text-contrast.test.ts new file mode 100644 index 00000000..10b7b61d --- /dev/null +++ b/packages/cli/src/linter/linter/rules/non-text-contrast.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/linter/linter/rules/non-text-contrast.ts b/packages/cli/src/linter/linter/rules/non-text-contrast.ts new file mode 100644 index 00000000..41ba08ca --- /dev/null +++ b/packages/cli/src/linter/linter/rules/non-text-contrast.ts @@ -0,0 +1,59 @@ +// 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_NON_TEXT_MINIMUM = 3; + +/** + * WCAG 1.4.11 non-text contrast — warns when component borderColor/backgroundColor + * pairs fall below the 3:1 minimum for UI boundaries. + */ +export function nonTextContrastCheck(state: DesignSystemState): RuleFinding[] { + const findings: RuleFinding[] = []; + for (const [compName, comp] of state.components) { + const borderValue = comp.properties.get('borderColor'); + const bgValue = comp.properties.get('backgroundColor'); + if (!borderValue || !bgValue) continue; + + const borderColor = resolveToColor(borderValue); + const bgColor = resolveToColor(bgValue); + if (!borderColor || !bgColor) continue; + + const ratio = contrastRatio(borderColor, bgColor); + if (ratio < WCAG_NON_TEXT_MINIMUM) { + findings.push({ + path: `components.${compName}`, + message: `borderColor (${borderColor.hex}) on backgroundColor (${bgColor.hex}) has contrast ratio ${ratio.toFixed(2)}:1, below WCAG 1.4.11 non-text minimum of ${WCAG_NON_TEXT_MINIMUM}:1.`, + }); + } + } + return findings; +} + +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 nonTextContrastCheckRule: RuleDescriptor = { + name: 'non-text-contrast', + severity: 'warning', + description: 'WCAG 1.4.11 non-text contrast — warns when borderColor/backgroundColor pairs fall below 3:1.', + run: nonTextContrastCheck, +}; diff --git a/packages/cli/src/linter/linter/rules/types.test.ts b/packages/cli/src/linter/linter/rules/types.test.ts index f33b34d3..7e0cbed7 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(14); DEFAULT_RULE_DESCRIPTORS.forEach((rule: RuleDescriptor) => { expect(rule.name).toBeTruthy(); expect(rule.severity).toBeTruthy(); diff --git a/packages/cli/src/linter/linter/rules/wide-gamut-palette.test.ts b/packages/cli/src/linter/linter/rules/wide-gamut-palette.test.ts new file mode 100644 index 00000000..07d39933 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/wide-gamut-palette.test.ts @@ -0,0 +1,44 @@ +// 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 { wideGamutPaletteCheck } from './wide-gamut-palette.js'; +import { buildState } from './test-helpers.js'; + +describe('wideGamutPaletteCheck', () => { + it('notes sRGB-only palettes', () => { + const state = buildState({ + colors: { primary: '#336699', secondary: '#ffcc00' }, + }); + const findings = wideGamutPaletteCheck(state); + expect(findings.length).toBe(1); + expect(findings[0]!.message).toMatch(/wide-gamut/); + }); + + it('accepts oklch palette without sRGB-only note', () => { + const state = buildState({ + colors: { primary: 'oklch(0.55 0.15 250)', secondary: '#ffcc00' }, + }); + const findings = wideGamutPaletteCheck(state).filter(f => f.message.includes('sRGB-limited')); + expect(findings.length).toBe(0); + }); + + it('parses color(display-p3 ...) tokens', () => { + const state = buildState({ + colors: { accent: 'color(display-p3 1 0.2 0.4)' }, + }); + const findings = wideGamutPaletteCheck(state).filter(f => f.path === 'colors.accent'); + expect(findings.length).toBe(0); + }); +}); diff --git a/packages/cli/src/linter/linter/rules/wide-gamut-palette.ts b/packages/cli/src/linter/linter/rules/wide-gamut-palette.ts new file mode 100644 index 00000000..bc992ea8 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/wide-gamut-palette.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 { parseCssColor } from '../../model/color-parser.js'; +import type { RuleDescriptor, RuleFinding } from './types.js'; + +const WIDE_GAMUT_PATTERN = /\b(oklch|oklab|lch|lab|color-mix|color\s*\(\s*(?:display-p3|srgb|rec2020))/i; + +/** + * Wide-gamut palette — notes when the palette uses only sRGB-limited syntax + * and validates wide-gamut color strings when present. + */ +export function wideGamutPaletteCheck(state: DesignSystemState): RuleFinding[] { + const findings: RuleFinding[] = []; + const sources = state.colorSources; + if (!sources || sources.size === 0) return findings; + + let hasWideGamut = false; + for (const [name, raw] of sources) { + if (WIDE_GAMUT_PATTERN.test(raw)) { + hasWideGamut = true; + if (parseCssColor(raw) === null) { + findings.push({ + path: `colors.${name}`, + message: `'${raw}' uses wide-gamut syntax but is not a valid CSS color. Check spacing, units, and color space name.`, + }); + } + } + } + + if (!hasWideGamut && sources.size >= 2) { + findings.push({ + path: 'colors', + message: 'Palette uses only sRGB-limited color syntax (hex, rgb, hsl). Consider wide-gamut tokens such as oklch() or color(display-p3 ...) for P3-capable displays.', + }); + } + + return findings; +} + +export const wideGamutPaletteCheckRule: RuleDescriptor = { + name: 'wide-gamut-palette', + severity: 'info', + description: 'Wide-gamut palette — notes sRGB-only palettes and validates wide-gamut color syntax.', + run: wideGamutPaletteCheck, +}; diff --git a/packages/cli/src/linter/model/color-parser.ts b/packages/cli/src/linter/model/color-parser.ts index f7127e15..d3bac94a 100644 --- a/packages/cli/src/linter/model/color-parser.ts +++ b/packages/cli/src/linter/model/color-parser.ts @@ -184,6 +184,36 @@ export function parseCssColor(colorStr: string): ParsedColorResult | null { const rgb = oklchToRgb(l, c, h); return makeResult(rgb.r, rgb.g, rgb.b, a); } + case 'color': { + if (args.length < 4) return null; + const space = args[0]!.toLowerCase(); + const a = args.length >= 5 ? parseAlpha(args[4]!) : 1; + if (isNaN(a)) return null; + + if (space === 'display-p3') { + const r = parseFloat(args[1]!); + const g = parseFloat(args[2]!); + const b = parseFloat(args[3]!); + if ([r, g, b].some(v => isNaN(v))) return null; + const rgb = displayP3ToSrgb(r, g, b); + return makeResult(rgb.r, rgb.g, rgb.b, a); + } + + if (space === 'srgb') { + const r = parsePercentOrNumber(args[1]!, 255); + const g = parsePercentOrNumber(args[2]!, 255); + const b = parsePercentOrNumber(args[3]!, 255); + if ([r, g, b].some(v => isNaN(v))) return null; + return makeResult( + Math.max(0, Math.min(255, Math.round(r))), + Math.max(0, Math.min(255, Math.round(g))), + Math.max(0, Math.min(255, Math.round(b))), + a, + ); + } + + return null; + } case 'color-mix': { // color-mix(in srgb, color1 percentage, color2 percentage) // Let's split the inner tokens by comma at depth 0 @@ -588,3 +618,27 @@ function oklchToRgb(l: number, c: number, h: number): { r: number; g: number; b: const b = c * Math.sin(hRad); return oklabToRgb(l, a, b); } + +/** Convert display-p3 (0–1) channel values to sRGB 0–255. */ +function displayP3ToSrgb(r: number, g: number, b: number): { r: number; g: number; b: number } { + const linearize = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)); + const rl = linearize(r); + const gl = linearize(g); + const bl = linearize(b); + + const x = 0.4865709 * rl + 0.2656677 * gl + 0.1982173 * bl; + const y = 0.2289746 * rl + 0.6917385 * gl + 0.0792669 * bl; + const z = 0.0000000 * rl + 0.0451134 * gl + 1.0439444 * bl; + + const rLin = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z; + const gLin = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z; + const bLin = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z; + + const gamma = (v: number) => (v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055); + + return { + r: Math.max(0, Math.min(255, Math.round(gamma(rLin) * 255))), + g: Math.max(0, Math.min(255, Math.round(gamma(gLin) * 255))), + b: Math.max(0, Math.min(255, Math.round(gamma(bLin) * 255))), + }; +} diff --git a/packages/cli/src/linter/model/handler.ts b/packages/cli/src/linter/model/handler.ts index bee269ff..ef5c28bd 100644 --- a/packages/cli/src/linter/model/handler.ts +++ b/packages/cli/src/linter/model/handler.ts @@ -47,6 +47,7 @@ export class ModelHandler implements ModelSpec { const findings: Finding[] = []; const symbolTable = new Map(); const colors = new Map(); + const colorSources = new Map(); const typography = new Map(); const rounded = new Map(); const spacing = new Map(); @@ -61,6 +62,7 @@ export class ModelHandler implements ModelSpec { } else if (isValidColor(raw)) { const resolved = parseColor(raw); colors.set(name, resolved); + colorSources.set(name, raw); symbolTable.set(`colors.${name}`, resolved); } else { findings.push({ @@ -134,6 +136,9 @@ export class ModelHandler implements ModelSpec { if (resolved !== null && typeof resolved === 'object' && 'type' in resolved && resolved.type === 'color') { colors.set(name, resolved as ResolvedColor); symbolTable.set(`colors.${name}`, resolved); + const refPath = raw.slice(1, -1).replace(/^colors\./, ''); + const inherited = colorSources.get(refPath); + colorSources.set(name, inherited ?? raw); } } }); @@ -236,6 +241,8 @@ export class ModelHandler implements ModelSpec { components, symbolTable, sections: input.sections, + documentSections: input.documentSections, + colorSources: colorSources.size > 0 ? colorSources : undefined, unknownKeys, unknownKeyValues, }, diff --git a/packages/cli/src/linter/model/spec.ts b/packages/cli/src/linter/model/spec.ts index 115ee47c..3cac6476 100644 --- a/packages/cli/src/linter/model/spec.ts +++ b/packages/cli/src/linter/model/spec.ts @@ -80,6 +80,10 @@ export interface DesignSystemState { symbolTable: Map; /** Markdown heading names found in the document */ sections?: string[] | undefined; + /** Full markdown section bodies, used by accessibility prose checks. */ + documentSections?: Array<{ heading: string; content: string }> | undefined; + /** Original CSS color strings from the palette, keyed by token name. */ + colorSources?: Map | undefined; /** Top-level YAML keys that are not part of the known schema */ unknownKeys?: string[] | undefined; /** Raw YAML values for unknown top-level keys, keyed by the unknown key name */ diff --git a/packages/cli/src/linter/spec-config.yaml b/packages/cli/src/linter/spec-config.yaml index 67dfa143..5a64d3b2 100644 --- a/packages/cli/src/linter/spec-config.yaml +++ b/packages/cli/src/linter/spec-config.yaml @@ -69,6 +69,9 @@ component_sub_tokens: type: Color - name: textColor type: Color + - name: borderColor + type: Color + description: "Border or outline color for UI boundaries (WCAG 1.4.11 non-text contrast)." - name: typography type: Typography - name: rounded diff --git a/packages/cli/src/linter/spec-gen/spec.mdx b/packages/cli/src/linter/spec-gen/spec.mdx index 44064d5c..e2efbffb 100644 --- a/packages/cli/src/linter/spec-gen/spec.mdx +++ b/packages/cli/src/linter/spec-gen/spec.mdx @@ -49,6 +49,7 @@ The `` 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. @@ -189,6 +190,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 @@ -248,6 +251,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. + {componentsExample()} ### Component Property Tokens