diff --git a/README.md b/README.md
index ed855c5..1f840e8 100644
--- a/README.md
+++ b/README.md
@@ -317,7 +317,7 @@ npx @google/design.md spec --rules-only --format json
## Linting Rules
-The linter runs eleven rules against a parsed DESIGN.md. Each rule produces findings at a fixed severity level.
+The linter runs twelve rules against a parsed DESIGN.md. Each rule produces findings at a fixed severity level.
| Rule | Severity | What it checks |
|:-----|:---------|:---------------|
@@ -332,6 +332,7 @@ The linter runs eleven rules against a parsed DESIGN.md. Each rule produces find
| `unknown-key` | warning | A top-level YAML key looks like a typo of a known schema key (e.g. `colours:` → `colors:`); custom extension keys stay silent |
| `token-like-ignored` | warning | An unknown top-level key has token-like values (e.g. hex colors, font families, dimensions) suggesting it was dropped or misspelled |
| `omitted-rules` | info | Validates the `omitted` configuration mapping for unknown or redundant sections |
+| `prose-token-leak` | warning | Literal token values (like hex colors or CSS dimensions) duplicated in the markdown prose body |
### Programmatic API
diff --git a/docs/spec.md b/docs/spec.md
index 5995e54..34f852c 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -5,7 +5,7 @@
DESIGN.md is a self-contained, plain-text representation of a design system. It defines the visual identity of a brand and product, thereby ensuring that these stylistic choices can be followed across design sessions and between different AI agents and tools. As a human-readable, open-format document, it serves as a living source of truth that both humans and AI can understand and refine.
-A DESIGN.md file contains two parts: An optional YAML frontmatter, and a markdown body. The YAML front matter contains machine-readable design tokens. The markdown body sections provide human-readable design rationale and guidance. Prose may use descriptive color names (e.g., "Midnight Forest Green") that correspond to systematic token names (e.g., `primary`). The tokens are the normative values; the prose provides context for how to apply them.
+A DESIGN.md file contains two parts: An optional YAML frontmatter, and a markdown body. The YAML front matter contains machine-readable design tokens. The markdown body sections provide human-readable design rationale and guidance. Prose may use descriptive color names (e.g., "Midnight Forest Green") that correspond to systematic token names (e.g., `primary`). The tokens are the normative values; the prose provides context for how to apply them. To avoid documentation drift and maintain a single source of truth, the markdown prose body must not duplicate literal token values (such as hex colors or CSS dimensions). Prose should instead reference tokens by name (e.g., `{colors.primary}`) or describe their perceptual roles. Literal values are permitted only inside YAML frontmatter, code blocks, and inline code.
# Design Tokens
diff --git a/packages/cli/src/commands/spec.test.ts b/packages/cli/src/commands/spec.test.ts
index b7113ec..9ec8a97 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(11);
+ expect(output.rules.length).toBe(12);
});
});
diff --git a/packages/cli/src/linter/linter/rules/index.ts b/packages/cli/src/linter/linter/rules/index.ts
index 596a78e..1a2d491 100644
--- a/packages/cli/src/linter/linter/rules/index.ts
+++ b/packages/cli/src/linter/linter/rules/index.ts
@@ -26,6 +26,7 @@ import { missingTypographyRule } from './missing-typography.js';
import { unknownKeyRule } from './unknown-key.js';
import { tokenLikeIgnoredRule } from './token-like-ignored.js';
import { omittedRule } from './omitted.js';
+import { proseTokenLeakRule } from './prose-token-leak.js';
/** The default set of lint rule descriptors, in order. */
export const DEFAULT_RULE_DESCRIPTORS: RuleDescriptor[] = [
@@ -40,6 +41,7 @@ export const DEFAULT_RULE_DESCRIPTORS: RuleDescriptor[] = [
unknownKeyRule,
tokenLikeIgnoredRule,
omittedRule,
+ proseTokenLeakRule,
];
/** Converts a RuleDescriptor into a LintRule by injecting severity into findings. */
@@ -68,4 +70,5 @@ export { unknownKey } from './unknown-key.js';
export { sectionOrder } from './section-order.js';
export { tokenLikeIgnored } from './token-like-ignored.js';
export { omittedRule as omitted } from './omitted.js';
+export { proseTokenLeak } from './prose-token-leak.js';
export type { LintRule } from './types.js';
diff --git a/packages/cli/src/linter/linter/rules/prose-token-leak.test.ts b/packages/cli/src/linter/linter/rules/prose-token-leak.test.ts
new file mode 100644
index 0000000..ce5a781
--- /dev/null
+++ b/packages/cli/src/linter/linter/rules/prose-token-leak.test.ts
@@ -0,0 +1,111 @@
+// 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 { proseTokenLeak } from './prose-token-leak.js';
+import { buildState } from './test-helpers.js';
+
+describe('proseTokenLeak', () => {
+ it('warns when literal colors or dimensions are found in markdown prose', () => {
+ const state = buildState({
+ documentSections: [
+ {
+ heading: 'Colors',
+ content: 'The background should be #ffffff and padding is 12px.',
+ },
+ ],
+ });
+ const findings = proseTokenLeak(state);
+ expect(findings.length).toBe(2);
+ expect(findings[0]!.message).toContain("Literal hex color '#ffffff'");
+ expect(findings[0]!.path).toBe('sections.Colors');
+ expect(findings[1]!.message).toContain("Literal dimension '12px'");
+ expect(findings[1]!.path).toBe('sections.Colors');
+ });
+
+ it('warns when literal functional colors (rgb, rgba, hsl, hsla) are found in markdown prose', () => {
+ const state = buildState({
+ documentSections: [
+ {
+ heading: 'Colors',
+ content: 'We use rgb(255, 0, 0) and hsl(120, 100%, 50%) for status states.',
+ },
+ ],
+ });
+ const findings = proseTokenLeak(state);
+ expect(findings.length).toBe(2);
+ expect(findings[0]!.message).toContain("Literal functional color 'rgb(255, 0, 0)'");
+ expect(findings[1]!.message).toContain("Literal functional color 'hsl(120, 100%, 50%)'");
+ });
+
+ it('passes when tokens are referenced using brackets', () => {
+ const state = buildState({
+ documentSections: [
+ {
+ heading: 'Colors',
+ content: 'The background should be {colors.primary} and padding is {spacing.md}.',
+ },
+ ],
+ });
+ expect(proseTokenLeak(state).length).toBe(0);
+ });
+
+ it('passes when values are inside fenced code blocks', () => {
+ const state = buildState({
+ documentSections: [
+ {
+ heading: 'Colors',
+ content: 'Example styling block:\n```yaml\nprimary: "#ffffff"\npadding: 12px\n```\n',
+ },
+ ],
+ });
+ expect(proseTokenLeak(state).length).toBe(0);
+ });
+
+ it('passes when values are inside inline backticks', () => {
+ const state = buildState({
+ documentSections: [
+ {
+ heading: 'Colors',
+ content: 'Do not use `#ffffff` or `12px` directly in text.',
+ },
+ ],
+ });
+ expect(proseTokenLeak(state).length).toBe(0);
+ });
+
+ it('passes when relative anchors and HTML links are used', () => {
+ const state = buildState({
+ documentSections: [
+ {
+ heading: 'Overview',
+ content: 'Check the [color contrast page](#abc) or click here.',
+ },
+ ],
+ });
+ expect(proseTokenLeak(state).length).toBe(0);
+ });
+
+ it('ignores bare numbers without unit suffixes', () => {
+ const state = buildState({
+ documentSections: [
+ {
+ heading: 'Overview',
+ content: 'We define 3 typography scales and 4 colors here.',
+ },
+ ],
+ });
+ expect(proseTokenLeak(state).length).toBe(0);
+ });
+});
diff --git a/packages/cli/src/linter/linter/rules/prose-token-leak.ts b/packages/cli/src/linter/linter/rules/prose-token-leak.ts
new file mode 100644
index 0000000..fe32e44
--- /dev/null
+++ b/packages/cli/src/linter/linter/rules/prose-token-leak.ts
@@ -0,0 +1,99 @@
+// 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';
+
+// CSS hex color: #RGB, #RGBA, #RRGGBB, or #RRGGBBAA
+const HEX_COLOR_RE = /#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g;
+
+// CSS functional color notations: rgb(), rgba(), hsl(), hsla()
+const FUNCTIONAL_COLOR_RE = /\b(rgba?|hsla?)\([^\)]+\)/g;
+
+// CSS dimension: number + unit suffix (px, rem, em, pt, mm, cm, in, vh, vw, %)
+const CSS_DIMENSION_RE = /\b\d*\.?\d+(px|rem|em|pt|mm|cm|in|vh|vw|%)\b/g;
+
+export function proseTokenLeak(state: DesignSystemState): RuleFinding[] {
+ const findings: RuleFinding[] = [];
+ const docs = state.documentSections ?? [];
+
+ for (const section of docs) {
+ let content = section.content;
+
+ // 0. Strip YAML frontmatter block from prelude if present
+ if (!section.heading) {
+ content = content.replace(/^---[\s\S]*?^---/m, '');
+ }
+
+ // 1. Strip markdown links (e.g. [label](#abc)) to prevent matching anchor hashes
+ content = content.replace(/\[[\s\S]*?\]\([\s\S]*?\)/g, '');
+
+ // 2. Strip HTML tags (e.g. ) to prevent matching HTML attribute values
+ content = content.replace(/<[^>]*>/g, '');
+
+ // 3. Strip HTML comments
+ content = content.replace(//g, '');
+
+ // 4. Strip fenced code blocks
+ content = content.replace(/```[\s\S]*?```/g, '');
+
+ // 5. Strip inline code backticks
+ content = content.replace(/`[\s\S]*?`/g, '');
+
+ // 6. Strip markdown headings (e.g. ## Colors)
+ content = content.replace(/^#+.*$/gm, '');
+
+ // 7. Strip curly-brace token references (e.g. {colors.primary}) so they are allowed
+ content = content.replace(/\{[^{}]+\}/g, '');
+
+ // Check for hex color leaks
+ let match;
+ // Reset regex indices
+ HEX_COLOR_RE.lastIndex = 0;
+ FUNCTIONAL_COLOR_RE.lastIndex = 0;
+ CSS_DIMENSION_RE.lastIndex = 0;
+
+ while ((match = HEX_COLOR_RE.exec(content)) !== null) {
+ findings.push({
+ path: section.heading ? `sections.${section.heading}` : 'prelude',
+ message: `Literal hex color '${match[0]}' found in markdown prose under '${section.heading || 'prelude'}'. Literal values cause documentation drift. Reference the token by name (e.g., '{colors.primary}') instead.`,
+ });
+ }
+
+ // Check for functional color leaks
+ while ((match = FUNCTIONAL_COLOR_RE.exec(content)) !== null) {
+ findings.push({
+ path: section.heading ? `sections.${section.heading}` : 'prelude',
+ message: `Literal functional color '${match[0]}' found in markdown prose under '${section.heading || 'prelude'}'. Literal values cause documentation drift. Reference the token by name (e.g., '{colors.primary}') instead.`,
+ });
+ }
+
+ // Check for dimension leaks
+ while ((match = CSS_DIMENSION_RE.exec(content)) !== null) {
+ findings.push({
+ path: section.heading ? `sections.${section.heading}` : 'prelude',
+ message: `Literal dimension '${match[0]}' found in markdown prose under '${section.heading || 'prelude'}'. Literal values cause documentation drift. Reference the token by name instead.`,
+ });
+ }
+ }
+
+ return findings;
+}
+
+export const proseTokenLeakRule: RuleDescriptor = {
+ name: 'prose-token-leak',
+ severity: 'warning',
+ description: 'Prose token leak — warns when a literal color or dimension token value is written in the markdown prose body.',
+ run: proseTokenLeak,
+};
diff --git a/packages/cli/src/linter/linter/rules/types.test.ts b/packages/cli/src/linter/linter/rules/types.test.ts
index 5df8445..9368ad7 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(11);
+ expect(DEFAULT_RULE_DESCRIPTORS.length).toBe(12);
DEFAULT_RULE_DESCRIPTORS.forEach((rule: RuleDescriptor) => {
expect(rule.name).toBeTruthy();
expect(rule.severity).toBeTruthy();
diff --git a/packages/cli/src/linter/model/handler.ts b/packages/cli/src/linter/model/handler.ts
index 9ea1193..dc0e980 100644
--- a/packages/cli/src/linter/model/handler.ts
+++ b/packages/cli/src/linter/model/handler.ts
@@ -220,6 +220,7 @@ export class ModelHandler implements ModelSpec {
components,
symbolTable,
sections: input.sections,
+ documentSections: input.documentSections,
unknownKeys,
unknownKeyValues,
},
diff --git a/packages/cli/src/linter/model/spec.ts b/packages/cli/src/linter/model/spec.ts
index c84b09b..b601761 100644
--- a/packages/cli/src/linter/model/spec.ts
+++ b/packages/cli/src/linter/model/spec.ts
@@ -82,6 +82,8 @@ export interface DesignSystemState {
symbolTable: Map;
/** Markdown heading names found in the document */
sections?: string[] | undefined;
+ /** Full content of each section, including heading and body */
+ documentSections?: Array<{ heading: string; content: string }> | 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-gen/spec.mdx b/packages/cli/src/linter/spec-gen/spec.mdx
index c9e417d..29cae3c 100644
--- a/packages/cli/src/linter/spec-gen/spec.mdx
+++ b/packages/cli/src/linter/spec-gen/spec.mdx
@@ -5,7 +5,7 @@ import { frontmatterExample, colorsExample, typographyExample, componentsExample
DESIGN.md is a self-contained, plain-text representation of a design system. It defines the visual identity of a brand and product, thereby ensuring that these stylistic choices can be followed across design sessions and between different AI agents and tools. As a human-readable, open-format document, it serves as a living source of truth that both humans and AI can understand and refine.
-A DESIGN.md file contains two parts: An optional YAML frontmatter, and a markdown body. The YAML front matter contains machine-readable design tokens. The markdown body sections provide human-readable design rationale and guidance. Prose may use descriptive color names (e.g., "Midnight Forest Green") that correspond to systematic token names (e.g., `primary`). The tokens are the normative values; the prose provides context for how to apply them.
+A DESIGN.md file contains two parts: An optional YAML frontmatter, and a markdown body. The YAML front matter contains machine-readable design tokens. The markdown body sections provide human-readable design rationale and guidance. Prose may use descriptive color names (e.g., "Midnight Forest Green") that correspond to systematic token names (e.g., `primary`). The tokens are the normative values; the prose provides context for how to apply them. To avoid documentation drift and maintain a single source of truth, the markdown prose body must not duplicate literal token values (such as hex colors or CSS dimensions). Prose should instead reference tokens by name (e.g., `{colors.primary}`) or describe their perceptual roles. Literal values are permitted only inside YAML frontmatter, code blocks, and inline code.
# Design Tokens