* feat(scene-graph)!: make a stroke a paint Stroke extends Fill, so a stroke carries the same paint vocabulary a fill does instead of a lone color. Every construction site now states a solid paint type, which keeps today's behaviour exactly; rendering, .fig conversion, and the stroke panel still read solid strokes only. copyFill is generic over the paint shape so copyStroke reuses it rather than repeating the deep copy of gradient stops, transforms and pattern fields. Groundwork for the gradient and image strokes in #797. * test(scene-graph): cover a stroke's nested paint data in copyStroke A stroke is a paint now, so its gradient stops and transform must copy as deeply as a fill's; the fixture was solid and proved only the color and dash pattern.
167 lines
5.3 KiB
TypeScript
167 lines
5.3 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
|
|
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'
|
|
|
|
const NAVY: Color = { r: 2 / 255, g: 26 / 255, b: 59 / 255, a: 1 }
|
|
|
|
const HIDDEN_STROKE: Stroke = {
|
|
type: 'SOLID',
|
|
color: { r: 1, g: 0, b: 0, a: 1 },
|
|
weight: 4,
|
|
opacity: 1,
|
|
visible: false,
|
|
align: 'CENTER',
|
|
cap: 'NONE',
|
|
join: 'MITER'
|
|
}
|
|
|
|
const VISIBLE_STROKE: Stroke = {
|
|
type: 'SOLID',
|
|
color: NAVY,
|
|
weight: 10.126,
|
|
opacity: 1,
|
|
visible: true,
|
|
align: 'CENTER',
|
|
cap: 'NONE',
|
|
join: 'MITER'
|
|
}
|
|
|
|
interface ChildSummary {
|
|
summary: string
|
|
}
|
|
|
|
function setupStrokedChild() {
|
|
const { figma, graph } = setupToolTest()
|
|
const frame = figma.createFrame()
|
|
frame.name = 'Outline'
|
|
frame.resize(200, 200)
|
|
|
|
const outline = figma.createRectangle()
|
|
outline.resize(100, 4)
|
|
frame.appendChild(outline)
|
|
graph.updateNode(outline.id, {
|
|
fills: [],
|
|
strokes: [HIDDEN_STROKE, VISIBLE_STROKE]
|
|
})
|
|
|
|
return { figma, frameId: frame.id, outlineId: outline.id }
|
|
}
|
|
|
|
describe('describe stroke summaries', () => {
|
|
test('reports the first visible stroke in a child summary', () => {
|
|
const { figma, frameId } = setupStrokedChild()
|
|
const result = getTool('describe').execute(figma, { id: frameId }) as ToolResult
|
|
const children = result.children as ChildSummary[]
|
|
const summary = expectDefined(children[0], 'stroked child summary').summary
|
|
|
|
expect(summary).toContain('#021A3B 10.13px stroke')
|
|
})
|
|
|
|
test('reports the first visible stroke in the node visual summary', () => {
|
|
const { figma, outlineId } = setupStrokedChild()
|
|
const result = getTool('describe').execute(figma, { id: outlineId }) as ToolResult
|
|
|
|
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([])
|
|
})
|
|
})
|