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
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 { 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,
Expand Down Expand Up @@ -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';
47 changes: 47 additions & 0 deletions packages/cli/src/linter/linter/rules/letter-spacing.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 { 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);
});
});
41 changes: 41 additions & 0 deletions packages/cli/src/linter/linter/rules/letter-spacing.ts
Original file line number Diff line number Diff line change
@@ -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,
};
60 changes: 60 additions & 0 deletions packages/cli/src/linter/linter/rules/min-font-size.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
55 changes: 55 additions & 0 deletions packages/cli/src/linter/linter/rules/min-font-size.ts
Original file line number Diff line number Diff line change
@@ -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,
};
47 changes: 47 additions & 0 deletions packages/cli/src/linter/linter/rules/min-line-height.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 { 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);
});
});
46 changes: 46 additions & 0 deletions packages/cli/src/linter/linter/rules/min-line-height.ts
Original file line number Diff line number Diff line change
@@ -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,
};
2 changes: 1 addition & 1 deletion packages/cli/src/linter/linter/rules/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down