feat(ai): detect text-bg contrast below WCAG AA (P0-2 from aesthetics roadmap)

13th pre-validation detector. Walks every text node, finds the closest
ancestor with a usable solid fill (or first gradient stop as a coarse
approximation), resolves both colors through doc.variables / theme,
and computes WCAG 2.x relative-luminance contrast ratio. Flags ratios
below 4.5:1 for normal text and 3.0:1 for large text (>=24px or
>=19px bold).

Detect-only severity (info). The 2026-05-09 review explicitly rejected
auto-replacing fills via "nearest brand-token" heuristics — the right
replacement depends on the design system + theme + intent, which only
the user/agent can decide. Issues surface in the audit panel and chat
status line so the misuse is visible without silently rewriting fills.

Side effects:
- Extracted parseHexColor / relativeLuminance / colorContrast from
  detectors.ts into diagnostics/color-utils.ts so the new detector
  doesn't duplicate ~30 lines of WCAG math.
- New detector lives in diagnostics/detectors-typography.ts (mirroring
  the per-category split started by detectors-spacing.ts).
- Adds @zseven-w/pen-core to pen-ai-skills deps so the detector can
  call resolveColorRef + getDefaultTheme — the canonical authority on
  the document's variable model.
This commit is contained in:
Fini 2026-05-10 14:30:00 +08:00
parent 561066678f
commit e6a0680ed3
9 changed files with 387 additions and 64 deletions

View file

@ -130,6 +130,7 @@
"name": "@zseven-w/pen-ai-skills",
"version": "0.8.0",
"dependencies": {
"@zseven-w/pen-core": "workspace:*",
"@zseven-w/pen-types": "workspace:*",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.1",

View file

@ -40,6 +40,7 @@
}
},
"dependencies": {
"@zseven-w/pen-core": "workspace:*",
"@zseven-w/pen-types": "workspace:*",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.1"

View file

@ -0,0 +1,175 @@
import { describe, it, expect } from 'vitest';
import type { PenNode, PenDocument } from '@zseven-w/pen-types';
import { detectTextBgContrast } from '../diagnostics/detectors-typography';
// 2026-05-09 user reported black-card-with-white-text mixed into a cream
// page — the white-on-cream paths read as "muddy" and the AA check is the
// systematic way to flag it. Auto-fix is intentionally omitted; the right
// replacement depends on the design system + theme so this stays detect-only.
const text = (
id: string,
fill: unknown,
fontSize: number = 16,
fontWeight?: number | string,
): PenNode =>
({
id,
type: 'text',
content: 'sample',
fontSize,
fontWeight,
fill,
}) as unknown as PenNode;
const frame = (id: string, children: PenNode[], fill?: unknown): PenNode =>
({
id,
type: 'frame',
layout: 'vertical',
fill,
children,
}) as unknown as PenNode;
const solid = (color: string) => [{ type: 'solid' as const, color }];
const emptyDoc: PenDocument = {
children: [],
variables: undefined,
themes: undefined,
} as unknown as PenDocument;
const docWithVars = (vars: Record<string, unknown>, themes?: Record<string, string[]>) =>
({
children: [],
variables: vars,
themes,
}) as unknown as PenDocument;
describe('detectTextBgContrast', () => {
it('does NOT flag black text on white background (ratio 21:1)', () => {
const root = frame('page', [text('t1', solid('#000000'))], solid('#FFFFFF'));
expect(detectTextBgContrast(root, emptyDoc)).toHaveLength(0);
});
it('flags light-gray text on white (ratio ~3.9 < AA 4.5)', () => {
const root = frame('page', [text('t1', solid('#888888'))], solid('#FFFFFF'));
const issues = detectTextBgContrast(root, emptyDoc);
expect(issues).toHaveLength(1);
expect(issues[0].nodeId).toBe('t1');
expect(issues[0].category).toBe('text-bg-contrast');
expect(issues[0].severity).toBe('info');
expect(issues[0].suggestedValue).toBeNull();
expect(issues[0].reason).toMatch(/below WCAG AA/);
});
it('flags white text on white bg (ratio 1.0 — invisible)', () => {
const root = frame('page', [text('t1', solid('#FFFFFF'))], solid('#FFFFFF'));
expect(detectTextBgContrast(root, emptyDoc)).toHaveLength(1);
});
it('does NOT flag white text on dark cream-styled card (the user-reported good case)', () => {
// Cream page with a dark card; white text on the dark card should pass.
const root = frame(
'page',
[frame('card', [text('t1', solid('#FFFFFF'))], solid('#1A1A1A'))],
solid('#FFF8E7'), // cream
);
expect(detectTextBgContrast(root, emptyDoc)).toHaveLength(0);
});
it('flags white text on cream page (the user-reported bad case)', () => {
// The same white text but with no dark card wrapping — sits directly
// on the cream page background; ratio ~1.10, fails AA hard.
const root = frame('page', [text('t1', solid('#FFFFFF'))], solid('#FFF8E7'));
expect(detectTextBgContrast(root, emptyDoc)).toHaveLength(1);
});
it('uses the LARGE-text threshold (3.0) for fontSize >= 24', () => {
// Ratio ~3.5 — fails normal 4.5 but passes large 3.0
const root = frame('page', [text('t1', solid('#787878'), 32)], solid('#FFFFFF'));
expect(detectTextBgContrast(root, emptyDoc)).toHaveLength(0);
});
it('uses the LARGE-text threshold (3.0) for fontSize >= 19 + bold weight', () => {
const root = frame('page', [text('t1', solid('#787878'), 20, 700)], solid('#FFFFFF'));
expect(detectTextBgContrast(root, emptyDoc)).toHaveLength(0);
});
it('still flags >=19px non-bold text (large rule needs 700+ weight)', () => {
const root = frame('page', [text('t1', solid('#888888'), 20, 400)], solid('#FFFFFF'));
expect(detectTextBgContrast(root, emptyDoc)).toHaveLength(1);
});
it('walks ancestor chain to find first non-transparent bg', () => {
// Outer page has cream; inner section has no fill (transparent);
// the text's effective bg should still resolve to cream.
const root = frame(
'page',
[
frame('section', [text('t1', solid('#FFF8E7'))]), // section has no fill
],
solid('#FFF8E7'),
);
expect(detectTextBgContrast(root, emptyDoc)).toHaveLength(1);
});
it('defaults to white bg when no ancestor has any fill', () => {
// No page fill at all — detector falls back to the canvas default
// (white). White text on white = invisible.
const root = frame('page', [text('t1', solid('#FFFFFF'))]);
expect(detectTextBgContrast(root, emptyDoc)).toHaveLength(1);
});
it('resolves $variable refs through doc.variables / theme', () => {
const doc = docWithVars({
'color-text': { type: 'color', value: '#888888' },
'color-bg': { type: 'color', value: '#FFFFFF' },
});
const root = frame('page', [text('t1', solid('$color-text'))], solid('$color-bg'));
const issues = detectTextBgContrast(root, doc);
expect(issues).toHaveLength(1);
expect(issues[0].reason).toMatch(/text=#888888 on bg=#FFFFFF/);
});
it('skips text whose color ref does not resolve (no false positive)', () => {
const doc = docWithVars({}); // no variables, ref will not resolve
const root = frame('page', [text('t1', solid('$nonexistent'))], solid('#FFFFFF'));
expect(detectTextBgContrast(root, doc)).toHaveLength(0);
});
it('skips text with no fill array (renderer default applies)', () => {
const root = frame('page', [text('t1', undefined)], solid('#FFFFFF'));
expect(detectTextBgContrast(root, emptyDoc)).toHaveLength(0);
});
it('approximates gradient bg by first stop', () => {
const gradientBg = [
{
type: 'linear_gradient' as const,
stops: [
{ color: '#FFFFFF', offset: 0 },
{ color: '#000000', offset: 1 },
],
},
];
// First stop is white → light bg; white text fails.
const root = frame('page', [text('t1', solid('#FFFFFF'))], gradientBg);
expect(detectTextBgContrast(root, emptyDoc)).toHaveLength(1);
});
it('flags multiple offending texts in one pass', () => {
const root = frame(
'page',
[
text('good', solid('#000000')),
text('bad-1', solid('#FFFFFF')),
text('bad-2', solid('#EEEEEE')),
],
solid('#FFFFFF'),
);
const issues = detectTextBgContrast(root, emptyDoc);
expect(issues).toHaveLength(2);
expect(new Set(issues.map((i) => i.nodeId))).toEqual(new Set(['bad-1', 'bad-2']));
});
});

View file

@ -0,0 +1,64 @@
/**
* Color helpers shared across diagnostic detectors.
*
* Extracted from detectors.ts in 2026-05-10 when the second contrast-aware
* detector (detectTextBgContrast) needed the same WCAG luminance math the
* invisible-container detector already had inline. Kept narrow on purpose
* the only export surface is what detectors actually call.
*/
/**
* Parse `#rgb` / `#rrggbb` / `#rrggbbaa` to `{r, g, b}` (alpha is dropped).
* Returns null on parse failure or non-string input.
*/
export function parseHexColor(s: unknown): { r: number; g: number; b: number } | null {
if (typeof s !== 'string') return null;
const m = s.trim().match(/^#([0-9a-fA-F]{3,8})$/);
if (!m) return null;
let hex = m[1];
if (hex.length === 3) {
hex = hex
.split('')
.map((c) => c + c)
.join('');
}
if (hex.length !== 6 && hex.length !== 8) return null;
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
return { r, g, b };
}
/** WCAG 2.x relative luminance for sRGB. Returns 0.01.0. */
export function relativeLuminance(c: { r: number; g: number; b: number }): number {
const lin = (v: number): number => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
};
return 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b);
}
/**
* Compare two color strings via WCAG relative-luminance contrast ratio.
* Returns 1.0 for identical colors, growing toward 21.0 as they diverge,
* or Infinity if either color cannot be parsed (e.g. unresolved variable
* refs caller should resolve refs upstream).
*
* Why WCAG ratio rather than max RGB channel diff: the human eye is much
* more sensitive to small tonal differences on dark backgrounds than light
* ones (WeberFechner / dark adaptation). Channel-diff would give false
* positives in dark themes; ratio is luminance-based and matches the metric
* WCAG / Stark / Figma report.
*/
export function colorContrast(a: string, b: string): number {
if (a === b) return 1;
const pa = parseHexColor(a);
const pb = parseHexColor(b);
if (!pa || !pb) return Infinity;
const lumA = relativeLuminance(pa);
const lumB = relativeLuminance(pb);
const lighter = Math.max(lumA, lumB);
const darker = Math.min(lumA, lumB);
return (lighter + 0.05) / (darker + 0.05);
}

View file

@ -0,0 +1,137 @@
import type { PenNode, PenDocument } from '@zseven-w/pen-types';
import { resolveColorRef, getDefaultTheme } from '@zseven-w/pen-core';
import type { Issue } from './types';
import { colorContrast } from './color-utils';
/** WCAG 2.x AA threshold for normal-size text. */
const WCAG_AA_NORMAL = 4.5;
/** WCAG 2.x AA threshold for large text (>= 18pt or >= 14pt bold). */
const WCAG_AA_LARGE = 3.0;
/**
* Pull the first solid color out of a fill array. Gradients get reduced to
* their first stop as a coarse approximation text on a gradient bg is
* already a hard contrast call, and reading the gradient midpoint is
* outside this detector's scope. Returns null when there's no usable
* solid color (image fills, missing fills, transparent overlays).
*/
function firstSolidColor(fills: unknown): string | null {
if (!Array.isArray(fills) || fills.length === 0) return null;
for (const fill of fills) {
if (!fill || typeof fill !== 'object') continue;
const f = fill as { type?: string; color?: string; stops?: Array<{ color?: string }> };
if (f.type === 'solid' && typeof f.color === 'string') return f.color;
if (
(f.type === 'linear_gradient' || f.type === 'radial_gradient') &&
Array.isArray(f.stops) &&
f.stops.length > 0 &&
typeof f.stops[0].color === 'string'
) {
return f.stops[0].color;
}
}
return null;
}
/**
* WCAG 2.x large-text rule: >= 18pt OR >= 14pt bold (weight >= 700).
* Web maps 18pt 24px and 14pt 18.66px use 24/19 in pixel terms.
*/
function isLargeText(node: PenNode): boolean {
const t = node as PenNode & { fontSize?: number; fontWeight?: number | string };
if (typeof t.fontSize !== 'number') return false;
if (t.fontSize >= 24) return true;
const weight =
typeof t.fontWeight === 'number'
? t.fontWeight
: typeof t.fontWeight === 'string'
? parseInt(t.fontWeight, 10)
: NaN;
if (t.fontSize >= 19 && Number.isFinite(weight) && weight >= 700) return true;
return false;
}
/**
* Aesthetic detector: text whose color contrast against its rendered
* background falls below WCAG AA threshold (4.5:1 for normal text,
* 3.0:1 for large text).
*
* Background determination walks the parent chain and picks the first
* solid fill (or gradient first-stop) it finds. Missing fills are
* "transparent" the walk continues to the next ancestor; if the chain
* exhausts with no color, the detector defaults to white (#FFFFFF), the
* canvas bg in OpenPencil's renderer.
*
* Both text and background colors are resolved through the document's
* variable table and active theme so `$color-text-primary` etc. compare
* correctly. Unresolvable refs (typo'd token names, runtime-only refs)
* skip the check rather than guess.
*
* Severity is INFO (detect-only, never auto-fix). The 2026-05-09 user
* feedback was explicit that auto-replacing brand colors via "nearest
* token" heuristics was unsafe this detector surfaces issues into the
* audit panel and chat status line so the user / agent can decide,
* without silently rewriting their fills.
*/
export function detectTextBgContrast(root: PenNode, doc: PenDocument): Issue[] {
const issues: Issue[] = [];
const variables = doc.variables ?? {};
const theme = getDefaultTheme(doc.themes);
walk(root, []);
return issues;
function walk(node: PenNode, ancestors: PenNode[]): void {
if (node.type === 'text') {
checkText(node, ancestors);
}
if ('children' in node && Array.isArray(node.children)) {
const nextAncestors = ancestors.concat(node);
for (const c of node.children) walk(c, nextAncestors);
}
}
function ancestorBgColor(ancestors: PenNode[]): string {
// Walk closest-first; skip transparent/imageless ancestors. Default
// to white when the chain runs out — matches OpenPencil's canvas
// default and avoids the "everything fails" report when a doc has
// no explicit page fill.
for (let i = ancestors.length - 1; i >= 0; i--) {
const fill = (ancestors[i] as unknown as { fill?: unknown }).fill;
const raw = firstSolidColor(fill);
if (!raw) continue;
const resolved = resolveColorRef(raw, variables, theme);
if (typeof resolved === 'string') return resolved;
}
return '#FFFFFF';
}
function checkText(node: PenNode, ancestors: PenNode[]): void {
const textFill = (node as unknown as { fill?: unknown }).fill;
const rawText = firstSolidColor(textFill);
if (!rawText) return; // no fill or non-solid — renderer default applies, skip
const textColor = resolveColorRef(rawText, variables, theme);
if (typeof textColor !== 'string') return; // unresolvable ref
const bgColor = ancestorBgColor(ancestors);
const ratio = colorContrast(textColor, bgColor);
if (!Number.isFinite(ratio)) return; // either color failed to parse
const threshold = isLargeText(node) ? WCAG_AA_LARGE : WCAG_AA_NORMAL;
if (ratio >= threshold) return;
issues.push({
nodeId: node.id,
category: 'text-bg-contrast',
// Detect-only — auto-replacing the fill with a "nearest brand
// token" is the path the 2026-05-09 review explicitly rejected.
severity: 'info',
property: 'fill',
currentValue: textFill ?? null,
// No suggestedValue: the right replacement depends on the design
// system + theme + intent, which only the user/agent can decide.
suggestedValue: null,
reason: `text/bg contrast ${ratio.toFixed(2)}:1 below WCAG AA ${threshold}:1 (text=${textColor} on bg=${bgColor})`,
});
}
}

View file

@ -1,6 +1,8 @@
import type { PenNode, PenDocument } from '@zseven-w/pen-types';
import type { Issue } from './types';
import { detectEdgeSectionPadding } from './detectors-spacing';
import { detectTextBgContrast } from './detectors-typography';
import { colorContrast, parseHexColor, relativeLuminance } from './color-utils';
/** Extract the first fill color from a node (raw, including variable refs) */
function getFirstFillColor(node: PenNode): string | null {
@ -10,68 +12,6 @@ function getFirstFillColor(node: PenNode): string | null {
return null;
}
/**
* Compare two color strings via WCAG relative-luminance contrast ratio.
* Returns 1.0 for identical colors, growing toward 21.0 as they diverge,
* or Infinity if either color cannot be parsed (e.g. variable refs).
*
* Why WCAG contrast ratio rather than max RGB channel diff:
* the human eye is much more sensitive to small tonal differences on
* dark backgrounds than light ones (WeberFechner / dark adaptation).
* A 9-unit RGB diff means very different things at different lightness:
*
* #FAFAFA vs #F1F1F1 (light, RGB diff 9): contrast ratio 1.07 invisible
* #111111 vs #1A1A1A (dark, RGB diff 9): contrast ratio 1.18 distinguishable
*
* Channel-diff treats them identically and produces false positives on
* dark theme cards. Contrast ratio is luminance-based, perceptually
* uniform-ish, and gives the right answer in both regimes. It also
* matches the metric WCAG and design tools (Stark, Figma) use.
*
* Used by detectInvisibleContainers to catch cases where fill colors are
* visually nearly identical but not strictly equal.
*/
function colorContrast(a: string, b: string): number {
if (a === b) return 1;
const pa = parseHexColor(a);
const pb = parseHexColor(b);
if (!pa || !pb) return Infinity;
const lumA = relativeLuminance(pa);
const lumB = relativeLuminance(pb);
const lighter = Math.max(lumA, lumB);
const darker = Math.min(lumA, lumB);
return (lighter + 0.05) / (darker + 0.05);
}
/** WCAG 2.x relative luminance for sRGB. Returns 0.01.0. */
function relativeLuminance(c: { r: number; g: number; b: number }): number {
const lin = (v: number): number => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
};
return 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b);
}
/** Parse #rgb / #rrggbb / #rrggbbaa to {r,g,b}. Returns null on parse failure. */
function parseHexColor(s: string): { r: number; g: number; b: number } | null {
if (typeof s !== 'string') return null;
const m = s.trim().match(/^#([0-9a-fA-F]{3,8})$/);
if (!m) return null;
let hex = m[1];
if (hex.length === 3) {
hex = hex
.split('')
.map((c) => c + c)
.join('');
}
if (hex.length !== 6 && hex.length !== 8) return null;
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
return { r, g, b };
}
/** Check if a node already has a visible stroke */
function hasStroke(node: PenNode): boolean {
if (!('stroke' in node)) return false;
@ -751,7 +691,7 @@ export function detectExcessiveFrameEffects(root: PenNode): Issue[] {
}
/**
* Run all 12 detectors and return the deduplicated combined issue list.
* Run all 13 detectors and return the deduplicated combined issue list.
* Dedup key: `${nodeId}:${property}` (matches runPreValidationFixes).
* On collision, the first issue wins (detector execution order below).
*/
@ -769,6 +709,7 @@ export function detectAllIssues(root: PenNode, doc: PenDocument): Issue[] {
...detectMixedSiblingPadding(root),
...detectExcessiveFrameEffects(root),
...detectEdgeSectionPadding(root),
...detectTextBgContrast(root, doc),
];
const seen = new Set<string>();
const unique: Issue[] = [];

View file

@ -15,3 +15,5 @@ export {
detectAllIssues,
} from './detectors';
export { detectEdgeSectionPadding } from './detectors-spacing';
export { detectTextBgContrast } from './detectors-typography';
export { colorContrast, parseHexColor, relativeLuminance } from './color-utils';

View file

@ -12,7 +12,8 @@ export type IssueCategory =
| 'text-stroke'
| 'mixed-sibling-padding'
| 'excessive-frame-effects'
| 'edge-section-padding';
| 'edge-section-padding'
| 'text-bg-contrast';
export interface Issue {
/** Node id where the issue was detected */

View file

@ -47,6 +47,7 @@ export const DEBUG_TOOL_DEFINITIONS = [
'mixed-sibling-padding',
'excessive-frame-effects',
'edge-section-padding',
'text-bg-contrast',
],
},
description: 'Filter to specific detector categories.',