From 9f80c35838704789dd90b4a9840ccb29e3afc812 Mon Sep 17 00:00:00 2001 From: rcoenen <753704+rcoenen@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:40:30 -0400 Subject: [PATCH] fix(tools): describe visible strokes (#447) - Include stroke color and rounded weight in node and child summaries - Select the first visible stroke when hidden paints precede it - Cover both describe output paths with mixed stroke visibility Co-authored-by: Rob Coenen <753704+rcoenen@users.noreply.github.com> --- CHANGELOG.md | 1 + packages/core/src/tools/describe/summaries.ts | 12 +++- tests/engine/tools/describe.test.ts | 67 +++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 tests/engine/tools/describe.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e935b5590..976222769 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Show stroke colors and weights in AI visual descriptions. (#447) - Stop warning AI agents that supported inline SVG attributes were ignored. (#445) - Help AI agents discover every shape supported by `create_shape`. (#448) - Keep `fill="none"` and `stroke="none"` SVG paths transparent when rendering inline artwork. (#446) diff --git a/packages/core/src/tools/describe/summaries.ts b/packages/core/src/tools/describe/summaries.ts index b25c3928f..b6d403bb3 100644 --- a/packages/core/src/tools/describe/summaries.ts +++ b/packages/core/src/tools/describe/summaries.ts @@ -16,6 +16,13 @@ function findSolidFillIndex(node: SceneNode): number { return node.fills.findIndex((candidate) => candidate.type === 'SOLID' && candidate.visible) } +function strokeSummary(node: SceneNode): string | null { + const stroke = node.strokes.find((candidate) => candidate.visible) + if (!stroke) return null + const weight = Math.round(stroke.weight * 100) / 100 + return `${colorToHex(stroke.color)} ${weight}px stroke` +} + export function describeVisual(node: SceneNode, graph?: SceneGraph): string { const parts: string[] = [] const fillIndex = findSolidFillIndex(node) @@ -23,7 +30,8 @@ export function describeVisual(node: SceneNode, graph?: SceneGraph): string { const fill = node.fills[fillIndex] parts.push(`${colorToHex(fill.color)}${boundFillSuffix(node, fillIndex, graph)} fill`) } - if (node.strokes.length > 0 && node.strokes[0]?.visible) parts.push('bordered') + const stroke = strokeSummary(node) + if (stroke) parts.push(stroke) if (node.cornerRadius > 0) parts.push('rounded') if (node.clipsContent) parts.push('clipped') for (const effect of node.effects) { @@ -83,6 +91,8 @@ export function summarizeContainer(node: SceneNode, graph?: SceneGraph): string const fill = node.fills[fillIndex] parts.push(`${colorToHex(fill.color)}${boundFillSuffix(node, fillIndex, graph)}`) } + const stroke = strokeSummary(node) + if (stroke) parts.push(stroke) if (node.cornerRadius > 0) parts.push('rounded') const layout = describeLayout(node) if (layout) parts.push(layout) diff --git a/tests/engine/tools/describe.test.ts b/tests/engine/tools/describe.test.ts new file mode 100644 index 000000000..1e395d904 --- /dev/null +++ b/tests/engine/tools/describe.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test' + +import type { Color, 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 = { + 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 = { + 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') + }) +})