From bc7e16fa20bbf906f93b208519676d0881d6a0df Mon Sep 17 00:00:00 2001 From: Fini Date: Mon, 20 Apr 2026 00:02:59 +0800 Subject: [PATCH] fix(mcp): element tools with colored bg set foreground for readable contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pen-core's DEFAULT_FILL is gray-300 (#d1d5db) — any text/icon without explicit fill renders light gray, unreadable on primary blue / dark / white backgrounds. Set explicit foreground colors on toast text + icon, fab icon, stepper step numbers, checkbox check, and segmented-control labels. Added contrast-regression test to lock the invariant. --- .../__tests__/element-tools-contrast.test.ts | 117 ++++++++++++++++++ packages/pen-mcp/src/tools/add-checkbox-v0.ts | 1 + packages/pen-mcp/src/tools/add-fab-v0.ts | 1 + .../src/tools/add-segmented-control-v0.ts | 1 + packages/pen-mcp/src/tools/add-stepper-v0.ts | 1 + packages/pen-mcp/src/tools/add-toast-v0.ts | 2 + 6 files changed, 123 insertions(+) create mode 100644 packages/pen-mcp/src/__tests__/element-tools-contrast.test.ts diff --git a/packages/pen-mcp/src/__tests__/element-tools-contrast.test.ts b/packages/pen-mcp/src/__tests__/element-tools-contrast.test.ts new file mode 100644 index 000000000..37d75bb47 --- /dev/null +++ b/packages/pen-mcp/src/__tests__/element-tools-contrast.test.ts @@ -0,0 +1,117 @@ +/** + * Foreground-contrast regression guard. pen-core's DEFAULT_FILL is + * `#d1d5db` (gray-300) — so any text/icon without an explicit `fill` + * renders as light gray. On a colored background (primary blue, iOS green, + * dark #111827, bright white), gray-300 produces unreadable content. The + * tools below hardcode colored fills on the container, so they MUST also + * set the foreground color — otherwise the weak-model design output ships + * broken by default. + * + * This test locks the foreground colors so the regression can't sneak + * back in via an unrelated edit. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { writeFile, unlink, readFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { handleAddToastV0 } from '../tools/add-toast-v0'; +import { handleAddFabV0 } from '../tools/add-fab-v0'; +import { handleAddStepperV0 } from '../tools/add-stepper-v0'; +import { handleAddCheckboxV0 } from '../tools/add-checkbox-v0'; +import { handleAddSegmentedControlV0 } from '../tools/add-segmented-control-v0'; +import { invalidateCache } from '../document-manager'; + +const TMP = join(tmpdir(), 'openpencil-element-contrast'); +const EMPTY = JSON.stringify({ version: '1.0.0', children: [] }); + +async function fresh(name: string): Promise { + const fp = join(TMP, name); + await writeFile(fp, EMPTY, 'utf-8'); + return fp; +} +async function readDoc(fp: string): Promise> { + return JSON.parse(await readFile(fp, 'utf-8')); +} +function getRoot(doc: Record): Record { + const pages = doc['pages'] as Array<{ children?: Record[] }> | undefined; + const top = doc['children'] as Record[] | undefined; + const root = (top ?? pages?.[0]?.children)?.[0]; + if (!root) throw new Error('no root'); + return root; +} +function firstFillColor(node: Record): string | undefined { + const fills = node.fill as Array<{ color?: string }> | undefined; + return fills?.[0]?.color; +} + +beforeEach(async () => { + await mkdir(TMP, { recursive: true }); +}); +afterEach(async () => { + for (const f of ['a.op']) { + try { + const fp = join(TMP, f); + invalidateCache(fp); + await unlink(fp); + } catch {} + } +}); + +describe('element tools — colored backgrounds ship with readable foreground', () => { + it('toast: white text + icon on dark #111827 bg', async () => { + const fp = await fresh('a.op'); + await handleAddToastV0({ filePath: fp, message: 'Copied', icon: 'check' }); + const t = getRoot(await readDoc(fp)); + expect(firstFillColor(t)).toBe('#111827'); + const kids = t.children as Record[]; + expect(firstFillColor(kids[0])).toBe('#FFFFFF'); // icon + expect(firstFillColor(kids[1])).toBe('#FFFFFF'); // message + }); + + it('fab: white icon on blue #2563EB bg', async () => { + const fp = await fresh('a.op'); + await handleAddFabV0({ filePath: fp, icon: 'plus' }); + const fab = getRoot(await readDoc(fp)); + expect(firstFillColor(fab)).toBe('#2563EB'); + const icon = (fab.children as Record[])[0]; + expect(firstFillColor(icon)).toBe('#FFFFFF'); + }); + + it('stepper: done numbers white on blue, pending numbers gray-500 on gray-200', async () => { + const fp = await fresh('a.op'); + await handleAddStepperV0({ filePath: fp, total: 3, current: 1 }); + const steps = getRoot(await readDoc(fp)).children as Record[]; + // steps 0 + 2 are done/active (blue), step 4 (index=4 in children array with + // connectors interleaved) is pending + const done0 = steps[0]; + const pending = steps[4]; + expect(firstFillColor(done0)).toBe('#2563EB'); + expect(firstFillColor(pending)).toBe('#E5E7EB'); + const doneNum = (done0.children as Record[])[0]; + const pendingNum = (pending.children as Record[])[0]; + expect(firstFillColor(doneNum)).toBe('#FFFFFF'); + expect(firstFillColor(pendingNum)).toBe('#6B7280'); + }); + + it('checkbox: white check icon on blue-filled box when checked', async () => { + const fp = await fresh('a.op'); + await handleAddCheckboxV0({ filePath: fp, label: 'Done', checked: true }); + const box = (getRoot(await readDoc(fp)).children as Record[])[0]; + expect(firstFillColor(box)).toBe('#2563EB'); + const check = (box.children as Record[])[0]; + expect(firstFillColor(check)).toBe('#FFFFFF'); + }); + + it('segmented control: active label dark-900 (on white), inactive label gray-600 (on gray-100)', async () => { + const fp = await fresh('a.op'); + await handleAddSegmentedControlV0({ + filePath: fp, + items: [{ label: 'Day' }, { label: 'Week', active: true }], + }); + const segs = getRoot(await readDoc(fp)).children as Record[]; + const inactiveLabel = (segs[0].children as Record[])[0]; + const activeLabel = (segs[1].children as Record[])[0]; + expect(firstFillColor(inactiveLabel)).toBe('#4B5563'); + expect(firstFillColor(activeLabel)).toBe('#111827'); + }); +}); diff --git a/packages/pen-mcp/src/tools/add-checkbox-v0.ts b/packages/pen-mcp/src/tools/add-checkbox-v0.ts index 33d48fa70..2d41a620f 100644 --- a/packages/pen-mcp/src/tools/add-checkbox-v0.ts +++ b/packages/pen-mcp/src/tools/add-checkbox-v0.ts @@ -46,6 +46,7 @@ export async function handleAddCheckboxV0( iconFontFamily: 'lucide', width: 14, height: 14, + fill: [{ type: 'solid', color: '#FFFFFF' }], }); } else { box.fill = []; diff --git a/packages/pen-mcp/src/tools/add-fab-v0.ts b/packages/pen-mcp/src/tools/add-fab-v0.ts index 9af613de3..677cae892 100644 --- a/packages/pen-mcp/src/tools/add-fab-v0.ts +++ b/packages/pen-mcp/src/tools/add-fab-v0.ts @@ -46,6 +46,7 @@ export async function handleAddFabV0( iconFontFamily: 'lucide', width: iconSize, height: iconSize, + fill: [{ type: 'solid', color: '#FFFFFF' }], }, ], }; diff --git a/packages/pen-mcp/src/tools/add-segmented-control-v0.ts b/packages/pen-mcp/src/tools/add-segmented-control-v0.ts index 56422bcec..a6395735b 100644 --- a/packages/pen-mcp/src/tools/add-segmented-control-v0.ts +++ b/packages/pen-mcp/src/tools/add-segmented-control-v0.ts @@ -49,6 +49,7 @@ export async function handleAddSegmentedControlV0( content: item.label, fontSize: 13, fontWeight: item.active ? 600 : 500, + fill: [{ type: 'solid', color: item.active ? '#111827' : '#4B5563' }], }, ], }; diff --git a/packages/pen-mcp/src/tools/add-stepper-v0.ts b/packages/pen-mcp/src/tools/add-stepper-v0.ts index 9423fcb7f..165dc9a31 100644 --- a/packages/pen-mcp/src/tools/add-stepper-v0.ts +++ b/packages/pen-mcp/src/tools/add-stepper-v0.ts @@ -52,6 +52,7 @@ export async function handleAddStepperV0( content: String(i + 1), fontSize: 13, fontWeight: 600, + fill: [{ type: 'solid', color: done ? '#FFFFFF' : '#6B7280' }], }, ], }); diff --git a/packages/pen-mcp/src/tools/add-toast-v0.ts b/packages/pen-mcp/src/tools/add-toast-v0.ts index f2ed53239..23df1e207 100644 --- a/packages/pen-mcp/src/tools/add-toast-v0.ts +++ b/packages/pen-mcp/src/tools/add-toast-v0.ts @@ -33,6 +33,7 @@ export async function handleAddToastV0( iconFontFamily: 'lucide', width: 18, height: 18, + fill: [{ type: 'solid', color: '#FFFFFF' }], }); } children.push({ @@ -42,6 +43,7 @@ export async function handleAddToastV0( content: params.message, fontSize: 14, fontWeight: 500, + fill: [{ type: 'solid', color: '#FFFFFF' }], }); const toast = { type: 'frame',