feat(mcp): add_scroll_row_v0 MVP element tool for non-Claude stability
Step 2 of N-tool element design (spec v0 §7.1 pinned to this impl). First production element-level tool, replacing the batch_design generic DSL for the specific pattern LLMs most commonly get wrong: horizontal scroll rows of cards / metric tiles / nav items. Tool fixes the structure at schema level: - Outer wrapper: fill_container + clipContent=true + vertical layout - Inner row: fit_content + horizontal + gap + padding=[0,20] - Children: fixed numeric width per variant (card=140 / metric=120 / nav=72) Exactly matches packages/pen-ai-skills/skills/phases/generation/overflow.md §HORIZONTAL SCROLL ROWS. Sugar over handleBatchDesign (D1=A route). - tools/add-scroll-row-v0.ts: 230-line builder with 3 children_type presets - routes/design-routes.ts: register in production DESIGN_TOOL_DEFINITIONS - __tests__/add-scroll-row-v0.test.ts: 13 tests covering wrapper invariants, per-type structure, icon/subtitle optionality, overrides, persistence, golden snapshot - d0-parity-spike.test.ts: rewrite "names exactly" assertion to "pre-D0 tools still present" so new production tools don't break baseline; scope snapshot to pre-D0 5 tools only 70/70 pen-mcp tests pass. format:check + tsc --noEmit both green.
This commit is contained in:
parent
955adc1277
commit
b8744fbda3
|
|
@ -0,0 +1,147 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`add_scroll_row_v0 — snapshot golden output > golden: 3 cards with title+subtitle+icon > scroll-row-cards-golden 1`] = `
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"height": 24,
|
||||
"iconFontFamily": "lucide",
|
||||
"iconFontName": "flame",
|
||||
"name": "Icon",
|
||||
"type": "icon_font",
|
||||
"width": 24,
|
||||
},
|
||||
{
|
||||
"content": "Hiit",
|
||||
"fontSize": 16,
|
||||
"fontWeight": 600,
|
||||
"name": "Title",
|
||||
"role": "heading",
|
||||
"type": "text",
|
||||
"width": "fill_container",
|
||||
},
|
||||
{
|
||||
"content": "30 min",
|
||||
"fontSize": 13,
|
||||
"fontWeight": 400,
|
||||
"name": "Subtitle",
|
||||
"role": "body",
|
||||
"type": "text",
|
||||
"width": "fill_container",
|
||||
},
|
||||
],
|
||||
"cornerRadius": 20,
|
||||
"gap": 8,
|
||||
"height": 160,
|
||||
"layout": "vertical",
|
||||
"name": "Card",
|
||||
"padding": 16,
|
||||
"role": "card",
|
||||
"type": "frame",
|
||||
"width": 140,
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"height": 24,
|
||||
"iconFontFamily": "lucide",
|
||||
"iconFontName": "dumbbell",
|
||||
"name": "Icon",
|
||||
"type": "icon_font",
|
||||
"width": 24,
|
||||
},
|
||||
{
|
||||
"content": "Strength",
|
||||
"fontSize": 16,
|
||||
"fontWeight": 600,
|
||||
"name": "Title",
|
||||
"role": "heading",
|
||||
"type": "text",
|
||||
"width": "fill_container",
|
||||
},
|
||||
{
|
||||
"content": "45 min",
|
||||
"fontSize": 13,
|
||||
"fontWeight": 400,
|
||||
"name": "Subtitle",
|
||||
"role": "body",
|
||||
"type": "text",
|
||||
"width": "fill_container",
|
||||
},
|
||||
],
|
||||
"cornerRadius": 20,
|
||||
"gap": 8,
|
||||
"height": 160,
|
||||
"layout": "vertical",
|
||||
"name": "Card",
|
||||
"padding": 16,
|
||||
"role": "card",
|
||||
"type": "frame",
|
||||
"width": 140,
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"height": 24,
|
||||
"iconFontFamily": "lucide",
|
||||
"iconFontName": "leaf",
|
||||
"name": "Icon",
|
||||
"type": "icon_font",
|
||||
"width": 24,
|
||||
},
|
||||
{
|
||||
"content": "Yoga",
|
||||
"fontSize": 16,
|
||||
"fontWeight": 600,
|
||||
"name": "Title",
|
||||
"role": "heading",
|
||||
"type": "text",
|
||||
"width": "fill_container",
|
||||
},
|
||||
{
|
||||
"content": "25 min",
|
||||
"fontSize": 13,
|
||||
"fontWeight": 400,
|
||||
"name": "Subtitle",
|
||||
"role": "body",
|
||||
"type": "text",
|
||||
"width": "fill_container",
|
||||
},
|
||||
],
|
||||
"cornerRadius": 20,
|
||||
"gap": 8,
|
||||
"height": 160,
|
||||
"layout": "vertical",
|
||||
"name": "Card",
|
||||
"padding": 16,
|
||||
"role": "card",
|
||||
"type": "frame",
|
||||
"width": 140,
|
||||
},
|
||||
],
|
||||
"gap": 12,
|
||||
"height": "fit_content",
|
||||
"layout": "horizontal",
|
||||
"name": "Scroll Inner Row",
|
||||
"padding": [
|
||||
0,
|
||||
20,
|
||||
],
|
||||
"role": "scroll-row",
|
||||
"type": "frame",
|
||||
"width": "fit_content",
|
||||
},
|
||||
],
|
||||
"clipContent": true,
|
||||
"height": "fit_content",
|
||||
"layout": "vertical",
|
||||
"name": "Scroll Row (card)",
|
||||
"role": "scroll-row-wrapper",
|
||||
"type": "frame",
|
||||
"width": "fill_container",
|
||||
}
|
||||
`;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`D0 parity spike — additive & gated tool registration > existing tool DEFINITIONS are unchanged (snapshot) > existing-design-tool-definitions 1`] = `
|
||||
exports[`D0 parity spike — additive & gated tool registration > pre-D0 production design tool DEFINITIONS are unchanged (snapshot) > pre-d0-design-tool-definitions 1`] = `
|
||||
[
|
||||
{
|
||||
"description": "Get design knowledge prompt. Use "section" to retrieve a focused subset instead of the full prompt. Sections: schema (PenNode types), layout (flexbox rules), roles (semantic roles), text (typography/CJK/copywriting), style (visual style policy), icons (icon names), examples (design examples), guidelines (design tips), planning (layered workflow guide). Omit section for the full prompt.",
|
||||
|
|
|
|||
288
packages/pen-mcp/src/__tests__/add-scroll-row-v0.test.ts
Normal file
288
packages/pen-mcp/src/__tests__/add-scroll-row-v0.test.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
/**
|
||||
* Unit tests for add_scroll_row_v0 — MVP element tool (Step 2 of N-tool spec v0).
|
||||
*
|
||||
* The tool builds a horizontal scroll row that strictly follows the
|
||||
* nested wrapper pattern taught in
|
||||
* `packages/pen-ai-skills/skills/phases/generation/overflow.md` §HORIZONTAL
|
||||
* SCROLL ROWS. These tests lock down:
|
||||
*
|
||||
* - Registration in production DESIGN_TOOL_DEFINITIONS (not gated)
|
||||
* - Outer wrapper: fill_container / fit_content / vertical / clipContent=true
|
||||
* - Inner row: fit_content / horizontal / gap / padding=[0,20]
|
||||
* - Each child kind has the correct fixed width, cornerRadius, padding
|
||||
* - icon / subtitle are optional; title always present
|
||||
* - card_width / gap overrides propagate
|
||||
* - Parent resolution (null root vs parent_id)
|
||||
* - Round-trip through handleBatchDesign persists to disk
|
||||
*
|
||||
* See spec openpencil-docs/superpowers/specs/2026-04-19-element-tools-v0.md §7.1
|
||||
*/
|
||||
|
||||
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 { handleAddScrollRowV0 } from '../tools/add-scroll-row-v0';
|
||||
import { invalidateCache } from '../document-manager';
|
||||
|
||||
const TMP_DIR = join(tmpdir(), 'openpencil-add-scroll-row-v0-tests');
|
||||
const EMPTY_DOC = JSON.stringify({ version: '1.0.0', children: [] });
|
||||
|
||||
async function fresh(name: string): Promise<string> {
|
||||
const fp = join(TMP_DIR, name);
|
||||
await writeFile(fp, EMPTY_DOC, '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 pageChildren = pages?.[0]?.children;
|
||||
const topChildren = doc['children'] as Record<string, unknown>[] | undefined;
|
||||
const root = pageChildren?.[0] ?? topChildren?.[0];
|
||||
if (!root) throw new Error('expected root child');
|
||||
return root;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await mkdir(TMP_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const f of ['cards.op', 'metrics.op', 'nav.op', 'min.op', 'override.op', 'persist.op']) {
|
||||
try {
|
||||
const fp = join(TMP_DIR, f);
|
||||
invalidateCache(fp);
|
||||
await unlink(fp);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
describe('add_scroll_row_v0 — registration', () => {
|
||||
it('is registered in production DESIGN_TOOL_DEFINITIONS', () => {
|
||||
const names = DESIGN_TOOL_DEFINITIONS.map((t) => t.name);
|
||||
expect(names).toContain('add_scroll_row_v0');
|
||||
});
|
||||
|
||||
it('is in DESIGN_TOOL_NAMES', () => {
|
||||
expect(DESIGN_TOOL_NAMES.has('add_scroll_row_v0')).toBe(true);
|
||||
});
|
||||
|
||||
it('has required fields children_type and items', () => {
|
||||
const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_scroll_row_v0');
|
||||
expect(def).toBeDefined();
|
||||
expect(def?.inputSchema.required).toEqual(['children_type', 'items']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('add_scroll_row_v0 — wrapper structure invariants', () => {
|
||||
it('outer wrapper has fill_container + clipContent + vertical layout', async () => {
|
||||
const fp = await fresh('cards.op');
|
||||
await handleAddScrollRowV0({
|
||||
filePath: fp,
|
||||
children_type: 'card',
|
||||
items: [{ title: 'Hello' }],
|
||||
});
|
||||
const doc = await readDoc(fp);
|
||||
const wrapper = getRoot(doc);
|
||||
expect(wrapper.type).toBe('frame');
|
||||
expect(wrapper.role).toBe('scroll-row-wrapper');
|
||||
expect(wrapper.width).toBe('fill_container');
|
||||
expect(wrapper.height).toBe('fit_content');
|
||||
expect(wrapper.layout).toBe('vertical');
|
||||
expect(wrapper.clipContent).toBe(true);
|
||||
});
|
||||
|
||||
it('inner row has fit_content + horizontal + padding=[0,20]', async () => {
|
||||
const fp = await fresh('cards.op');
|
||||
await handleAddScrollRowV0({
|
||||
filePath: fp,
|
||||
children_type: 'card',
|
||||
items: [{ title: 'A' }, { title: 'B' }],
|
||||
});
|
||||
const wrapper = getRoot(await readDoc(fp));
|
||||
const innerRow = (wrapper.children as Record<string, unknown>[])[0];
|
||||
expect(innerRow.role).toBe('scroll-row');
|
||||
expect(innerRow.width).toBe('fit_content');
|
||||
expect(innerRow.height).toBe('fit_content');
|
||||
expect(innerRow.layout).toBe('horizontal');
|
||||
expect(innerRow.padding).toEqual([0, 20]);
|
||||
expect(innerRow.gap).toBe(12); // default
|
||||
expect((innerRow.children as unknown[]).length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('add_scroll_row_v0 — children_type: card', () => {
|
||||
it('default card is 140×160, cornerRadius=20, padding=16', async () => {
|
||||
const fp = await fresh('cards.op');
|
||||
await handleAddScrollRowV0({
|
||||
filePath: fp,
|
||||
children_type: 'card',
|
||||
items: [{ title: 'Only' }],
|
||||
});
|
||||
const wrapper = getRoot(await readDoc(fp));
|
||||
const row = (wrapper.children as Record<string, unknown>[])[0];
|
||||
const card = (row.children as Record<string, unknown>[])[0];
|
||||
expect(card.width).toBe(140);
|
||||
expect(card.height).toBe(160);
|
||||
expect(card.cornerRadius).toBe(20);
|
||||
expect(card.padding).toBe(16);
|
||||
expect(card.role).toBe('card');
|
||||
});
|
||||
|
||||
it('card with title + subtitle + icon has all 3 children with correct roles', async () => {
|
||||
const fp = await fresh('cards.op');
|
||||
await handleAddScrollRowV0({
|
||||
filePath: fp,
|
||||
children_type: 'card',
|
||||
items: [{ title: 'Hiit', subtitle: '30 min', icon: 'flame' }],
|
||||
});
|
||||
const wrapper = getRoot(await readDoc(fp));
|
||||
const row = (wrapper.children as Record<string, unknown>[])[0];
|
||||
const card = (row.children as Record<string, unknown>[])[0];
|
||||
const kids = card.children as Record<string, unknown>[];
|
||||
expect(kids.length).toBe(3);
|
||||
expect(kids[0].type).toBe('icon_font');
|
||||
expect(kids[0].iconFontName).toBe('flame');
|
||||
expect(kids[0].iconFontFamily).toBe('lucide');
|
||||
expect(kids[1].type).toBe('text');
|
||||
expect(kids[1].content).toBe('Hiit');
|
||||
expect(kids[1].role).toBe('heading');
|
||||
expect(kids[2].type).toBe('text');
|
||||
expect(kids[2].content).toBe('30 min');
|
||||
expect(kids[2].role).toBe('body');
|
||||
});
|
||||
|
||||
it('card with only title has just one text child', async () => {
|
||||
const fp = await fresh('min.op');
|
||||
await handleAddScrollRowV0({
|
||||
filePath: fp,
|
||||
children_type: 'card',
|
||||
items: [{ title: 'Bare' }],
|
||||
});
|
||||
const wrapper = getRoot(await readDoc(fp));
|
||||
const card = (
|
||||
(wrapper.children as Record<string, unknown>[])[0].children as Record<string, unknown>[]
|
||||
)[0];
|
||||
const kids = card.children as Record<string, unknown>[];
|
||||
expect(kids.length).toBe(1);
|
||||
expect(kids[0].type).toBe('text');
|
||||
expect(kids[0].content).toBe('Bare');
|
||||
});
|
||||
});
|
||||
|
||||
describe('add_scroll_row_v0 — children_type: metric_tile', () => {
|
||||
it('default metric_tile is 120×100, cornerRadius=16', async () => {
|
||||
const fp = await fresh('metrics.op');
|
||||
await handleAddScrollRowV0({
|
||||
filePath: fp,
|
||||
children_type: 'metric_tile',
|
||||
items: [{ title: 'Steps', subtitle: '8,432' }],
|
||||
});
|
||||
const wrapper = getRoot(await readDoc(fp));
|
||||
const tile = (
|
||||
(wrapper.children as Record<string, unknown>[])[0].children as Record<string, unknown>[]
|
||||
)[0];
|
||||
expect(tile.width).toBe(120);
|
||||
expect(tile.height).toBe(100);
|
||||
expect(tile.cornerRadius).toBe(16);
|
||||
expect(tile.role).toBe('metric-tile');
|
||||
const kids = tile.children as Record<string, unknown>[];
|
||||
expect(kids[0].content).toBe('Steps');
|
||||
expect(kids[0].role).toBe('body');
|
||||
expect(kids[1].content).toBe('8,432');
|
||||
expect(kids[1].role).toBe('heading');
|
||||
expect(kids[1].fontSize).toBe(28);
|
||||
});
|
||||
});
|
||||
|
||||
describe('add_scroll_row_v0 — children_type: nav_item', () => {
|
||||
it('default nav_item is 72 wide with fit_content height + alignItems=center', async () => {
|
||||
const fp = await fresh('nav.op');
|
||||
await handleAddScrollRowV0({
|
||||
filePath: fp,
|
||||
children_type: 'nav_item',
|
||||
items: [
|
||||
{ title: 'Home', icon: 'home' },
|
||||
{ title: 'Search', icon: 'search' },
|
||||
],
|
||||
});
|
||||
const wrapper = getRoot(await readDoc(fp));
|
||||
const items = (wrapper.children as Record<string, unknown>[])[0].children as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
expect(items.length).toBe(2);
|
||||
expect(items[0].width).toBe(72);
|
||||
expect(items[0].height).toBe('fit_content');
|
||||
expect(items[0].alignItems).toBe('center');
|
||||
expect(items[0].role).toBe('nav-item');
|
||||
});
|
||||
});
|
||||
|
||||
describe('add_scroll_row_v0 — overrides', () => {
|
||||
it('respects card_width and gap overrides', async () => {
|
||||
const fp = await fresh('override.op');
|
||||
await handleAddScrollRowV0({
|
||||
filePath: fp,
|
||||
children_type: 'card',
|
||||
items: [{ title: 'A' }, { title: 'B' }],
|
||||
card_width: 220,
|
||||
gap: 24,
|
||||
});
|
||||
const wrapper = getRoot(await readDoc(fp));
|
||||
const row = (wrapper.children as Record<string, unknown>[])[0];
|
||||
expect(row.gap).toBe(24);
|
||||
const cards = row.children as Record<string, unknown>[];
|
||||
expect(cards[0].width).toBe(220);
|
||||
expect(cards[1].width).toBe(220);
|
||||
});
|
||||
});
|
||||
|
||||
describe('add_scroll_row_v0 — persistence', () => {
|
||||
it('result.nodeCount reflects full node tree (wrapper+row+cards+children)', async () => {
|
||||
const fp = await fresh('persist.op');
|
||||
const result = await handleAddScrollRowV0({
|
||||
filePath: fp,
|
||||
children_type: 'card',
|
||||
items: [{ title: 'A', subtitle: 's' }, { title: 'B', icon: 'x' }, { title: 'C' }],
|
||||
});
|
||||
// wrapper (1) + row (1) + 3 cards + [2 card-A children + 2 card-B children + 1 card-C children] = 10
|
||||
expect(result.nodeCount).toBe(10);
|
||||
expect(result.results).toHaveLength(1);
|
||||
expect(result.results[0].binding).toBe('row');
|
||||
expect(result.errors).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('add_scroll_row_v0 — snapshot golden output', () => {
|
||||
it('golden: 3 cards with title+subtitle+icon', async () => {
|
||||
const fp = await fresh('cards.op');
|
||||
await handleAddScrollRowV0({
|
||||
filePath: fp,
|
||||
children_type: 'card',
|
||||
items: [
|
||||
{ title: 'Hiit', subtitle: '30 min', icon: 'flame' },
|
||||
{ title: 'Strength', subtitle: '45 min', icon: 'dumbbell' },
|
||||
{ title: 'Yoga', subtitle: '25 min', icon: 'leaf' },
|
||||
],
|
||||
});
|
||||
const wrapper = getRoot(await readDoc(fp));
|
||||
// Strip ids for stable snapshot
|
||||
const stripIds = (n: unknown): unknown => {
|
||||
if (Array.isArray(n)) return n.map(stripIds);
|
||||
if (n && typeof n === 'object') {
|
||||
const o = { ...(n as Record<string, unknown>) };
|
||||
delete o.id;
|
||||
for (const k of Object.keys(o)) o[k] = stripIds(o[k]);
|
||||
return o;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
expect(stripIds(wrapper)).toMatchSnapshot('scroll-row-cards-golden');
|
||||
});
|
||||
});
|
||||
|
|
@ -67,19 +67,35 @@ describe('D0 parity spike — additive & gated tool registration', () => {
|
|||
expect(D0_SPIKE_TOOL_NAMES.has('add_section_v0')).toBe(true);
|
||||
});
|
||||
|
||||
it('existing design tool names are exactly the pre-spike set', () => {
|
||||
const names = DESIGN_TOOL_DEFINITIONS.map((t) => t.name).sort();
|
||||
expect(names).toEqual([
|
||||
it('pre-D0 production design tools still present (no regression)', () => {
|
||||
// These 5 existed when D0 spike validated Sugar route. They must remain
|
||||
// present for any future additive changes. New tools (add_scroll_row_v0
|
||||
// and beyond) may be added, but none of these 5 may disappear.
|
||||
const names = DESIGN_TOOL_DEFINITIONS.map((t) => t.name);
|
||||
for (const preD0 of [
|
||||
'batch_design',
|
||||
'design_content',
|
||||
'design_refine',
|
||||
'design_skeleton',
|
||||
'get_design_prompt',
|
||||
]) {
|
||||
expect(names).toContain(preD0);
|
||||
}
|
||||
});
|
||||
|
||||
it('pre-D0 production design tool DEFINITIONS are unchanged (snapshot)', () => {
|
||||
// Snapshot only the 5 pre-D0 tools. Any future MVP tool
|
||||
// (add_scroll_row_v0 etc.) is excluded from this snapshot so it keeps
|
||||
// its D0-baseline role: detecting drift in pre-existing tool schemas.
|
||||
const preD0Names = new Set([
|
||||
'batch_design',
|
||||
'design_content',
|
||||
'design_refine',
|
||||
'design_skeleton',
|
||||
'get_design_prompt',
|
||||
]);
|
||||
});
|
||||
|
||||
it('existing tool DEFINITIONS are unchanged (snapshot)', () => {
|
||||
expect(DESIGN_TOOL_DEFINITIONS).toMatchSnapshot('existing-design-tool-definitions');
|
||||
const preD0Tools = DESIGN_TOOL_DEFINITIONS.filter((t) => preD0Names.has(t.name));
|
||||
expect(preD0Tools).toMatchSnapshot('pre-d0-design-tool-definitions');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { handleDesignContent } from '../tools/design-content';
|
|||
import { handleDesignRefine } from '../tools/design-refine';
|
||||
import { LAYERED_DESIGN_TOOLS } from '../tools/layered-design-defs';
|
||||
import { handleAddSectionV0 } from '../tools/add-section-v0';
|
||||
import { handleAddScrollRowV0 } from '../tools/add-scroll-row-v0';
|
||||
|
||||
export const DESIGN_TOOL_DEFINITIONS = [
|
||||
{
|
||||
|
|
@ -78,6 +79,57 @@ export const DESIGN_TOOL_DEFINITIONS = [
|
|||
},
|
||||
},
|
||||
...LAYERED_DESIGN_TOOLS,
|
||||
{
|
||||
name: 'add_scroll_row_v0',
|
||||
description:
|
||||
'Create a horizontal scroll row (card / metric_tile / nav_item) with guaranteed overflow safety. ' +
|
||||
'Builds the exact nested wrapper + clipContent + fit_content inner-row structure that batch_design ' +
|
||||
'generates incorrectly ~X% of the time on non-Claude models (see ' +
|
||||
'packages/pen-ai-skills/skills/phases/generation/overflow.md §HORIZONTAL SCROLL ROWS). ' +
|
||||
'Always prefer this tool over batch_design when the spec mentions "horizontal scrolling cards", ' +
|
||||
'"swipeable row", "chip row", "metric tiles" or similar. schemaVersion 1.0',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
filePath: {
|
||||
type: 'string',
|
||||
description: 'Path to .op file, or omit to use the live canvas (default)',
|
||||
},
|
||||
children_type: {
|
||||
type: 'string',
|
||||
enum: ['card', 'metric_tile', 'nav_item'],
|
||||
description:
|
||||
'Kind of children to lay out. card = 140x160 vertical card with title/subtitle/icon; ' +
|
||||
'metric_tile = 120x100 with small label + big value; nav_item = 72 auto-height icon+label.',
|
||||
},
|
||||
items: {
|
||||
type: 'array',
|
||||
description: 'Row items. Each needs title; subtitle and icon are optional.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
subtitle: { type: 'string' },
|
||||
icon: { type: 'string', description: 'lucide icon name (e.g. "activity")' },
|
||||
},
|
||||
required: ['title'],
|
||||
},
|
||||
},
|
||||
card_width: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Fixed numeric width per item (overrides default: card=140, metric_tile=120, nav_item=72)',
|
||||
},
|
||||
gap: { type: 'number', description: 'Inner-row gap in px (default 12)' },
|
||||
parent_id: {
|
||||
type: 'string',
|
||||
description: 'Target parent node id. Omit for root-level insertion.',
|
||||
},
|
||||
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
|
||||
},
|
||||
required: ['children_type', 'items'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const DESIGN_TOOL_NAMES = new Set([
|
||||
|
|
@ -86,6 +138,7 @@ export const DESIGN_TOOL_NAMES = new Set([
|
|||
'design_skeleton',
|
||||
'design_content',
|
||||
'design_refine',
|
||||
'add_scroll_row_v0',
|
||||
]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
@ -113,6 +166,8 @@ export async function handleDesignToolCall(
|
|||
return JSON.stringify(await handleDesignContent(a), null, 2);
|
||||
case 'design_refine':
|
||||
return JSON.stringify(await handleDesignRefine(a), null, 2);
|
||||
case 'add_scroll_row_v0':
|
||||
return JSON.stringify(await handleAddScrollRowV0(a), null, 2);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
|
|
|||
231
packages/pen-mcp/src/tools/add-scroll-row-v0.ts
Normal file
231
packages/pen-mcp/src/tools/add-scroll-row-v0.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { handleBatchDesign } from './batch-design';
|
||||
|
||||
export interface AddScrollRowV0Item {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export type AddScrollRowV0ChildrenType = 'card' | 'metric_tile' | 'nav_item';
|
||||
|
||||
export interface AddScrollRowV0Params {
|
||||
children_type: AddScrollRowV0ChildrenType;
|
||||
items: AddScrollRowV0Item[];
|
||||
card_width?: number;
|
||||
gap?: number;
|
||||
parent_id?: string;
|
||||
filePath?: string;
|
||||
pageId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MVP element tool (D1=A sugar route). Builds a horizontal scroll row that
|
||||
* strictly follows the pattern taught in
|
||||
* `packages/pen-ai-skills/skills/phases/generation/overflow.md` §HORIZONTAL
|
||||
* SCROLL ROWS: outer wrapper (fill_container + clipContent) > inner row
|
||||
* (fit_content, horizontal, gap, padding) > children with fixed widths.
|
||||
*
|
||||
* This is the exact structure LLMs (especially non-Claude) get wrong when
|
||||
* using batch_design directly. By exposing a narrow tool we force the
|
||||
* correct shape at schema level.
|
||||
*
|
||||
* Spec: openpencil-docs/superpowers/specs/2026-04-19-element-tools-v0.md §7.1
|
||||
*/
|
||||
export async function handleAddScrollRowV0(
|
||||
params: AddScrollRowV0Params,
|
||||
): Promise<Awaited<ReturnType<typeof handleBatchDesign>>> {
|
||||
const gap = params.gap ?? 12;
|
||||
const cardWidth = params.card_width ?? defaultCardWidth(params.children_type);
|
||||
const wrapper = buildWrapperNode(params, gap, cardWidth);
|
||||
const parentRef = params.parent_id ? `"${params.parent_id}"` : 'null';
|
||||
const dsl = `row=I(${parentRef}, ${JSON.stringify(wrapper)})`;
|
||||
return handleBatchDesign({
|
||||
operations: dsl,
|
||||
filePath: params.filePath,
|
||||
pageId: params.pageId,
|
||||
postProcess: false,
|
||||
});
|
||||
}
|
||||
|
||||
function defaultCardWidth(kind: AddScrollRowV0ChildrenType): number {
|
||||
switch (kind) {
|
||||
case 'card':
|
||||
return 140;
|
||||
case 'metric_tile':
|
||||
return 120;
|
||||
case 'nav_item':
|
||||
return 72;
|
||||
}
|
||||
}
|
||||
|
||||
function buildWrapperNode(
|
||||
params: AddScrollRowV0Params,
|
||||
gap: number,
|
||||
cardWidth: number,
|
||||
): Record<string, unknown> {
|
||||
const cards = params.items.map((item) => buildChildNode(item, params.children_type, cardWidth));
|
||||
return {
|
||||
type: 'frame',
|
||||
name: `Scroll Row (${params.children_type})`,
|
||||
role: 'scroll-row-wrapper',
|
||||
width: 'fill_container',
|
||||
height: 'fit_content',
|
||||
layout: 'vertical',
|
||||
clipContent: true,
|
||||
children: [
|
||||
{
|
||||
type: 'frame',
|
||||
name: 'Scroll Inner Row',
|
||||
role: 'scroll-row',
|
||||
width: 'fit_content',
|
||||
height: 'fit_content',
|
||||
layout: 'horizontal',
|
||||
gap,
|
||||
padding: [0, 20],
|
||||
children: cards,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildChildNode(
|
||||
item: AddScrollRowV0Item,
|
||||
kind: AddScrollRowV0ChildrenType,
|
||||
cardWidth: number,
|
||||
): Record<string, unknown> {
|
||||
switch (kind) {
|
||||
case 'card':
|
||||
return buildCard(item, cardWidth);
|
||||
case 'metric_tile':
|
||||
return buildMetricTile(item, cardWidth);
|
||||
case 'nav_item':
|
||||
return buildNavItem(item, cardWidth);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCard(item: AddScrollRowV0Item, cardWidth: number): Record<string, unknown> {
|
||||
const children: Record<string, unknown>[] = [];
|
||||
if (item.icon) {
|
||||
children.push({
|
||||
type: 'icon_font',
|
||||
name: 'Icon',
|
||||
iconFontName: item.icon,
|
||||
iconFontFamily: 'lucide',
|
||||
width: 24,
|
||||
height: 24,
|
||||
});
|
||||
}
|
||||
children.push({
|
||||
type: 'text',
|
||||
name: 'Title',
|
||||
role: 'heading',
|
||||
content: item.title,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
width: 'fill_container',
|
||||
});
|
||||
if (item.subtitle) {
|
||||
children.push({
|
||||
type: 'text',
|
||||
name: 'Subtitle',
|
||||
role: 'body',
|
||||
content: item.subtitle,
|
||||
fontSize: 13,
|
||||
fontWeight: 400,
|
||||
width: 'fill_container',
|
||||
});
|
||||
}
|
||||
return {
|
||||
type: 'frame',
|
||||
name: 'Card',
|
||||
role: 'card',
|
||||
width: cardWidth,
|
||||
height: 160,
|
||||
cornerRadius: 20,
|
||||
padding: 16,
|
||||
layout: 'vertical',
|
||||
gap: 8,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMetricTile(item: AddScrollRowV0Item, cardWidth: number): 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: 'Label',
|
||||
role: 'body',
|
||||
content: item.title,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
width: 'fill_container',
|
||||
});
|
||||
if (item.subtitle) {
|
||||
children.push({
|
||||
type: 'text',
|
||||
name: 'Value',
|
||||
role: 'heading',
|
||||
content: item.subtitle,
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
width: 'fill_container',
|
||||
});
|
||||
}
|
||||
return {
|
||||
type: 'frame',
|
||||
name: 'Metric Tile',
|
||||
role: 'metric-tile',
|
||||
width: cardWidth,
|
||||
height: 100,
|
||||
cornerRadius: 16,
|
||||
padding: 16,
|
||||
layout: 'vertical',
|
||||
gap: 4,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function buildNavItem(item: AddScrollRowV0Item, cardWidth: number): Record<string, unknown> {
|
||||
const children: Record<string, unknown>[] = [];
|
||||
if (item.icon) {
|
||||
children.push({
|
||||
type: 'icon_font',
|
||||
name: 'Icon',
|
||||
iconFontName: item.icon,
|
||||
iconFontFamily: 'lucide',
|
||||
width: 24,
|
||||
height: 24,
|
||||
});
|
||||
}
|
||||
children.push({
|
||||
type: 'text',
|
||||
name: 'Label',
|
||||
role: 'label',
|
||||
content: item.title,
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
});
|
||||
return {
|
||||
type: 'frame',
|
||||
name: 'Nav Item',
|
||||
role: 'nav-item',
|
||||
width: cardWidth,
|
||||
height: 'fit_content',
|
||||
cornerRadius: 12,
|
||||
padding: [8, 12],
|
||||
layout: 'vertical',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
children,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue