fix(tools): judge describe text contrast by WCAG ratio (#758)

The describe tool judges text contrast by the WCAG 2 ratio (4.5:1, or 3:1 for large text) instead of a dark-on-dark luminance heuristic, and reports the ratio and threshold. It measures translucent and faded text as drawn, treats text as large only when the base style and every style run are, and skips variable-bound colors. contrastRatio() and compositeOver() now live in @open-pencil/scene-graph/color and are shared with the color-contrast lint rule and canvas labels. Fixes #735.
This commit is contained in:
mrhard9090 2026-09-30 10:32:34 +03:00 committed by GitHub
parent 87bff8620c
commit 4ef6667ffc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 199 additions and 60 deletions

View file

@ -25,6 +25,7 @@
### Fixed
- Judge text contrast in the AI and MCP `describe` tool by its WCAG 2 ratio (4.5:1, or 3:1 for large text), the same ratio the `color-contrast` lint rule computes. It no longer reports passing dark text on mid-tone backgrounds as "dark on dark", now reports low-contrast light text, measures translucent and faded text as it is drawn, skips text whose color is bound to a variable, and says the ratio and the threshold it missed (#735).
- Warn about options the paint and effect helpers ignore when rendering JSX instead of dropping them silently, and point `blur` in effect helpers at `radius`, the name Figma uses (#736).
- Size groups and boolean operations made through the AI and MCP `group_nodes` and `boolean_*` tools to what they contain, as the editor's commands already do, instead of a default 100 × 100 box or the first operand's box (#738).
- Keep a layer where it is drawn when it moves into or out of a rotated or flipped parent, instead of shifting it and leaving it at its old angle (#737).

View file

@ -1,35 +1,11 @@
import { wcagContrast } from 'culori'
import type { Color } from '@open-pencil/scene-graph'
import { compositeOver, contrastRatio } from '@open-pencil/scene-graph/color'
import { BLACK } from '#core/constants'
const WHITE: Color = { r: 1, g: 1, b: 1, a: 1 }
function compositeColor(foreground: Color, background: Color): Color {
const alpha = foreground.a
return {
r: foreground.r * alpha + background.r * (1 - alpha),
g: foreground.g * alpha + background.g * (1 - alpha),
b: foreground.b * alpha + background.b * (1 - alpha),
a: 1
}
}
export function canvasLabelForeground(background: Color, canvasBackground: Color = WHITE): Color {
const composited = compositeColor(background, canvasBackground)
const color = { mode: 'rgb' as const, r: composited.r, g: composited.g, b: composited.b }
const blackContrast = wcagContrast(color, {
mode: 'rgb',
r: BLACK.r,
g: BLACK.g,
b: BLACK.b
})
const whiteContrast = wcagContrast(color, {
mode: 'rgb',
r: WHITE.r,
g: WHITE.g,
b: WHITE.b
})
return blackContrast >= whiteContrast ? BLACK : WHITE
const composited = compositeOver(background, canvasBackground)
return contrastRatio(composited, BLACK) >= contrastRatio(composited, WHITE) ? BLACK : WHITE
}

View file

@ -1,5 +1,6 @@
import { contrastRatio } from '@open-pencil/scene-graph/color'
import { defineRule } from '#core/lint/rule'
import { contrastRatio } from '#core/lint/utils'
export default defineRule({
meta: {

View file

@ -25,22 +25,4 @@ export function getNodePath(node: LintPathNode): string[] {
return path
}
export function relativeLuminance(rgb: { r: number; g: number; b: number }): number {
const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((c) =>
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
) as [number, number, number]
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
export function contrastRatio(
a: { r: number; g: number; b: number },
b: { r: number; g: number; b: number }
): number {
const l1 = relativeLuminance(a)
const l2 = relativeLuminance(b)
const lighter = Math.max(l1, l2)
const darker = Math.min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
}
export const SPACING_SCALE = [0, 1, 2, 4, 8, 12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 128]

View file

@ -402,7 +402,7 @@ const ERROR_PATTERNS = [
/no color/i,
/collapses/i,
/no fill and no stroke/i,
/dark on dark/i,
/below WCAG AA/i,
/Touch target too small/i,
/Nested Text/i
]

View file

@ -1,17 +1,36 @@
import { wcagLuminance } from 'culori'
import { sumBy } from 'es-toolkit/math'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { colorToHex } from '@open-pencil/scene-graph/color'
import type { Fill, SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { colorToHex, compositeOver, contrastRatio } from '@open-pencil/scene-graph/color'
import type { Color } from '@open-pencil/scene-graph/primitives'
import type { DescribeIssue } from './issues'
import { CONTAINER_TYPES, findAncestorBackground } from './shared'
const DARK_BG_LUMINANCE = 0.35
const WCAG_AA_CONTRAST = 4.5
const WCAG_AA_LARGE_TEXT_CONTRAST = 3
function rgbLuminance(c: Color): number {
return wcagLuminance({ mode: 'rgb', r: c.r, g: c.g, b: c.b })
// Translucent text, through its fill or the node's own opacity, is seen blended with the background.
function blendOver(fill: Fill, nodeOpacity: number, background: Color): Color {
return compositeOver(fill.color, background, fill.opacity * fill.color.a * nodeOpacity)
}
// Round down so a failing ratio such as 4.499 is not shown as 4.50.
function formatRatio(ratio: number): string {
return (Math.floor(ratio * 100) / 100).toFixed(2)
}
// WCAG large text is at least 18pt, or 14pt bold (1pt = 4/3px).
function isLargeSize(fontSize: number, fontWeight: number): boolean {
return fontSize >= 24 || (fontSize >= 56 / 3 && fontWeight >= 700)
}
// Text qualifies only when every range does: the base style and each style run's own size and weight.
function isLargeText(node: SceneNode): boolean {
if (!isLargeSize(node.fontSize, node.fontWeight)) return false
return node.styleRuns.every(({ style }) =>
isLargeSize(style.fontSize ?? node.fontSize, style.fontWeight ?? node.fontWeight)
)
}
interface LayoutContext {
@ -204,7 +223,8 @@ function checkTextVisibility(ctx: LayoutContext): void {
for (const childId of node.childIds) {
const child = graph.getNode(childId)
if (!child?.visible || child.type !== 'TEXT') continue
const textFill = child.fills.find((f) => f.visible && f.type === 'SOLID')
const textFillIndex = child.fills.findIndex((f) => f.visible && f.type === 'SOLID')
const textFill = child.fills[textFillIndex] as Fill | undefined
if (!textFill) {
issues.push({
message: `"${child.name || child.text.slice(0, 20) || 'Text'}" has no color — invisible`,
@ -212,14 +232,17 @@ function checkTextVisibility(ctx: LayoutContext): void {
})
continue
}
const textLum = rgbLuminance(textFill.color)
if (textLum > DARK_BG_LUMINANCE) continue
// A variable-bound color depends on the active mode, so its static value proves nothing;
// the color-contrast lint rule skips it for the same reason.
if (child.boundVariables[`fills/${textFillIndex}/color`]) continue
const bg = findAncestorBackground(child, graph)
if (!bg) continue
if (rgbLuminance(bg) < DARK_BG_LUMINANCE) {
const ratio = contrastRatio(blendOver(textFill, child.opacity, bg), bg)
const required = isLargeText(child) ? WCAG_AA_LARGE_TEXT_CONTRAST : WCAG_AA_CONTRAST
if (ratio < required) {
issues.push({
message: `"${child.name || child.text.slice(0, 20) || 'Text'}" dark on dark (${colorToHex(textFill.color)} on ${colorToHex(bg)})`,
suggestion: 'Use a light color'
message: `"${child.name || child.text.slice(0, 20) || 'Text'}" contrast ${formatRatio(ratio)}:1 is below WCAG AA ${required}:1 (${colorToHex(textFill.color)} on ${colorToHex(bg)})`,
suggestion: 'Increase contrast between text and background'
})
}
}

View file

@ -0,0 +1,25 @@
import { wcagContrast } from 'culori'
import type { Color } from '../primitives'
/** Opaque color seen when `foreground` at `alpha` is drawn over an opaque `background`. */
export function compositeOver(foreground: Color, background: Color, alpha = foreground.a): Color {
const mix = (top: number, bottom: number) => top * alpha + bottom * (1 - alpha)
return {
r: mix(foreground.r, background.r),
g: mix(foreground.g, background.g),
b: mix(foreground.b, background.b),
a: 1
}
}
/** WCAG 2 contrast ratio of two colors, from 1 to 21; alpha is ignored, so composite first. */
export function contrastRatio(
a: Pick<Color, 'r' | 'g' | 'b'>,
b: Pick<Color, 'r' | 'g' | 'b'>
): number {
return wcagContrast(
{ mode: 'rgb', r: a.r, g: a.g, b: a.b },
{ mode: 'rgb', r: b.r, g: b.g, b: b.b }
)
}

View file

@ -1,3 +1,4 @@
export * from './contrast'
export * from './management'
export { normalizeColor } from './normalize'
export * from './okhcl'

View file

@ -0,0 +1,33 @@
import { describe, expect, test } from 'bun:test'
import { compositeOver, contrastRatio } from '@open-pencil/scene-graph/color'
import type { Color } from '@open-pencil/scene-graph/primitives'
const BLACK: Color = { r: 0, g: 0, b: 0, a: 1 }
const WHITE: Color = { r: 1, g: 1, b: 1, a: 1 }
const GRAY_94: Color = { r: 0x94 / 255, g: 0x94 / 255, b: 0x94 / 255, a: 1 }
describe('contrastRatio', () => {
test('matches the WCAG 2 extremes and is symmetric', () => {
expect(contrastRatio(BLACK, WHITE)).toBeCloseTo(21, 6)
expect(contrastRatio(WHITE, BLACK)).toBeCloseTo(21, 6)
expect(contrastRatio(WHITE, WHITE)).toBeCloseTo(1, 6)
})
test('measures a mid gray on white', () => {
expect(contrastRatio(GRAY_94, WHITE)).toBeCloseTo(3.03, 2)
})
})
describe('compositeOver', () => {
test('uses the foreground alpha by default and returns an opaque color', () => {
expect(compositeOver({ ...BLACK, a: 0.5 }, WHITE)).toEqual({ r: 0.5, g: 0.5, b: 0.5, a: 1 })
})
test('takes an explicit alpha for fill or node opacity', () => {
const blended = compositeOver(BLACK, WHITE, 0.4)
expect(blended.r).toBeCloseTo(0.6, 10)
expect(contrastRatio(blended, WHITE)).toBeCloseTo(2.849, 3)
expect(compositeOver(BLACK, WHITE, 0)).toEqual(WHITE)
})
})

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import type { Color, Stroke } from '@open-pencil/scene-graph'
import type { Color, Fill, SceneNode, Stroke } from '@open-pencil/scene-graph'
import { expectDefined } from '#tests/helpers/assert'
import { getTool, setupToolTest, type ToolResult } from '#tests/helpers/tools'
@ -65,3 +65,100 @@ describe('describe stroke summaries', () => {
expect(result.visual).toContain('#021A3B 10.13px stroke')
})
})
interface DescribedIssue {
message: string
severity?: string
}
function hex(value: string): Color {
const channel = (offset: number) => Number.parseInt(value.slice(offset, offset + 2), 16) / 255
return { r: channel(1), g: channel(3), b: channel(5), a: 1 }
}
function solid(color: string, opacity = 1): Fill {
return { type: 'SOLID', color: hex(color), opacity, visible: true }
}
function describeTextOnBackground(
textColor: string,
backgroundColor: string,
fontSize: number,
fontWeight = 400,
textOpacity = 1,
options: { opacity?: number; boundColor?: string; styleRuns?: SceneNode['styleRuns'] } = {}
): DescribedIssue[] {
const { figma, graph } = setupToolTest()
const frame = figma.createFrame()
frame.name = 'Button'
frame.resize(200, 60)
graph.updateNode(frame.id, { fills: [solid(backgroundColor)] })
const label = figma.createText()
label.name = 'Label'
frame.appendChild(label)
graph.updateNode(label.id, {
fills: [solid(textColor, textOpacity)],
fontSize,
fontWeight,
opacity: options.opacity ?? 1,
boundVariables: options.boundColor ? { 'fills/0/color': options.boundColor } : {},
styleRuns: options.styleRuns ?? []
})
const result = getTool('describe').execute(figma, { id: frame.id }) as ToolResult
const issues = (result.issues ?? []) as DescribedIssue[]
return issues.filter((issue) => issue.message.startsWith('"Label"'))
}
describe('describe text contrast', () => {
test('accepts dark text on a mid-tone background that passes WCAG AA', () => {
expect(describeTextOnBackground('#14101F', '#FF5FA2', 17, 700)).toEqual([])
})
test('reports the ratio and threshold for text below WCAG AA', () => {
const issues = describeTextOnBackground('#949494', '#FFFFFF', 14)
expect(issues).toHaveLength(1)
expect(issues[0]).toMatchObject({
message: '"Label" contrast 3.03:1 is below WCAG AA 4.5:1 (#949494 on #FFFFFF)',
severity: 'error'
})
})
test('measures a translucent text fill as blended with the background', () => {
expect(describeTextOnBackground('#000000', '#FFFFFF', 14)).toEqual([])
expect(describeTextOnBackground('#000000', '#FFFFFF', 14, 400, 0.4)[0]?.message).toContain(
'contrast 2.84:1 is below WCAG AA 4.5:1'
)
})
test('uses the 3:1 threshold for large text', () => {
expect(describeTextOnBackground('#949494', '#FFFFFF', 24)).toEqual([])
expect(describeTextOnBackground('#949494', '#FFFFFF', 19, 700)).toEqual([])
expect(describeTextOnBackground('#B0B0B0', '#FFFFFF', 24)[0]?.message).toContain(
'below WCAG AA 3:1'
)
})
test('measures text faded by its own opacity', () => {
expect(
describeTextOnBackground('#000000', '#FFFFFF', 14, 400, 1, { opacity: 0.4 })[0]?.message
).toContain('contrast 2.84:1 is below WCAG AA 4.5:1')
})
test('skips text whose fill color is bound to a variable', () => {
expect(
describeTextOnBackground('#949494', '#FFFFFF', 14, 400, 1, { boundColor: 'v1' })
).toEqual([])
})
test('applies the normal threshold when a style run makes part of large text small', () => {
const smallRun = { styleRuns: [{ start: 0, length: 4, style: { fontSize: 14 } }] }
expect(
describeTextOnBackground('#949494', '#FFFFFF', 24, 400, 1, smallRun)[0]?.message
).toContain('contrast 3.03:1 is below WCAG AA 4.5:1')
const largeRun = { styleRuns: [{ start: 0, length: 4, style: { fontSize: 32 } }] }
expect(describeTextOnBackground('#949494', '#FFFFFF', 24, 400, 1, largeRun)).toEqual([])
})
})