diff --git a/packages/cli/src/linter/linter/rules/index.ts b/packages/cli/src/linter/linter/rules/index.ts index 92a525ab..c4b230e3 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 { letterSpacingRule } from './letter-spacing.js'; +import { minLineHeightRule } from './min-line-height.js'; +import { minFontSizeRule } from './min-font-size.js'; /** The default set of lint rule descriptors, in order. */ export const DEFAULT_RULE_DESCRIPTORS: RuleDescriptor[] = [ brokenRefRule, missingPrimaryRule, contrastCheckRule, + letterSpacingRule, + minLineHeightRule, + minFontSizeRule, orphanedTokensRule, tokenSummaryRule, missingSectionsRule, @@ -64,4 +70,7 @@ export { missingTypography } from './missing-typography.js'; export { unknownKey } from './unknown-key.js'; export { sectionOrder } from './section-order.js'; export { tokenLikeIgnored } from './token-like-ignored.js'; +export { letterSpacingCheck } from './letter-spacing.js'; +export { minLineHeightCheck } from './min-line-height.js'; +export { minFontSizeCheck } from './min-font-size.js'; export type { LintRule } from './types.js'; diff --git a/packages/cli/src/linter/linter/rules/letter-spacing.test.ts b/packages/cli/src/linter/linter/rules/letter-spacing.test.ts new file mode 100644 index 00000000..9ef4881a --- /dev/null +++ b/packages/cli/src/linter/linter/rules/letter-spacing.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 { letterSpacingCheck } from './letter-spacing.js'; +import { buildState } from './test-helpers.js'; + +describe('letterSpacingCheck', () => { + it('warns on aggressive negative letterSpacing', () => { + const state = buildState({ + typography: { + 'display-xl': { + fontFamily: 'Inter', + fontSize: '48px', + letterSpacing: '-0.06em', + }, + }, + }); + const findings = letterSpacingCheck(state); + expect(findings.length).toBe(1); + expect(findings[0]!.message).toMatch(/-0\.06em/); + }); + + it('passes for mild negative letterSpacing', () => { + const state = buildState({ + typography: { + h1: { + fontFamily: 'Inter', + fontSize: '32px', + letterSpacing: '-0.02em', + }, + }, + }); + expect(letterSpacingCheck(state).length).toBe(0); + }); +}); diff --git a/packages/cli/src/linter/linter/rules/letter-spacing.ts b/packages/cli/src/linter/linter/rules/letter-spacing.ts new file mode 100644 index 00000000..4817fd95 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/letter-spacing.ts @@ -0,0 +1,41 @@ +// 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'; + +/** Warn when letterSpacing is aggressively negative (readability / dyslexia). */ +const MIN_LETTER_SPACING_EM = -0.05; + +export function letterSpacingCheck(state: DesignSystemState): RuleFinding[] { + const findings: RuleFinding[] = []; + for (const [name, typo] of state.typography) { + const ls = typo.letterSpacing; + if (!ls || ls.unit !== 'em') continue; + if (ls.value < MIN_LETTER_SPACING_EM) { + findings.push({ + path: `typography.${name}.letterSpacing`, + message: `letterSpacing ${ls.value}em is below ${MIN_LETTER_SPACING_EM}em — aggressive negative tracking can impair readability.`, + }); + } + } + return findings; +} + +export const letterSpacingRule: RuleDescriptor = { + name: 'letter-spacing', + severity: 'warning', + description: 'Warns when typography letterSpacing is more aggressive than -0.05em.', + run: letterSpacingCheck, +}; diff --git a/packages/cli/src/linter/linter/rules/min-font-size.test.ts b/packages/cli/src/linter/linter/rules/min-font-size.test.ts new file mode 100644 index 00000000..6656c8fd --- /dev/null +++ b/packages/cli/src/linter/linter/rules/min-font-size.test.ts @@ -0,0 +1,60 @@ +// 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 { minFontSizeCheck } from './min-font-size.js'; +import { buildState } from './test-helpers.js'; + +describe('minFontSizeCheck', () => { + it('warns when fontSize is below 14px', () => { + const state = buildState({ + typography: { + caption: { + fontFamily: 'Inter', + fontSize: '12px', + }, + }, + }); + const findings = minFontSizeCheck(state); + expect(findings.length).toBe(1); + expect(findings[0]!.message).toMatch(/14px/); + }); + + it('warns when body fontSize is below 16px', () => { + const state = buildState({ + typography: { + 'body-sm': { + fontFamily: 'Inter', + fontSize: '14px', + }, + }, + }); + const findings = minFontSizeCheck(state); + expect(findings.length).toBe(1); + expect(findings[0]!.message).toMatch(/16px/); + }); + + it('passes for 16px body text', () => { + const state = buildState({ + typography: { + 'body-lg': { + fontFamily: 'Inter', + fontSize: '16px', + lineHeight: '1.5', + }, + }, + }); + expect(minFontSizeCheck(state).length).toBe(0); + }); +}); diff --git a/packages/cli/src/linter/linter/rules/min-font-size.ts b/packages/cli/src/linter/linter/rules/min-font-size.ts new file mode 100644 index 00000000..f4d3181a --- /dev/null +++ b/packages/cli/src/linter/linter/rules/min-font-size.ts @@ -0,0 +1,55 @@ +// 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 MIN_FONT_SIZE_PX = 14; +const MIN_BODY_FONT_SIZE_PX = 16; + +function isBodyRole(name: string): boolean { + return /^body/i.test(name); +} + +function fontSizeInPx(value: number, unit: string): number | null { + if (unit === 'px') return value; + if (unit === 'rem') return value * 16; + return null; +} + +export function minFontSizeCheck(state: DesignSystemState): RuleFinding[] { + const findings: RuleFinding[] = []; + for (const [name, typo] of state.typography) { + const fs = typo.fontSize; + if (!fs) continue; + const px = fontSizeInPx(fs.value, fs.unit); + if (px === null) continue; + + const minPx = isBodyRole(name) ? MIN_BODY_FONT_SIZE_PX : MIN_FONT_SIZE_PX; + if (px < minPx) { + findings.push({ + path: `typography.${name}.fontSize`, + message: `fontSize ${fs.value}${fs.unit} (~${px}px) is below the ${minPx}px minimum for ${isBodyRole(name) ? 'body' : 'general'} text — small sizes are especially inaccessible in CJK scripts.`, + }); + } + } + return findings; +} + +export const minFontSizeRule: RuleDescriptor = { + name: 'min-font-size', + severity: 'warning', + description: 'Warns when typography fontSize is below 14px (16px for body roles).', + run: minFontSizeCheck, +}; diff --git a/packages/cli/src/linter/linter/rules/min-line-height.test.ts b/packages/cli/src/linter/linter/rules/min-line-height.test.ts new file mode 100644 index 00000000..48d17d58 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/min-line-height.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 { minLineHeightCheck } from './min-line-height.js'; +import { buildState } from './test-helpers.js'; + +describe('minLineHeightCheck', () => { + it('warns when label token has unitless lineHeight below 1.5', () => { + const state = buildState({ + typography: { + 'label-caps': { + fontFamily: 'Inter', + fontSize: '12px', + lineHeight: '1', + }, + }, + }); + const findings = minLineHeightCheck(state); + expect(findings.length).toBe(1); + expect(findings[0]!.path).toBe('typography.label-caps.lineHeight'); + }); + + it('ignores display headings with tight lineHeight', () => { + const state = buildState({ + typography: { + 'display-xl': { + fontFamily: 'Inter', + fontSize: '48px', + lineHeight: '1.1', + }, + }, + }); + expect(minLineHeightCheck(state).length).toBe(0); + }); +}); diff --git a/packages/cli/src/linter/linter/rules/min-line-height.ts b/packages/cli/src/linter/linter/rules/min-line-height.ts new file mode 100644 index 00000000..456f6941 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/min-line-height.ts @@ -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'; + +/** WCAG 1.4.12 text spacing — body/label roles should allow 1.5× line height. */ +const MIN_LINE_HEIGHT = 1.5; + +function isBodyOrLabelRole(name: string): boolean { + return /^(body|label)/i.test(name); +} + +export function minLineHeightCheck(state: DesignSystemState): RuleFinding[] { + const findings: RuleFinding[] = []; + for (const [name, typo] of state.typography) { + if (!isBodyOrLabelRole(name)) continue; + const lh = typo.lineHeight; + if (!lh || lh.unit !== '') continue; + if (lh.value < MIN_LINE_HEIGHT) { + findings.push({ + path: `typography.${name}.lineHeight`, + message: `lineHeight ${lh.value} is below ${MIN_LINE_HEIGHT} for a body/label role — may fail WCAG 1.4.12 text spacing overrides.`, + }); + } + } + return findings; +} + +export const minLineHeightRule: RuleDescriptor = { + name: 'min-line-height', + severity: 'warning', + description: 'Warns when body/label typography uses unitless lineHeight below 1.5 (WCAG 1.4.12).', + run: minLineHeightCheck, +}; 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();