From e6a0680ed3609d3c53ab00c7129841dc8c6597ef Mon Sep 17 00:00:00 2001 From: Fini Date: Sun, 10 May 2026 14:30:00 +0800 Subject: [PATCH] feat(ai): detect text-bg contrast below WCAG AA (P0-2 from aesthetics roadmap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bun.lock | 1 + packages/pen-ai-skills/package.json | 1 + .../__tests__/detectors-typography.test.ts | 175 ++++++++++++++++++ .../src/diagnostics/color-utils.ts | 64 +++++++ .../src/diagnostics/detectors-typography.ts | 137 ++++++++++++++ .../src/diagnostics/detectors.ts | 67 +------ .../pen-ai-skills/src/diagnostics/index.ts | 2 + .../pen-ai-skills/src/diagnostics/types.ts | 3 +- packages/pen-mcp/src/routes/debug-routes.ts | 1 + 9 files changed, 387 insertions(+), 64 deletions(-) create mode 100644 packages/pen-ai-skills/src/__tests__/detectors-typography.test.ts create mode 100644 packages/pen-ai-skills/src/diagnostics/color-utils.ts create mode 100644 packages/pen-ai-skills/src/diagnostics/detectors-typography.ts diff --git a/bun.lock b/bun.lock index 10a50b20..59cc4804 100644 --- a/bun.lock +++ b/bun.lock @@ -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", diff --git a/packages/pen-ai-skills/package.json b/packages/pen-ai-skills/package.json index d858a12c..02df3d0a 100644 --- a/packages/pen-ai-skills/package.json +++ b/packages/pen-ai-skills/package.json @@ -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" diff --git a/packages/pen-ai-skills/src/__tests__/detectors-typography.test.ts b/packages/pen-ai-skills/src/__tests__/detectors-typography.test.ts new file mode 100644 index 00000000..ad652740 --- /dev/null +++ b/packages/pen-ai-skills/src/__tests__/detectors-typography.test.ts @@ -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, themes?: Record) => + ({ + 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'])); + }); +}); diff --git a/packages/pen-ai-skills/src/diagnostics/color-utils.ts b/packages/pen-ai-skills/src/diagnostics/color-utils.ts new file mode 100644 index 00000000..240dfb1b --- /dev/null +++ b/packages/pen-ai-skills/src/diagnostics/color-utils.ts @@ -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.0–1.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 (Weber–Fechner / 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); +} diff --git a/packages/pen-ai-skills/src/diagnostics/detectors-typography.ts b/packages/pen-ai-skills/src/diagnostics/detectors-typography.ts new file mode 100644 index 00000000..998e6a79 --- /dev/null +++ b/packages/pen-ai-skills/src/diagnostics/detectors-typography.ts @@ -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})`, + }); + } +} diff --git a/packages/pen-ai-skills/src/diagnostics/detectors.ts b/packages/pen-ai-skills/src/diagnostics/detectors.ts index 0570b25a..2bfcc229 100644 --- a/packages/pen-ai-skills/src/diagnostics/detectors.ts +++ b/packages/pen-ai-skills/src/diagnostics/detectors.ts @@ -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 (Weber–Fechner / 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.0–1.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(); const unique: Issue[] = []; diff --git a/packages/pen-ai-skills/src/diagnostics/index.ts b/packages/pen-ai-skills/src/diagnostics/index.ts index bbfafbfe..60561089 100644 --- a/packages/pen-ai-skills/src/diagnostics/index.ts +++ b/packages/pen-ai-skills/src/diagnostics/index.ts @@ -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'; diff --git a/packages/pen-ai-skills/src/diagnostics/types.ts b/packages/pen-ai-skills/src/diagnostics/types.ts index 41dea235..7e4d87a6 100644 --- a/packages/pen-ai-skills/src/diagnostics/types.ts +++ b/packages/pen-ai-skills/src/diagnostics/types.ts @@ -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 */ diff --git a/packages/pen-mcp/src/routes/debug-routes.ts b/packages/pen-mcp/src/routes/debug-routes.ts index 320f04b4..0ceee7ee 100644 --- a/packages/pen-mcp/src/routes/debug-routes.ts +++ b/packages/pen-mcp/src/routes/debug-routes.ts @@ -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.',