mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-05-31 19:04:29 +07:00
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:
parent
561066678f
commit
e6a0680ed3
9 changed files with 387 additions and 64 deletions
1
bun.lock
1
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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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']));
|
||||
});
|
||||
});
|
||||
64
packages/pen-ai-skills/src/diagnostics/color-utils.ts
Normal file
64
packages/pen-ai-skills/src/diagnostics/color-utils.ts
Normal 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.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);
|
||||
}
|
||||
137
packages/pen-ai-skills/src/diagnostics/detectors-typography.ts
Normal file
137
packages/pen-ai-skills/src/diagnostics/detectors-typography.ts
Normal 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})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string>();
|
||||
const unique: Issue[] = [];
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
Loading…
Reference in a new issue