From 75467676b1c067ba85edf97a4aca2ccc19786fbe Mon Sep 17 00:00:00 2001 From: Fini Date: Sun, 19 Apr 2026 19:28:41 +0800 Subject: [PATCH] feat(mcp): expand element tool family to 9 (add 4 layout-pattern tools) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 7 continues: 5 → 9 narrow element tools, each solving one documented anti-pattern from pen-ai-skills prompt knowledge. - add_stat_grid_v0: NON-scrolling 2-5 metric grid. Each cell uses width=fill_container so the renderer auto-distributes space. Directly solves the documented activity-rings overflow bug in packages/pen-ai-skills/skills/phases/generation/layout.md (three fixed 100px rings in a 279px inner card silently clip the third; with fill_container the third fits by construction). Different from add_metric_row_v0 which is a scrolling wrapper with fixed-px items. - add_section_header_v0: heading + optional trailing action ("See all" / "View more"). Forces horizontal space_between alignItems= center so action stays flush-right. Common dashboard pattern that non-Claude models frequently vertical-stack instead. - add_top_nav_bar_v0: mobile app bar. Leading icon (back/menu) + centered title + trailing icon (search/more). Dual of add_bottom_nav_v0. Empty slots become 44×44 spacers so the title stays visually centered even with asymmetric icons. - add_icon_button_v0: 44×44 icon-only button with flex centering. Explicitly NOT layout=none (the documented anti-pattern in memory: layout=none + nested absolute-positioned children renders unreliably under Skia). Forces layout=horizontal + justifyContent /alignItems=center. All four follow the established element-tool pattern: - Sugar route via insertElementTree (parent_id pre-check, DSL escape pre-check, snapshot rollback, post-check parent-location verification) - assignIdsRecursively on the built subtree - No union types in schema; narrow required params; optional sizing/styling via follow-up batch_design U-op Tests: 22 new unit (5+6+6+5 per tool) + element-tools-contract updated to assert all 9 tools satisfy §4 invariants. All 124 pen-mcp tests pass. skill file `elements.md` expanded from 5 → 9 tools (decision tree updated, PREFER phrases mapped per tool, usage examples added, role list extended). Registry regenerated from 44 → 44 skills (same count, elements.md edit in place). MCP live e2e smoke via StdioClientTransport confirms ListTools now returns 49 tools (40 baseline + 9 element), all 4 new tools callable, structural invariants verified on live output (stat-grid cell.width=fill_container; icon-button layout \!= none; 3-slot top-nav structure; section-header action group). format + tsc green. Bundle rebuilt. --- .../skills/phases/generation/elements.md | 63 +++++-- .../src/__tests__/add-icon-button-v0.test.ts | 103 ++++++++++++ .../__tests__/add-section-header-v0.test.ts | 130 ++++++++++++++ .../src/__tests__/add-stat-grid-v0.test.ts | 134 +++++++++++++++ .../src/__tests__/add-top-nav-bar-v0.test.ts | 130 ++++++++++++++ .../__tests__/element-tools-contract.test.ts | 4 + packages/pen-mcp/src/routes/design-routes.ts | 158 ++++++++++++++++++ .../pen-mcp/src/tools/add-icon-button-v0.ts | 58 +++++++ .../src/tools/add-section-header-v0.ts | 97 +++++++++++ .../pen-mcp/src/tools/add-stat-grid-v0.ts | 102 +++++++++++ .../pen-mcp/src/tools/add-top-nav-bar-v0.ts | 101 +++++++++++ 11 files changed, 1068 insertions(+), 12 deletions(-) create mode 100644 packages/pen-mcp/src/__tests__/add-icon-button-v0.test.ts create mode 100644 packages/pen-mcp/src/__tests__/add-section-header-v0.test.ts create mode 100644 packages/pen-mcp/src/__tests__/add-stat-grid-v0.test.ts create mode 100644 packages/pen-mcp/src/__tests__/add-top-nav-bar-v0.test.ts create mode 100644 packages/pen-mcp/src/tools/add-icon-button-v0.ts create mode 100644 packages/pen-mcp/src/tools/add-section-header-v0.ts create mode 100644 packages/pen-mcp/src/tools/add-stat-grid-v0.ts create mode 100644 packages/pen-mcp/src/tools/add-top-nav-bar-v0.ts diff --git a/packages/pen-ai-skills/skills/phases/generation/elements.md b/packages/pen-ai-skills/skills/phases/generation/elements.md index 6a4280514..5c79f1c57 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements.md @@ -31,22 +31,38 @@ These narrow MCP tools emit well-known structures that batch_design frequently g ## Decision tree (pick first match) -1. Row of items with title + subtitle + optional icon → `add_card_row_v0` -2. Row of items with small label + big numeric value → `add_metric_row_v0` -3. Row of filter chips / category tabs (label + optional icon, active state) → `add_nav_chip_row_v0` -4. Bottom tab bar (inline flow, 3-5 nav items) → `add_bottom_nav_v0` -5. Apple-style progress ring with centered text → `add_activity_ring_v0` -6. None match → fall through to `batch_design` +Rows (horizontal, in-card or scrolling): + +1. Row of items with title + subtitle + optional icon → `add_card_row_v0` (scroll) +2. Row of items with small label + big numeric value → `add_metric_row_v0` (scroll) +3. Row of filter chips / category tabs (label + optional icon, active state) → `add_nav_chip_row_v0` (scroll) +4. Non-scrolling 2-5 stats inline (auto-share width) → `add_stat_grid_v0` + +Single elements: + +5. Section header (big title + optional "See all" action) → `add_section_header_v0` +6. Bottom tab bar (inline flow, 3-5 nav items) → `add_bottom_nav_v0` +7. Mobile top bar (leading icon + centered title + trailing icon) → `add_top_nav_bar_v0` +8. Icon-only button (44×44, hit-target safe) → `add_icon_button_v0` +9. Apple-style progress ring with centered text → `add_activity_ring_v0` + +10. None match → fall through to `batch_design` + +**Disambiguation**: if you need a ROW of 3 metrics that should NOT scroll (e.g. a stats strip inside a card), use `add_stat_grid_v0`, NOT `add_metric_row_v0`. The grid uses `fill_container` per cell so it never overflows; the metric row uses fixed-px cells + scroll wrapper. ## When to use vs batch_design PREFER an element tool when the spec says any of: -- "horizontal scrolling cards", "swipeable row", "chip row", "pills" -- "metric tiles", "KPI cards", "dashboard stats", "Steps / Kcal / Sleep" row -- "category filter chips", "quick-access shortcuts" -- "bottom nav", "tab bar", "tabbar", "底部导航" -- "activity ring", "progress ring", "circular progress", "Apple health ring" +- "horizontal scrolling cards", "swipeable row", "chip row", "pills" → `add_card_row_v0` +- "metric tiles", "KPI cards", "dashboard stats" (SCROLLING row) → `add_metric_row_v0` +- "stats row", "3 metrics side by side", "summary bar" (NON-scrolling grid) → `add_stat_grid_v0` +- "category filter chips", "quick-access shortcuts" → `add_nav_chip_row_v0` +- "section title with See all / View more" → `add_section_header_v0` +- "bottom nav", "tab bar", "tabbar", "底部导航" → `add_bottom_nav_v0` +- "top bar", "app bar", "header with back button", "页面标题栏" → `add_top_nav_bar_v0` +- "icon-only button", "close button", "menu button" (toolbar-style) → `add_icon_button_v0` +- "activity ring", "progress ring", "circular progress", "Apple health ring" → `add_activity_ring_v0` STILL use batch_design when: @@ -94,6 +110,29 @@ add_activity_ring_v0({ size: 80, thickness: 8, }) + +add_stat_grid_v0({ + items: [ + { value: "8,432", label: "Steps", icon: "activity" }, + { value: "512", label: "Kcal", icon: "flame" }, + { value: "7h", label: "Sleep", icon: "moon" }, + ], +}) + +add_section_header_v0({ + title: "Recent Workouts", + action: { label: "See all", icon: "arrow-right" }, +}) + +add_top_nav_bar_v0({ + title: "Settings", + leading_icon: "chevron-left", + trailing_icon: "more-vertical", +}) + +add_icon_button_v0({ + icon: "search", +}) ``` ## Composition pattern @@ -112,7 +151,7 @@ The tool guarantees — you cannot break them from the input side: - `bottom-tab-bar` is inline (no empty spacer sibling needed, do NOT add one) - Activity ring is frame+cornerRadius=size/2+stroke+centered text — NEVER emit ellipse+sibling text for rings - Every emitted node has a unique id (you can reference it later) -- Roles are set (`card` / `metric-tile` / `nav-chip` / `nav-chip-active` / `bottom-tab-bar` / `nav-item` / `nav-item-active` / `activity-ring`) +- Roles are set (`card` / `metric-tile` / `nav-chip` / `nav-chip-active` / `bottom-tab-bar` / `nav-item` / `nav-item-active` / `activity-ring` / `stat-grid` / `stat-cell` / `section-header` / `section-header-action` / `top-nav-bar` / `nav-spacer` / `icon-button`) ## Failure mode diff --git a/packages/pen-mcp/src/__tests__/add-icon-button-v0.test.ts b/packages/pen-mcp/src/__tests__/add-icon-button-v0.test.ts new file mode 100644 index 000000000..5408fcaa2 --- /dev/null +++ b/packages/pen-mcp/src/__tests__/add-icon-button-v0.test.ts @@ -0,0 +1,103 @@ +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 { DESIGN_TOOL_DEFINITIONS, DESIGN_TOOL_NAMES } from '../routes/design-routes'; +import { handleAddIconButtonV0 } from '../tools/add-icon-button-v0'; +import { invalidateCache } from '../document-manager'; + +const TMP = join(tmpdir(), 'openpencil-add-icon-button-v0'); +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; +} + +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('add_icon_button_v0', () => { + it('registered + required icon', () => { + expect(DESIGN_TOOL_NAMES.has('add_icon_button_v0')).toBe(true); + const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_icon_button_v0'); + expect(def?.inputSchema.required).toEqual(['icon']); + }); + + it('default 44×44 with flex centering (NOT layout=none anti-pattern)', async () => { + const fp = await fresh('a.op'); + await handleAddIconButtonV0({ filePath: fp, icon: 'search' }); + const btn = getRoot(await readDoc(fp)); + expect(btn.role).toBe('icon-button'); + expect(btn.width).toBe(44); + expect(btn.height).toBe(44); + expect(btn.cornerRadius).toBe(8); + // Critical: NOT layout='none' (which renders unreliably per memory) + expect(btn.layout).toBe('horizontal'); + expect(btn.justifyContent).toBe('center'); + expect(btn.alignItems).toBe('center'); + const kids = btn.children as Record[]; + expect(kids.length).toBe(1); + expect(kids[0].type).toBe('icon_font'); + expect(kids[0].iconFontName).toBe('search'); + expect(kids[0].iconFontFamily).toBe('lucide'); + expect(kids[0].width).toBe(24); + expect(kids[0].height).toBe(24); + }); + + it('custom size + icon_size overrides propagate', async () => { + const fp = await fresh('a.op'); + await handleAddIconButtonV0({ + filePath: fp, + icon: 'menu', + size: 40, + icon_size: 20, + }); + const btn = getRoot(await readDoc(fp)); + expect(btn.width).toBe(40); + expect(btn.height).toBe(40); + const icon = (btn.children as Record[])[0]; + expect(icon.width).toBe(20); + expect(icon.height).toBe(20); + }); + + it('button + icon both have unique ids', async () => { + const fp = await fresh('a.op'); + await handleAddIconButtonV0({ filePath: fp, icon: 'x' }); + const btn = getRoot(await readDoc(fp)); + const iconNode = (btn.children as Record[])[0]; + expect(typeof btn.id).toBe('string'); + expect(typeof iconNode.id).toBe('string'); + expect(btn.id).not.toBe(iconNode.id); + }); + + it('throws on bogus parent_id AND leaves file untouched', async () => { + const fp = await fresh('a.op'); + const before = await readFile(fp, 'utf-8'); + await expect( + handleAddIconButtonV0({ filePath: fp, icon: 'x', parent_id: 'nope' }), + ).rejects.toThrow(/parent_id.*not found/); + expect(await readFile(fp, 'utf-8')).toBe(before); + }); +}); diff --git a/packages/pen-mcp/src/__tests__/add-section-header-v0.test.ts b/packages/pen-mcp/src/__tests__/add-section-header-v0.test.ts new file mode 100644 index 000000000..f67ae7a85 --- /dev/null +++ b/packages/pen-mcp/src/__tests__/add-section-header-v0.test.ts @@ -0,0 +1,130 @@ +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 { DESIGN_TOOL_DEFINITIONS, DESIGN_TOOL_NAMES } from '../routes/design-routes'; +import { handleAddSectionHeaderV0 } from '../tools/add-section-header-v0'; +import { invalidateCache } from '../document-manager'; + +const TMP = join(tmpdir(), 'openpencil-add-section-header-v0'); +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; +} + +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('add_section_header_v0', () => { + it('registered + required title', () => { + expect(DESIGN_TOOL_NAMES.has('add_section_header_v0')).toBe(true); + const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_section_header_v0'); + expect(def?.inputSchema.required).toEqual(['title']); + }); + + it('title-only: horizontal + space_between + 1 child (the title)', async () => { + const fp = await fresh('a.op'); + await handleAddSectionHeaderV0({ filePath: fp, title: 'Recent Activity' }); + const header = getRoot(await readDoc(fp)); + expect(header.role).toBe('section-header'); + expect(header.width).toBe('fill_container'); + expect(header.layout).toBe('horizontal'); + expect(header.justifyContent).toBe('space_between'); + expect(header.alignItems).toBe('center'); + const kids = header.children as Record[]; + expect(kids.length).toBe(1); + expect(kids[0].type).toBe('text'); + expect(kids[0].content).toBe('Recent Activity'); + expect(kids[0].role).toBe('heading'); + }); + + it('with action: title + action group (label + icon)', async () => { + const fp = await fresh('a.op'); + await handleAddSectionHeaderV0({ + filePath: fp, + title: 'Workouts', + action: { label: 'See all', icon: 'arrow-right' }, + }); + const header = getRoot(await readDoc(fp)); + const kids = header.children as Record[]; + expect(kids.length).toBe(2); + expect(kids[0].content).toBe('Workouts'); + const action = kids[1]; + expect(action.role).toBe('section-header-action'); + expect(action.layout).toBe('horizontal'); + const actionKids = action.children as Record[]; + expect(actionKids.length).toBe(2); + expect(actionKids[0].content).toBe('See all'); + expect(actionKids[1].iconFontName).toBe('arrow-right'); + }); + + it('action without icon emits label-only', async () => { + const fp = await fresh('a.op'); + await handleAddSectionHeaderV0({ + filePath: fp, + title: 'Stats', + action: { label: 'View more' }, + }); + const kids = getRoot(await readDoc(fp)).children as Record[]; + const actionKids = kids[1].children as Record[]; + expect(actionKids.length).toBe(1); + expect(actionKids[0].content).toBe('View more'); + }); + + it('every node has a unique id', async () => { + const fp = await fresh('a.op'); + await handleAddSectionHeaderV0({ + filePath: fp, + title: 'Foo', + action: { label: 'Bar', icon: 'x' }, + }); + const ids: string[] = []; + function walk(n: Record): void { + if (typeof n.id === 'string') ids.push(n.id); + if (Array.isArray(n.children)) + (n.children as Record[]).forEach( + (c) => c && typeof c === 'object' && walk(c), + ); + } + walk(getRoot(await readDoc(fp))); + // header + title + action frame + (action label + action icon) = 5 + expect(ids.length).toBe(5); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('throws on bogus parent_id AND leaves file untouched', async () => { + const fp = await fresh('a.op'); + const before = await readFile(fp, 'utf-8'); + await expect( + handleAddSectionHeaderV0({ + filePath: fp, + title: 'X', + parent_id: 'nope', + }), + ).rejects.toThrow(/parent_id.*not found/); + expect(await readFile(fp, 'utf-8')).toBe(before); + }); +}); diff --git a/packages/pen-mcp/src/__tests__/add-stat-grid-v0.test.ts b/packages/pen-mcp/src/__tests__/add-stat-grid-v0.test.ts new file mode 100644 index 000000000..d26a4413d --- /dev/null +++ b/packages/pen-mcp/src/__tests__/add-stat-grid-v0.test.ts @@ -0,0 +1,134 @@ +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 { DESIGN_TOOL_DEFINITIONS, DESIGN_TOOL_NAMES } from '../routes/design-routes'; +import { handleAddStatGridV0 } from '../tools/add-stat-grid-v0'; +import { invalidateCache } from '../document-manager'; + +const TMP = join(tmpdir(), 'openpencil-add-stat-grid-v0'); +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; +} + +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('add_stat_grid_v0', () => { + it('registered + required items', () => { + expect(DESIGN_TOOL_NAMES.has('add_stat_grid_v0')).toBe(true); + const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_stat_grid_v0'); + expect(def?.inputSchema.required).toEqual(['items']); + }); + + it('EACH CELL uses width=fill_container (critical for no-overflow invariant)', async () => { + const fp = await fresh('a.op'); + await handleAddStatGridV0({ + filePath: fp, + items: [ + { value: '8,432', label: 'Steps', icon: 'activity' }, + { value: '512', label: 'Kcal' }, + { value: '7h', label: 'Sleep' }, + ], + }); + const grid = getRoot(await readDoc(fp)); + expect(grid.role).toBe('stat-grid'); + expect(grid.width).toBe('fill_container'); + expect(grid.layout).toBe('horizontal'); + expect(grid.justifyContent).toBe('space_between'); + const cells = grid.children as Record[]; + expect(cells.length).toBe(3); + for (const cell of cells) { + expect(cell.role).toBe('stat-cell'); + // THIS is the core invariant: fill_container on every cell = no overflow + expect(cell.width).toBe('fill_container'); + expect(cell.layout).toBe('vertical'); + expect(cell.alignItems).toBe('center'); + } + }); + + it('emits value + label (heading + body), optional icon', async () => { + const fp = await fresh('a.op'); + await handleAddStatGridV0({ + filePath: fp, + items: [ + { value: '75%', label: 'Goal', icon: 'target' }, + { value: '42', label: 'Workouts' }, + ], + }); + const cells = getRoot(await readDoc(fp)).children as Record[]; + // with icon: icon + value + label = 3 kids + const k0 = cells[0].children as Record[]; + expect(k0.length).toBe(3); + expect(k0[0].type).toBe('icon_font'); + expect(k0[0].iconFontName).toBe('target'); + expect(k0[1].content).toBe('75%'); + expect(k0[1].role).toBe('heading'); + expect(k0[2].content).toBe('Goal'); + expect(k0[2].role).toBe('body'); + // without icon: value + label = 2 kids + const k1 = cells[1].children as Record[]; + expect(k1.length).toBe(2); + }); + + it('every node has a unique id', async () => { + const fp = await fresh('a.op'); + await handleAddStatGridV0({ + filePath: fp, + items: [ + { value: '1', label: 'A' }, + { value: '2', label: 'B' }, + ], + }); + const ids: string[] = []; + function walk(n: Record): void { + if (typeof n.id === 'string') ids.push(n.id); + if (Array.isArray(n.children)) + (n.children as Record[]).forEach( + (c) => c && typeof c === 'object' && walk(c), + ); + } + walk(getRoot(await readDoc(fp))); + // grid + 2 cells + 4 children (2 each: value + label, no icon) = 7 + expect(ids.length).toBe(7); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('throws on bogus parent_id AND leaves file untouched', async () => { + const fp = await fresh('a.op'); + const before = await readFile(fp, 'utf-8'); + await expect( + handleAddStatGridV0({ + filePath: fp, + items: [{ value: '1', label: 'A' }], + parent_id: 'nope', + }), + ).rejects.toThrow(/parent_id.*not found/); + const after = await readFile(fp, 'utf-8'); + expect(after).toBe(before); + }); +}); diff --git a/packages/pen-mcp/src/__tests__/add-top-nav-bar-v0.test.ts b/packages/pen-mcp/src/__tests__/add-top-nav-bar-v0.test.ts new file mode 100644 index 000000000..045dbb4b9 --- /dev/null +++ b/packages/pen-mcp/src/__tests__/add-top-nav-bar-v0.test.ts @@ -0,0 +1,130 @@ +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 { DESIGN_TOOL_DEFINITIONS, DESIGN_TOOL_NAMES } from '../routes/design-routes'; +import { handleAddTopNavBarV0 } from '../tools/add-top-nav-bar-v0'; +import { invalidateCache } from '../document-manager'; + +const TMP = join(tmpdir(), 'openpencil-add-top-nav-bar-v0'); +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; +} + +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('add_top_nav_bar_v0', () => { + it('registered + required title', () => { + expect(DESIGN_TOOL_NAMES.has('add_top_nav_bar_v0')).toBe(true); + const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_top_nav_bar_v0'); + expect(def?.inputSchema.required).toEqual(['title']); + }); + + it('both icons: 3 children = leading button + title + trailing button', async () => { + const fp = await fresh('a.op'); + await handleAddTopNavBarV0({ + filePath: fp, + title: 'Settings', + leading_icon: 'chevron-left', + trailing_icon: 'more-vertical', + }); + const bar = getRoot(await readDoc(fp)); + expect(bar.role).toBe('top-nav-bar'); + expect(bar.width).toBe('fill_container'); + expect(bar.height).toBe(56); + expect(bar.justifyContent).toBe('space_between'); + expect(bar.padding).toEqual([0, 16]); + const kids = bar.children as Record[]; + expect(kids.length).toBe(3); + expect(kids[0].role).toBe('icon-button'); + expect((kids[0].children as Record[])[0].iconFontName).toBe('chevron-left'); + expect(kids[1].content).toBe('Settings'); + expect(kids[1].role).toBe('heading'); + expect(kids[2].role).toBe('icon-button'); + expect((kids[2].children as Record[])[0].iconFontName).toBe('more-vertical'); + }); + + it('no icons: 44×44 spacers on both sides keep title visually centered', async () => { + const fp = await fresh('a.op'); + await handleAddTopNavBarV0({ filePath: fp, title: 'Home' }); + const bar = getRoot(await readDoc(fp)); + const kids = bar.children as Record[]; + expect(kids.length).toBe(3); + expect(kids[0].role).toBe('nav-spacer'); + expect(kids[0].width).toBe(44); + expect(kids[0].height).toBe(44); + expect(kids[2].role).toBe('nav-spacer'); + }); + + it('asymmetric (leading only): trailing becomes spacer', async () => { + const fp = await fresh('a.op'); + await handleAddTopNavBarV0({ + filePath: fp, + title: 'Profile', + leading_icon: 'arrow-left', + }); + const kids = getRoot(await readDoc(fp)).children as Record[]; + expect(kids[0].role).toBe('icon-button'); + expect(kids[2].role).toBe('nav-spacer'); + }); + + it('every node has a unique id', async () => { + const fp = await fresh('a.op'); + await handleAddTopNavBarV0({ + filePath: fp, + title: 'X', + leading_icon: 'a', + trailing_icon: 'b', + }); + const ids: string[] = []; + function walk(n: Record): void { + if (typeof n.id === 'string') ids.push(n.id); + if (Array.isArray(n.children)) + (n.children as Record[]).forEach( + (c) => c && typeof c === 'object' && walk(c), + ); + } + walk(getRoot(await readDoc(fp))); + // bar + 3 slots + 2 icons (leading/trailing) + 0 (title is text, counted) = bar + leading(btn+icon) + title + trailing(btn+icon) = 1+2+1+2 = 6 + expect(ids.length).toBe(6); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('throws on bogus parent_id AND leaves file untouched', async () => { + const fp = await fresh('a.op'); + const before = await readFile(fp, 'utf-8'); + await expect( + handleAddTopNavBarV0({ + filePath: fp, + title: 'X', + parent_id: 'nope', + }), + ).rejects.toThrow(/parent_id.*not found/); + expect(await readFile(fp, 'utf-8')).toBe(before); + }); +}); diff --git a/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts b/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts index 14a96a1af..9628a8d7e 100644 --- a/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts +++ b/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts @@ -28,6 +28,10 @@ const ELEMENT_TOOL_NAMES = [ 'add_nav_chip_row_v0', 'add_bottom_nav_v0', 'add_activity_ring_v0', + 'add_stat_grid_v0', + 'add_section_header_v0', + 'add_top_nav_bar_v0', + 'add_icon_button_v0', ]; describe('element tools — v0-MUST contract', () => { diff --git a/packages/pen-mcp/src/routes/design-routes.ts b/packages/pen-mcp/src/routes/design-routes.ts index c13129ecc..078787b6a 100644 --- a/packages/pen-mcp/src/routes/design-routes.ts +++ b/packages/pen-mcp/src/routes/design-routes.ts @@ -10,6 +10,10 @@ import { handleAddMetricRowV0 } from '../tools/add-metric-row-v0'; import { handleAddNavChipRowV0 } from '../tools/add-nav-chip-row-v0'; import { handleAddBottomNavV0 } from '../tools/add-bottom-nav-v0'; import { handleAddActivityRingV0 } from '../tools/add-activity-ring-v0'; +import { handleAddStatGridV0 } from '../tools/add-stat-grid-v0'; +import { handleAddSectionHeaderV0 } from '../tools/add-section-header-v0'; +import { handleAddTopNavBarV0 } from '../tools/add-top-nav-bar-v0'; +import { handleAddIconButtonV0 } from '../tools/add-icon-button-v0'; export const DESIGN_TOOL_DEFINITIONS = [ { @@ -291,6 +295,148 @@ export const DESIGN_TOOL_DEFINITIONS = [ required: ['center_text'], }, }, + { + name: 'add_stat_grid_v0', + description: + 'Create a NON-scrolling stat grid (2-5 items share the row via fill_container). ' + + 'Different from add_metric_row_v0: this emits an inline grid that auto-distributes ' + + 'available width, solving the documented activity-rings overflow bug in ' + + 'packages/pen-ai-skills/skills/phases/generation/layout.md (three fixed 100px items in ' + + 'a 279px inner card silently clip the third item). Each cell is width=fill_container; ' + + 'renderer does the division. Use when spec mentions "stats row", "3 metrics side by side", ' + + '"summary bar", or an inline (non-scrollable) row of KPIs. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: { + type: 'string', + enum: ['1.0'], + description: 'Schema version (v0-MUST §4.2). Clients MAY omit.', + }, + filePath: { type: 'string', description: 'Path to .op file, or omit for live canvas' }, + items: { + type: 'array', + description: + 'Stat cells. Each needs value + label; icon is optional. 2-5 items work best.', + items: { + type: 'object', + properties: { + value: { type: 'string', description: 'Big numeric value (e.g. "8,432")' }, + label: { type: 'string', description: 'Small descriptive label (e.g. "Steps")' }, + icon: { type: 'string', description: 'lucide icon name (optional)' }, + }, + required: ['value', 'label'], + }, + }, + gap: { type: 'number', description: 'Gap between cells in px (default 16)' }, + parent_id: { + type: 'string', + description: 'Target parent node id (must exist). Omit for root-level insertion.', + }, + pageId: { type: 'string', description: 'Target page ID (defaults to first page)' }, + }, + required: ['items'], + }, + }, + { + name: 'add_section_header_v0', + description: + 'Section header with big title on left + optional trailing action (e.g. "See all", ' + + '"View more"). Forces horizontal space_between alignItems=center layout so the action ' + + 'always sits flush-right. Use when spec shows "Section Title" with a "See all" or "→" link, ' + + 'or any heading + secondary action pair. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: { + type: 'string', + enum: ['1.0'], + description: 'Schema version (v0-MUST §4.2). Clients MAY omit.', + }, + filePath: { type: 'string', description: 'Path to .op file, or omit for live canvas' }, + title: { type: 'string', description: 'Section title (big heading)' }, + action: { + type: 'object', + description: 'Optional trailing action (e.g. { label: "See all", icon: "arrow-right" })', + properties: { + label: { type: 'string' }, + icon: { type: 'string', description: 'lucide icon name (optional)' }, + }, + required: ['label'], + }, + parent_id: { + type: 'string', + description: 'Target parent node id (must exist). Omit for root-level insertion.', + }, + pageId: { type: 'string', description: 'Target page ID (defaults to first page)' }, + }, + required: ['title'], + }, + }, + { + name: 'add_top_nav_bar_v0', + description: + 'Mobile top navigation bar: optional leading icon (back/menu) + centered title + ' + + 'optional trailing icon (search/more). Dual of add_bottom_nav_v0. Title always centered; ' + + 'empty slots become 44×44 spacers so the title visually stays centered. Use when spec ' + + 'mentions "top bar", "app bar", "header with back button", "页面标题栏". schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: { + type: 'string', + enum: ['1.0'], + description: 'Schema version (v0-MUST §4.2). Clients MAY omit.', + }, + filePath: { type: 'string', description: 'Path to .op file, or omit for live canvas' }, + title: { type: 'string', description: 'Centered title text' }, + leading_icon: { + type: 'string', + description: 'lucide icon name for the left slot (e.g. "chevron-left", "menu")', + }, + trailing_icon: { + type: 'string', + description: 'lucide icon name for the right slot (e.g. "search", "more-vertical")', + }, + height: { type: 'number', description: 'Bar height in px (default 56)' }, + parent_id: { + type: 'string', + description: 'Target parent node id (must exist). Omit for root-level insertion.', + }, + pageId: { type: 'string', description: 'Target page ID (defaults to first page)' }, + }, + required: ['title'], + }, + }, + { + name: 'add_icon_button_v0', + description: + 'Icon-only button. Forces 44×44 minimum hit target (Apple HIG + Material) with ' + + 'flex-centered icon — NEVER emits the layout=none + absolute-positioned icon anti-pattern ' + + 'documented in pen-ai-skills memory (layout=none + nested children renders unreliably). ' + + 'Use when the spec shows "icon-only" buttons, search/close/menu buttons, or iconic actions ' + + 'in toolbars. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: { + type: 'string', + enum: ['1.0'], + description: 'Schema version (v0-MUST §4.2). Clients MAY omit.', + }, + filePath: { type: 'string', description: 'Path to .op file, or omit for live canvas' }, + icon: { type: 'string', description: 'lucide icon name' }, + size: { type: 'number', description: 'Button size (hit-target) in px (default 44)' }, + icon_size: { type: 'number', description: 'Icon glyph size in px (default 24)' }, + parent_id: { + type: 'string', + description: 'Target parent node id (must exist). Omit for root-level insertion.', + }, + pageId: { type: 'string', description: 'Target page ID (defaults to first page)' }, + }, + required: ['icon'], + }, + }, ]; export const DESIGN_TOOL_NAMES = new Set([ @@ -304,6 +450,10 @@ export const DESIGN_TOOL_NAMES = new Set([ 'add_nav_chip_row_v0', 'add_bottom_nav_v0', 'add_activity_ring_v0', + 'add_stat_grid_v0', + 'add_section_header_v0', + 'add_top_nav_bar_v0', + 'add_icon_button_v0', ]); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -341,6 +491,14 @@ export async function handleDesignToolCall( return JSON.stringify(await handleAddBottomNavV0(a), null, 2); case 'add_activity_ring_v0': return JSON.stringify(await handleAddActivityRingV0(a), null, 2); + case 'add_stat_grid_v0': + return JSON.stringify(await handleAddStatGridV0(a), null, 2); + case 'add_section_header_v0': + return JSON.stringify(await handleAddSectionHeaderV0(a), null, 2); + case 'add_top_nav_bar_v0': + return JSON.stringify(await handleAddTopNavBarV0(a), null, 2); + case 'add_icon_button_v0': + return JSON.stringify(await handleAddIconButtonV0(a), null, 2); default: return ''; } diff --git a/packages/pen-mcp/src/tools/add-icon-button-v0.ts b/packages/pen-mcp/src/tools/add-icon-button-v0.ts new file mode 100644 index 000000000..afe9e63ab --- /dev/null +++ b/packages/pen-mcp/src/tools/add-icon-button-v0.ts @@ -0,0 +1,58 @@ +import type { handleBatchDesign } from './batch-design'; +import { + assignIdsRecursively, + ensureParentExists, + insertElementTree, +} from './element-tool-helpers'; + +export interface AddIconButtonV0Params { + icon: string; + size?: number; + icon_size?: number; + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Icon-only button. Forces the 44×44 minimum-hit-target + flex-centered + * icon pattern (Apple HIG + Material). Common failure on non-Claude + * models: layout="none" with manually-placed icon x/y (the documented + * anti-pattern in pen-ai-skills memory: layout=none + absolute + * positioning renders unreliably). + * + * Correct: frame(layout=horizontal, justifyContent=center, + * alignItems=center) + centered icon_font child. This tool encodes it. + * + * Spec: openpencil-docs/superpowers/specs/2026-04-19-element-tools-v0.md §7 + */ +export async function handleAddIconButtonV0( + params: AddIconButtonV0Params, +): Promise>> { + await ensureParentExists(params); + const size = params.size ?? 44; + const iconSize = params.icon_size ?? 24; + const button = { + type: 'frame', + name: 'Icon Button', + role: 'icon-button', + width: size, + height: size, + layout: 'horizontal', + justifyContent: 'center', + alignItems: 'center', + cornerRadius: 8, + children: [ + { + type: 'icon_font', + name: 'Icon', + iconFontName: params.icon, + iconFontFamily: 'lucide', + width: iconSize, + height: iconSize, + }, + ], + }; + assignIdsRecursively(button); + return insertElementTree({ binding: 'btn', tree: button, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-section-header-v0.ts b/packages/pen-mcp/src/tools/add-section-header-v0.ts new file mode 100644 index 000000000..01f3941cc --- /dev/null +++ b/packages/pen-mcp/src/tools/add-section-header-v0.ts @@ -0,0 +1,97 @@ +import type { handleBatchDesign } from './batch-design'; +import { + assignIdsRecursively, + ensureParentExists, + insertElementTree, +} from './element-tool-helpers'; + +export interface AddSectionHeaderV0Action { + label: string; + icon?: string; +} + +export interface AddSectionHeaderV0Params { + title: string; + action?: AddSectionHeaderV0Action; + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Dashboard / landing section header: big title on the left, optional + * trailing action (e.g. "See all →", "View More", "Edit"). + * Forces horizontal + space_between + alignItems=center layout so the + * action always sits flush-right regardless of title length. + * + * Common failure on non-Claude models: title and action stack vertically + * (wrong layout direction) or action overlaps title (missing + * space_between). Tool encodes the correct pattern. + * + * Spec: openpencil-docs/superpowers/specs/2026-04-19-element-tools-v0.md §7 + */ +export async function handleAddSectionHeaderV0( + params: AddSectionHeaderV0Params, +): Promise>> { + await ensureParentExists(params); + const children: Record[] = [ + { + type: 'text', + name: 'Title', + role: 'heading', + content: params.title, + fontSize: 20, + fontWeight: 700, + }, + ]; + if (params.action) { + children.push(buildActionGroup(params.action)); + } + const header = { + type: 'frame', + name: 'Section Header', + role: 'section-header', + width: 'fill_container', + height: 'fit_content', + layout: 'horizontal', + justifyContent: 'space_between', + alignItems: 'center', + children, + }; + assignIdsRecursively(header); + return insertElementTree({ binding: 'header', tree: header, ...params }); +} + +function buildActionGroup(action: AddSectionHeaderV0Action): Record { + const children: Record[] = [ + { + type: 'text', + name: 'Action Label', + role: 'label', + content: action.label, + fontSize: 14, + fontWeight: 500, + }, + ]; + if (action.icon) { + children.push({ + type: 'icon_font', + name: 'Action Icon', + iconFontName: action.icon, + iconFontFamily: 'lucide', + width: 16, + height: 16, + }); + } + return { + type: 'frame', + name: 'Action', + role: 'section-header-action', + width: 'fit_content', + height: 'fit_content', + layout: 'horizontal', + alignItems: 'center', + gap: 4, + children, + }; +} diff --git a/packages/pen-mcp/src/tools/add-stat-grid-v0.ts b/packages/pen-mcp/src/tools/add-stat-grid-v0.ts new file mode 100644 index 000000000..d98eb5333 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-stat-grid-v0.ts @@ -0,0 +1,102 @@ +import type { handleBatchDesign } from './batch-design'; +import { + assignIdsRecursively, + ensureParentExists, + insertElementTree, +} from './element-tool-helpers'; + +export interface AddStatGridV0Item { + value: string; + label: string; + icon?: string; +} + +export interface AddStatGridV0Params { + items: AddStatGridV0Item[]; + gap?: number; + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Fixed-column stat grid where every item gets `width: "fill_container"` so + * the row auto-distributes available space between 2-5 items without + * overflowing the parent. + * + * Solves the documented "activity-rings overflow" anti-pattern in + * packages/pen-ai-skills/skills/phases/generation/layout.md: three + * fixed-100px rings with 24px gap inside a 279px inner card OVERFLOW; + * the third is silently clipped on the right edge. Forcing fill_container + * makes the renderer share space mathematically — no clipping possible. + * + * Different from add_metric_row_v0 which is HORIZONTAL SCROLL (fit_content + * + clipContent wrapper) with fixed-px items: stat_grid is the + * NON-scrolling in-card variant. + * + * Spec: openpencil-docs/superpowers/specs/2026-04-19-element-tools-v0.md §7 + */ +export async function handleAddStatGridV0( + params: AddStatGridV0Params, +): Promise>> { + await ensureParentExists(params); + const gap = params.gap ?? 16; + const cells = params.items.map((item) => buildCell(item)); + const grid = { + type: 'frame', + name: 'Stat Grid', + role: 'stat-grid', + width: 'fill_container', + height: 'fit_content', + layout: 'horizontal', + gap, + alignItems: 'center', + justifyContent: 'space_between', + children: cells, + }; + assignIdsRecursively(grid); + return insertElementTree({ binding: 'grid', tree: grid, ...params }); +} + +function buildCell(item: AddStatGridV0Item): Record { + const children: Record[] = []; + if (item.icon) { + children.push({ + type: 'icon_font', + name: 'Icon', + iconFontName: item.icon, + iconFontFamily: 'lucide', + width: 20, + height: 20, + }); + } + children.push({ + type: 'text', + name: 'Value', + role: 'heading', + content: item.value, + fontSize: 24, + fontWeight: 700, + width: 'fill_container', + }); + children.push({ + type: 'text', + name: 'Label', + role: 'body', + content: item.label, + fontSize: 12, + fontWeight: 500, + width: 'fill_container', + }); + return { + type: 'frame', + name: 'Stat Cell', + role: 'stat-cell', + width: 'fill_container', // critical: shares space with siblings, no overflow + height: 'fit_content', + layout: 'vertical', + alignItems: 'center', + gap: 4, + children, + }; +} diff --git a/packages/pen-mcp/src/tools/add-top-nav-bar-v0.ts b/packages/pen-mcp/src/tools/add-top-nav-bar-v0.ts new file mode 100644 index 000000000..4f30d6ffa --- /dev/null +++ b/packages/pen-mcp/src/tools/add-top-nav-bar-v0.ts @@ -0,0 +1,101 @@ +import type { handleBatchDesign } from './batch-design'; +import { + assignIdsRecursively, + ensureParentExists, + insertElementTree, +} from './element-tool-helpers'; + +export interface AddTopNavBarV0Params { + title: string; + leading_icon?: string; + trailing_icon?: string; + height?: number; + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Mobile top navigation bar: optional leading icon (usually back/menu) + * + centered title + optional trailing icon (search / more). + * + * Dual of add_bottom_nav_v0. Forces the fill_container + fixed height + * + horizontal space_between + padding=[0,16] pattern that's consistent + * across iOS-style and Material-style app bars. + * + * Title always centered. Leading/trailing icons occupy 44×44 hit targets + * per Apple HIG + Material guidelines. + * + * Spec: openpencil-docs/superpowers/specs/2026-04-19-element-tools-v0.md §7 + */ +export async function handleAddTopNavBarV0( + params: AddTopNavBarV0Params, +): Promise>> { + await ensureParentExists(params); + const height = params.height ?? 56; + const bar = { + type: 'frame', + name: 'Top Nav Bar', + role: 'top-nav-bar', + width: 'fill_container', + height, + layout: 'horizontal', + justifyContent: 'space_between', + alignItems: 'center', + padding: [0, 16], + children: [ + buildIconSlot(params.leading_icon, 'leading'), + { + type: 'text', + name: 'Title', + role: 'heading', + content: params.title, + fontSize: 17, + fontWeight: 600, + }, + buildIconSlot(params.trailing_icon, 'trailing'), + ], + }; + assignIdsRecursively(bar); + return insertElementTree({ binding: 'nav', tree: bar, ...params }); +} + +function buildIconSlot( + icon: string | undefined, + position: 'leading' | 'trailing', +): Record { + if (!icon) { + // Empty spacer with the same 44x44 footprint so the title stays + // visually centered even with an asymmetric slot. + return { + type: 'frame', + name: `${position} Spacer`, + role: 'nav-spacer', + width: 44, + height: 44, + layout: 'none', + children: [], + }; + } + return { + type: 'frame', + name: `${position} Icon Button`, + role: 'icon-button', + width: 44, + height: 44, + layout: 'horizontal', + justifyContent: 'center', + alignItems: 'center', + cornerRadius: 8, + children: [ + { + type: 'icon_font', + name: 'Icon', + iconFontName: icon, + iconFontFamily: 'lucide', + width: 24, + height: 24, + }, + ], + }; +}