feat(mcp): expand element tool family to 9 (add 4 layout-pattern tools)

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.
This commit is contained in:
Fini 2026-04-19 19:28:41 +08:00
parent bff94b2ada
commit 75467676b1
11 changed files with 1068 additions and 12 deletions

View file

@ -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

View file

@ -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<string> {
const fp = join(TMP, name);
await writeFile(fp, EMPTY, 'utf-8');
return fp;
}
async function readDoc(fp: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(fp, 'utf-8'));
}
function getRoot(doc: Record<string, unknown>): Record<string, unknown> {
const pages = doc['pages'] as Array<{ children?: Record<string, unknown>[] }> | undefined;
const top = doc['children'] as Record<string, unknown>[] | 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<string, unknown>[];
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<string, unknown>[])[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<string, unknown>[])[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);
});
});

View file

@ -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<string> {
const fp = join(TMP, name);
await writeFile(fp, EMPTY, 'utf-8');
return fp;
}
async function readDoc(fp: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(fp, 'utf-8'));
}
function getRoot(doc: Record<string, unknown>): Record<string, unknown> {
const pages = doc['pages'] as Array<{ children?: Record<string, unknown>[] }> | undefined;
const top = doc['children'] as Record<string, unknown>[] | 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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>[];
const actionKids = kids[1].children as Record<string, unknown>[];
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<string, unknown>): void {
if (typeof n.id === 'string') ids.push(n.id);
if (Array.isArray(n.children))
(n.children as Record<string, unknown>[]).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);
});
});

View file

@ -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<string> {
const fp = join(TMP, name);
await writeFile(fp, EMPTY, 'utf-8');
return fp;
}
async function readDoc(fp: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(fp, 'utf-8'));
}
function getRoot(doc: Record<string, unknown>): Record<string, unknown> {
const pages = doc['pages'] as Array<{ children?: Record<string, unknown>[] }> | undefined;
const top = doc['children'] as Record<string, unknown>[] | 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<string, unknown>[];
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<string, unknown>[];
// with icon: icon + value + label = 3 kids
const k0 = cells[0].children as Record<string, unknown>[];
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<string, unknown>[];
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<string, unknown>): void {
if (typeof n.id === 'string') ids.push(n.id);
if (Array.isArray(n.children))
(n.children as Record<string, unknown>[]).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);
});
});

View file

@ -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<string> {
const fp = join(TMP, name);
await writeFile(fp, EMPTY, 'utf-8');
return fp;
}
async function readDoc(fp: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(fp, 'utf-8'));
}
function getRoot(doc: Record<string, unknown>): Record<string, unknown> {
const pages = doc['pages'] as Array<{ children?: Record<string, unknown>[] }> | undefined;
const top = doc['children'] as Record<string, unknown>[] | 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<string, unknown>[];
expect(kids.length).toBe(3);
expect(kids[0].role).toBe('icon-button');
expect((kids[0].children as Record<string, unknown>[])[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<string, unknown>[])[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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>): void {
if (typeof n.id === 'string') ids.push(n.id);
if (Array.isArray(n.children))
(n.children as Record<string, unknown>[]).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);
});
});

View file

@ -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', () => {

View file

@ -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 '';
}

View file

@ -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<Awaited<ReturnType<typeof handleBatchDesign>>> {
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 });
}

View file

@ -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<Awaited<ReturnType<typeof handleBatchDesign>>> {
await ensureParentExists(params);
const children: Record<string, unknown>[] = [
{
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<string, unknown> {
const children: Record<string, unknown>[] = [
{
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,
};
}

View file

@ -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<Awaited<ReturnType<typeof handleBatchDesign>>> {
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<string, unknown> {
const children: Record<string, unknown>[] = [];
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,
};
}

View file

@ -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<Awaited<ReturnType<typeof handleBatchDesign>>> {
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<string, unknown> {
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,
},
],
};
}