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:
parent
bff94b2ada
commit
75467676b1
|
|
@ -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
|
||||
|
||||
|
|
|
|||
103
packages/pen-mcp/src/__tests__/add-icon-button-v0.test.ts
Normal file
103
packages/pen-mcp/src/__tests__/add-icon-button-v0.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
130
packages/pen-mcp/src/__tests__/add-section-header-v0.test.ts
Normal file
130
packages/pen-mcp/src/__tests__/add-section-header-v0.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
134
packages/pen-mcp/src/__tests__/add-stat-grid-v0.test.ts
Normal file
134
packages/pen-mcp/src/__tests__/add-stat-grid-v0.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
130
packages/pen-mcp/src/__tests__/add-top-nav-bar-v0.test.ts
Normal file
130
packages/pen-mcp/src/__tests__/add-top-nav-bar-v0.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
}
|
||||
|
|
|
|||
58
packages/pen-mcp/src/tools/add-icon-button-v0.ts
Normal file
58
packages/pen-mcp/src/tools/add-icon-button-v0.ts
Normal 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 });
|
||||
}
|
||||
97
packages/pen-mcp/src/tools/add-section-header-v0.ts
Normal file
97
packages/pen-mcp/src/tools/add-section-header-v0.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
102
packages/pen-mcp/src/tools/add-stat-grid-v0.ts
Normal file
102
packages/pen-mcp/src/tools/add-stat-grid-v0.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
101
packages/pen-mcp/src/tools/add-top-nav-bar-v0.ts
Normal file
101
packages/pen-mcp/src/tools/add-top-nav-bar-v0.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue