diff --git a/apps/web/server/api/mcp/exec-tool.post.ts b/apps/web/server/api/mcp/exec-tool.post.ts index a28a5dae9..ab07b04fa 100644 --- a/apps/web/server/api/mcp/exec-tool.post.ts +++ b/apps/web/server/api/mcp/exec-tool.post.ts @@ -35,6 +35,7 @@ import { buildSearchBar, buildSectionHeader, buildSegmentedControl, + buildSkeleton, buildStatGrid, buildStepper, buildSwitch, @@ -131,6 +132,7 @@ const SERVER_BUILDERS: Record = { add_stepper_v0: (a) => buildStepper(a as Parameters[0]), add_form_field_v0: (a) => buildFormField(a as Parameters[0]), add_textarea_v0: (a) => buildTextarea(a as Parameters[0]), + add_skeleton_v0: (a) => buildSkeleton(a as Parameters[0]), add_nav_chip_row_v0: (a) => buildNavChipRow(a as Parameters[0]), add_activity_ring_v0: (a) => buildActivityRing(a as Parameters[0]), add_rating_stars_v0: (a) => buildRatingStars(a as Parameters[0]), diff --git a/apps/web/src/services/ai/__tests__/anti-patterns-builder-clean.test.ts b/apps/web/src/services/ai/__tests__/anti-patterns-builder-clean.test.ts index d71555e2c..2aa48b870 100644 --- a/apps/web/src/services/ai/__tests__/anti-patterns-builder-clean.test.ts +++ b/apps/web/src/services/ai/__tests__/anti-patterns-builder-clean.test.ts @@ -44,6 +44,7 @@ import { buildSearchBar, buildSectionHeader, buildSegmentedControl, + buildSkeleton, buildStatGrid, buildStepper, buildSwitch, @@ -172,6 +173,7 @@ const CASES: BuilderCase[] = [ { name: 'search-bar', build: () => buildSearchBar({}) }, { name: 'form-field', build: () => buildFormField({ label: 'Email' }) }, { name: 'textarea', build: () => buildTextarea({ label: 'Bio', rows: 5 }) }, + { name: 'skeleton', build: () => buildSkeleton({ rows: 3 }) }, { name: 'chart-bars', build: () => buildChartBars({ values: [4, 7, 3, 9, 5] }) }, { name: 'empty-state', diff --git a/apps/web/src/services/ai/__tests__/detectors-builder-clean.test.ts b/apps/web/src/services/ai/__tests__/detectors-builder-clean.test.ts index b033b45a7..a0828472c 100644 --- a/apps/web/src/services/ai/__tests__/detectors-builder-clean.test.ts +++ b/apps/web/src/services/ai/__tests__/detectors-builder-clean.test.ts @@ -36,6 +36,7 @@ import { buildSearchBar, buildSectionHeader, buildSegmentedControl, + buildSkeleton, buildStatGrid, buildStepper, buildSwitch, @@ -166,6 +167,7 @@ const CASES: BuilderCase[] = [ { name: 'search-bar', build: () => buildSearchBar({}) }, { name: 'form-field', build: () => buildFormField({ label: 'Email' }) }, { name: 'textarea', build: () => buildTextarea({ label: 'Bio', rows: 5 }) }, + { name: 'skeleton', build: () => buildSkeleton({ rows: 3 }) }, { name: 'chart-bars', build: () => buildChartBars({ values: [4, 7, 3, 9, 5] }) }, { name: 'empty-state', diff --git a/apps/web/src/services/ai/__tests__/role-resolver-builder-coverage.test.ts b/apps/web/src/services/ai/__tests__/role-resolver-builder-coverage.test.ts index 9259bebd2..d28ae0ed7 100644 --- a/apps/web/src/services/ai/__tests__/role-resolver-builder-coverage.test.ts +++ b/apps/web/src/services/ai/__tests__/role-resolver-builder-coverage.test.ts @@ -45,6 +45,7 @@ import { buildSearchBar, buildSectionHeader, buildSegmentedControl, + buildSkeleton, buildStatGrid, buildStepper, buildSwitch, @@ -189,6 +190,7 @@ const CASES: BuilderCase[] = [ { name: 'search-bar', build: () => buildSearchBar({}) }, { name: 'form-field', build: () => buildFormField({ label: 'Email' }) }, { name: 'textarea', build: () => buildTextarea({ label: 'Bio', rows: 5 }) }, + { name: 'skeleton', build: () => buildSkeleton({ rows: 3 }) }, { name: 'chart-bars', build: () => buildChartBars({ values: [4, 7, 3, 9, 5] }) }, { name: 'empty-state', diff --git a/apps/web/src/services/ai/__tests__/shim-server-parity.test.ts b/apps/web/src/services/ai/__tests__/shim-server-parity.test.ts index 08ff811b5..3da0fb624 100644 --- a/apps/web/src/services/ai/__tests__/shim-server-parity.test.ts +++ b/apps/web/src/services/ai/__tests__/shim-server-parity.test.ts @@ -42,6 +42,7 @@ import { buildSearchBar, buildSectionHeader, buildSegmentedControl, + buildSkeleton, buildStatGrid, buildStepper, buildSwitch, @@ -242,6 +243,11 @@ const CASES: BuilderCase[] = [ args: { label: 'Bio', placeholder: 'Tell us about yourself', rows: 5 }, build: (a) => buildTextarea(a as unknown as Parameters[0]), }, + { + toolName: 'add_skeleton_v0', + args: { rows: 3 }, + build: (a) => buildSkeleton(a as unknown as Parameters[0]), + }, { toolName: 'add_form_field_v0', args: { label: 'Email' }, diff --git a/apps/web/src/services/ai/element-tool-shims/index.ts b/apps/web/src/services/ai/element-tool-shims/index.ts index 1ba2c4aaf..d5820dba1 100644 --- a/apps/web/src/services/ai/element-tool-shims/index.ts +++ b/apps/web/src/services/ai/element-tool-shims/index.ts @@ -53,6 +53,7 @@ import { buildSearchBar, buildSectionHeader, buildSegmentedControl, + buildSkeleton, buildStatGrid, buildStepper, buildSwitch, @@ -97,6 +98,7 @@ import { type SearchBarParams, type SectionHeaderParams, type SegmentedControlParams, + type SkeletonParams, type StatGridParams, type StepperParams, type SwitchParams, @@ -226,6 +228,7 @@ export const ELEMENT_SHIMS: Record = { add_stepper_v0: wrap(buildStepper), add_form_field_v0: wrap(buildFormField), add_textarea_v0: wrap(buildTextarea), + add_skeleton_v0: wrap(buildSkeleton), add_nav_chip_row_v0: wrap(buildNavChipRow), add_activity_ring_v0: wrap(buildActivityRing), add_rating_stars_v0: wrap(buildRatingStars), diff --git a/packages/pen-ai-skills/skills/phases/generation/elements.md b/packages/pen-ai-skills/skills/phases/generation/elements.md index d38e49b49..0e04461fa 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements.md @@ -122,7 +122,11 @@ Calendars: 42. Month calendar grid (weekday header + 7-col day rows, today/selected tint) → `add_calendar_grid_v0` -43. None match → fall through to `batch_design` +Loading / placeholder: + +43. Loading skeleton (N gray rectangles, last row ~60% width) → `add_skeleton_v0` + +44. 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. @@ -150,6 +154,7 @@ PREFER an element tool when the spec says any of: - "search bar", "search input", "filter search", "搜索栏" → `add_search_bar_v0` - "form field", "email input", "password field", "labeled input", "required field" → `add_form_field_v0` - "textarea", "multi-line input", "notes field", "description box", "bio input", "feedback box", "多行输入", "备注" → `add_textarea_v0` +- "skeleton", "loading placeholder", "shimmer", "loading state", "placeholder lines", "骨架屏", "加载中占位" → `add_skeleton_v0` - "toggle", "switch", "on/off", "开关" → `add_switch_v0` - "checkbox", "agreement", "select option", "复选框" → `add_checkbox_v0` - "radio", "single choice", "单选" → `add_radio_v0` (stack multiple in a vertical parent) @@ -277,6 +282,8 @@ add_form_field_v0({ label: "Email", placeholder: "you@example.com", leading_icon add_form_field_v0({ label: "Password", leading_icon: "lock", trailing_icon: "eye", required: true }) add_textarea_v0({ label: "Bio", placeholder: "Tell us about yourself", rows: 5 }) add_textarea_v0({ label: "Feedback", rows: 4, required: true }) +add_skeleton_v0({}) // default 3 rows, last short +add_skeleton_v0({ rows: 5, row_height: 20, row_gap: 8 }) add_switch_v0({}) // off (default) add_switch_v0({ active: true }) // on — iOS green diff --git a/packages/pen-core/src/__tests__/element-builders-layout.test.ts b/packages/pen-core/src/__tests__/element-builders-layout.test.ts index c379760e3..961d75972 100644 --- a/packages/pen-core/src/__tests__/element-builders-layout.test.ts +++ b/packages/pen-core/src/__tests__/element-builders-layout.test.ts @@ -38,6 +38,7 @@ import { buildSearchBar, buildSectionHeader, buildSegmentedControl, + buildSkeleton, buildStatGrid, buildStepper, buildSwitch, @@ -177,6 +178,7 @@ const CASES: LayoutCase[] = [ { name: 'buildSearchBar', tree: () => buildSearchBar({}), wrap: true }, { name: 'buildFormField', tree: () => buildFormField({ label: 'Email' }), wrap: true }, { name: 'buildTextarea', tree: () => buildTextarea({ label: 'Bio', rows: 5 }), wrap: true }, + { name: 'buildSkeleton', tree: () => buildSkeleton({ rows: 3 }), wrap: true }, { name: 'buildChartBars', tree: () => buildChartBars({ values: [4, 7, 3, 9, 5] }) }, // Composites diff --git a/packages/pen-core/src/__tests__/element-builders-normalize-preservation.test.ts b/packages/pen-core/src/__tests__/element-builders-normalize-preservation.test.ts index ca3310412..126c6c6e5 100644 --- a/packages/pen-core/src/__tests__/element-builders-normalize-preservation.test.ts +++ b/packages/pen-core/src/__tests__/element-builders-normalize-preservation.test.ts @@ -36,6 +36,7 @@ import { buildSearchBar, buildSectionHeader, buildSegmentedControl, + buildSkeleton, buildStatGrid, buildStepper, buildSwitch, @@ -168,6 +169,7 @@ const CASES: BuilderCase[] = [ { name: 'search-bar', build: () => buildSearchBar({}) }, { name: 'form-field', build: () => buildFormField({ label: 'Email' }) }, { name: 'textarea', build: () => buildTextarea({ label: 'Bio', rows: 5 }) }, + { name: 'skeleton', build: () => buildSkeleton({ rows: 3 }) }, { name: 'chart-bars', build: () => buildChartBars({ values: [4, 7, 3, 9, 5] }) }, { name: 'empty-state', diff --git a/packages/pen-core/src/__tests__/element-builders-performance.test.ts b/packages/pen-core/src/__tests__/element-builders-performance.test.ts index 98891086d..73cd631ff 100644 --- a/packages/pen-core/src/__tests__/element-builders-performance.test.ts +++ b/packages/pen-core/src/__tests__/element-builders-performance.test.ts @@ -34,6 +34,7 @@ import { buildSearchBar, buildSectionHeader, buildSegmentedControl, + buildSkeleton, buildStatGrid, buildStepper, buildSwitch, @@ -148,6 +149,7 @@ const CASES: PerfCase[] = [ { name: 'search-bar', build: () => buildSearchBar({}) }, { name: 'form-field', build: () => buildFormField({ label: 'Email' }) }, { name: 'textarea', build: () => buildTextarea({ label: 'Bio', rows: 5 }) }, + { name: 'skeleton', build: () => buildSkeleton({ rows: 3 }) }, { name: 'chart-bars', build: () => buildChartBars({ values: [4, 7, 3, 9, 5, 8] }) }, { name: 'empty-state', diff --git a/packages/pen-core/src/__tests__/element-builders-post-process-idempotent.test.ts b/packages/pen-core/src/__tests__/element-builders-post-process-idempotent.test.ts index 03e9a7a13..25f9b5e7c 100644 --- a/packages/pen-core/src/__tests__/element-builders-post-process-idempotent.test.ts +++ b/packages/pen-core/src/__tests__/element-builders-post-process-idempotent.test.ts @@ -36,6 +36,7 @@ import { buildSearchBar, buildSectionHeader, buildSegmentedControl, + buildSkeleton, buildStatGrid, buildStepper, buildSwitch, @@ -172,6 +173,7 @@ const CASES: BuilderCase[] = [ { name: 'search-bar', build: () => buildSearchBar({}) }, { name: 'form-field', build: () => buildFormField({ label: 'Email' }) }, { name: 'textarea', build: () => buildTextarea({ label: 'Bio', rows: 5 }) }, + { name: 'skeleton', build: () => buildSkeleton({ rows: 3 }) }, { name: 'chart-bars', build: () => buildChartBars({ values: [4, 7, 3, 9, 5] }) }, { name: 'empty-state', diff --git a/packages/pen-core/src/element-builders/index.ts b/packages/pen-core/src/element-builders/index.ts index a7e3cbf99..be71d0370 100644 --- a/packages/pen-core/src/element-builders/index.ts +++ b/packages/pen-core/src/element-builders/index.ts @@ -38,6 +38,7 @@ export { buildBreadcrumb, type BreadcrumbItem, type BreadcrumbParams } from './b export { buildStepper, type StepperParams } from './stepper.js'; export { buildFormField, type FormFieldParams } from './form-field.js'; export { buildTextarea, type TextareaParams } from './textarea.js'; +export { buildSkeleton, type SkeletonParams } from './skeleton.js'; export { buildNavChipRow, type NavChipRowItem, type NavChipRowParams } from './nav-chip-row.js'; export { buildActivityRing, type ActivityRingParams } from './activity-ring.js'; export { buildRatingStars, type RatingStarsParams } from './rating-stars.js'; diff --git a/packages/pen-core/src/element-builders/skeleton.ts b/packages/pen-core/src/element-builders/skeleton.ts new file mode 100644 index 000000000..8cc78742a --- /dev/null +++ b/packages/pen-core/src/element-builders/skeleton.ts @@ -0,0 +1,74 @@ +import type { ElementTree } from './helpers.js'; + +export interface SkeletonParams { + /** Number of skeleton rows to emit (clamped 1..20). Default 3. */ + rows?: number; + /** Height per row in px (clamped 4..48). Default 16. */ + row_height?: number; + /** Gap between rows in px (clamped 0..32). Default 12. */ + row_gap?: number; + /** + * When true, the LAST row is 60% width (simulates an unfinished + * paragraph line — the classic "shimmer" pattern). Default true. + */ + last_row_short?: boolean; +} + +/** + * Loading skeleton: N stacked gray rectangles mimicking text lines + * while content fetches. Each row is `rectangle + fill_container + + * height` — avoids the frame-with-fill route so post-processing + * won't treat it as a card. cornerRadius=4 (subtle rounding, matches + * typical shimmer libraries). + * + * Last row defaults to 60% width (`fit_content` + px width) to + * suggest a paragraph that ends mid-line — more organic than a + * grid of uniform bars. + */ +export function buildSkeleton(params: SkeletonParams): ElementTree { + const rows = Math.max(1, Math.min(20, params.rows ?? 3)); + const rowHeight = Math.max(4, Math.min(48, params.row_height ?? 16)); + const rowGap = Math.max(0, Math.min(32, params.row_gap ?? 12)); + const lastShort = params.last_row_short ?? true; + + const children: ElementTree[] = []; + for (let i = 0; i < rows; i++) { + const isLast = i === rows - 1; + if (isLast && lastShort && rows > 1) { + // Short last row: fixed-px width wrapped in a container that + // pushes it to start. Using a 60%-suggestive hard px here + // would vary by canvas; wrap in a fill_container row and + // let the rectangle take ~60% via fit_content + width=60%-of- + // typical-mobile (220px ≈ 60% of 375 mobile frame). + children.push({ + type: 'rectangle', + role: 'skeleton-row', + width: 220, + height: rowHeight, + cornerRadius: 4, + fill: [{ type: 'solid', color: '#E2E8F0' }], + }); + } else { + children.push({ + type: 'rectangle', + role: 'skeleton-row', + width: 'fill_container', + height: rowHeight, + cornerRadius: 4, + fill: [{ type: 'solid', color: '#E2E8F0' }], + }); + } + } + + return { + type: 'frame', + name: 'Skeleton', + role: 'skeleton', + width: 'fill_container', + height: 'fit_content', + layout: 'vertical', + gap: rowGap, + alignItems: 'start', + children, + }; +} diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index 2ac9c0613..d69665044 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -204,6 +204,7 @@ export { buildStepper, buildFormField, buildTextarea, + buildSkeleton, buildNavChipRow, buildActivityRing, buildRatingStars, @@ -261,6 +262,7 @@ export { type StepperParams, type FormFieldParams, type TextareaParams, + type SkeletonParams, type NavChipRowItem, type NavChipRowParams, type ActivityRingParams, diff --git a/packages/pen-mcp/src/__tests__/add-skeleton-v0.test.ts b/packages/pen-mcp/src/__tests__/add-skeleton-v0.test.ts new file mode 100644 index 000000000..1d6c55408 --- /dev/null +++ b/packages/pen-mcp/src/__tests__/add-skeleton-v0.test.ts @@ -0,0 +1,143 @@ +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 { handleAddSkeletonV0 } from '../tools/add-skeleton-v0'; +import { invalidateCache } from '../document-manager'; + +const TMP = join(tmpdir(), 'openpencil-add-skeleton-v0'); +const EMPTY = JSON.stringify({ version: '1.0.0', children: [] }); + +async function fresh(name: string): Promise { + const fp = join(TMP, name); + await writeFile(fp, EMPTY, 'utf-8'); + return fp; +} +async function readDoc(fp: string): Promise> { + return JSON.parse(await readFile(fp, 'utf-8')); +} +function getRoot(doc: Record): Record { + const pages = doc['pages'] as Array<{ children?: Record[] }> | undefined; + const top = doc['children'] as Record[] | undefined; + const root = (top ?? pages?.[0]?.children)?.[0]; + if (!root) throw new Error('no root'); + return root; +} + +beforeEach(async () => { + await mkdir(TMP, { recursive: true }); +}); +afterEach(async () => { + for (const f of ['a.op']) { + try { + const fp = join(TMP, f); + invalidateCache(fp); + await unlink(fp); + } catch {} + } +}); + +describe('add_skeleton_v0', () => { + it('registered; no required field (all optional)', () => { + expect(DESIGN_TOOL_NAMES.has('add_skeleton_v0')).toBe(true); + const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_skeleton_v0'); + expect(def?.inputSchema.required).toEqual([]); + }); + + it('default: 3 rows, first two fill_container, last ~60% (220px)', async () => { + const fp = await fresh('a.op'); + await handleAddSkeletonV0({ filePath: fp }); + const sk = getRoot(await readDoc(fp)); + expect(sk.role).toBe('skeleton'); + expect(sk.layout).toBe('vertical'); + expect(sk.gap).toBe(12); + const rows = sk.children as Record[]; + expect(rows.length).toBe(3); + expect(rows[0].type).toBe('rectangle'); + expect(rows[0].width).toBe('fill_container'); + expect(rows[0].height).toBe(16); + expect(rows[1].width).toBe('fill_container'); + // Last row short + expect(rows[2].width).toBe(220); + // Every row has the same gray fill and cornerRadius + for (const r of rows) { + expect(r.cornerRadius).toBe(4); + const fill = r.fill as Array<{ color: string }>; + expect(fill[0].color).toBe('#E2E8F0'); + } + }); + + it('rows / row_height / row_gap respected', async () => { + const fp = await fresh('a.op'); + await handleAddSkeletonV0({ filePath: fp, rows: 5, row_height: 20, row_gap: 8 }); + const sk = getRoot(await readDoc(fp)); + expect(sk.gap).toBe(8); + const rows = sk.children as Record[]; + expect(rows.length).toBe(5); + for (const r of rows) expect(r.height).toBe(20); + }); + + it('clamping: rows=0 → 1, rows=99 → 20; row_height=2 → 4, row_gap=99 → 32', async () => { + const fp = await fresh('a.op'); + + await handleAddSkeletonV0({ filePath: fp, rows: 0 }); + let sk = getRoot(await readDoc(fp)); + expect((sk.children as unknown[]).length).toBe(1); + + await writeFile(fp, EMPTY, 'utf-8'); + invalidateCache(fp); + await handleAddSkeletonV0({ filePath: fp, rows: 99 }); + sk = getRoot(await readDoc(fp)); + expect((sk.children as unknown[]).length).toBe(20); + + await writeFile(fp, EMPTY, 'utf-8'); + invalidateCache(fp); + await handleAddSkeletonV0({ filePath: fp, row_height: 2, row_gap: 99 }); + sk = getRoot(await readDoc(fp)); + expect(sk.gap).toBe(32); + const firstRow = (sk.children as Record[])[0]; + expect(firstRow.height).toBe(4); + }); + + it('last_row_short=false → last row is fill_container too', async () => { + const fp = await fresh('a.op'); + await handleAddSkeletonV0({ filePath: fp, rows: 3, last_row_short: false }); + const rows = getRoot(await readDoc(fp)).children as Record[]; + expect(rows[2].width).toBe('fill_container'); + }); + + it('single-row mode skips the "last short" behavior (looks wrong otherwise)', async () => { + const fp = await fresh('a.op'); + await handleAddSkeletonV0({ filePath: fp, rows: 1 }); + const rows = getRoot(await readDoc(fp)).children as Record[]; + expect(rows.length).toBe(1); + expect(rows[0].width).toBe('fill_container'); + }); + + it('every node has a unique id', async () => { + const fp = await fresh('a.op'); + await handleAddSkeletonV0({ filePath: fp, rows: 4 }); + const ids: string[] = []; + function walk(n: Record): void { + if (typeof n.id === 'string') ids.push(n.id); + if (Array.isArray(n.children)) + (n.children as Record[]).forEach( + (c) => c && typeof c === 'object' && walk(c), + ); + } + walk(getRoot(await readDoc(fp))); + // 1 wrapper + 4 rows = 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(handleAddSkeletonV0({ filePath: fp, parent_id: 'nope' })).rejects.toThrow( + /parent_id.*not found/, + ); + expect(await readFile(fp, 'utf-8')).toBe(before); + }); +}); diff --git a/packages/pen-mcp/src/routes/element-tool-defs-ext.ts b/packages/pen-mcp/src/routes/element-tool-defs-ext.ts index 6abfdcdd0..c14e5f99b 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs-ext.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs-ext.ts @@ -638,6 +638,39 @@ export const ELEMENT_TOOL_DEFINITIONS_EXT = [ required: [], }, }, + { + name: 'add_skeleton_v0', + description: + 'Loading skeleton placeholder. N stacked gray rectangles (cornerRadius=4) mimicking future ' + + 'text lines while content fetches. Last row defaults to ~60% width (220px) to suggest an ' + + 'unfinished paragraph. Use for "loading state", "placeholder", "shimmer". schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + rows: { + type: 'number', + description: 'Number of skeleton rows (clamped 1..20, default 3)', + }, + row_height: { + type: 'number', + description: 'Height per row in px (clamped 4..48, default 16)', + }, + row_gap: { + type: 'number', + description: 'Gap between rows in px (clamped 0..32, default 12)', + }, + last_row_short: { + type: 'boolean', + description: 'When true (default), last row is ~60% width to look unfinished', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: [], + }, + }, { name: 'add_textarea_v0', description: diff --git a/packages/pen-mcp/src/routes/element-tool-defs.ts b/packages/pen-mcp/src/routes/element-tool-defs.ts index eb3989935..a63b169a4 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs.ts @@ -35,6 +35,7 @@ import { handleAddListRowV0 } from '../tools/add-list-row-v0'; import { handleAddSearchBarV0 } from '../tools/add-search-bar-v0'; import { handleAddFormFieldV0 } from '../tools/add-form-field-v0'; import { handleAddTextareaV0 } from '../tools/add-textarea-v0'; +import { handleAddSkeletonV0 } from '../tools/add-skeleton-v0'; import { handleAddSwitchV0 } from '../tools/add-switch-v0'; import { handleAddCheckboxV0 } from '../tools/add-checkbox-v0'; import { handleAddRadioV0 } from '../tools/add-radio-v0'; @@ -113,6 +114,8 @@ export async function handleElementToolCall(name: string, a: any): Promise>> { + await ensureParentExists(params); + const skeleton = buildSkeleton(params); + assignIdsRecursively(skeleton); + return insertElementTree({ binding: 's', tree: skeleton, ...params }); +}