feat(ai): add_skeleton_v0 — loading placeholder element tool (44th)
N stacked gray rectangles (cornerRadius=4) mimicking future text lines while content fetches. Parameters: - rows (1..20, default 3) - row_height (4..48, default 16) - row_gap (0..32, default 12) - last_row_short (default true): last row renders at ~60% width (220px) to suggest an unfinished paragraph — more organic than uniform stripes. Disabled when rows=1 (looks wrong otherwise). Wired across all three paths (pen-core / pen-mcp / apps/web shim + Nitro SERVER_BUILDERS). elements.md PREFER list + keyword map + example lines updated. Tests: - pen-mcp: 8 new cases (default shape, rows/height/gap, clamp bounds, last_row_short=false, single-row corner case, id uniqueness, parent_id rollback) - Parametric coverage: auto-extended (+1 case each in 9 files). Total delta: 2931 → 2955 passing. Closes #49; covers part of #50 (skeleton entry).
This commit is contained in:
parent
2d4f54db56
commit
a08866cdda
|
|
@ -35,6 +35,7 @@ import {
|
|||
buildSearchBar,
|
||||
buildSectionHeader,
|
||||
buildSegmentedControl,
|
||||
buildSkeleton,
|
||||
buildStatGrid,
|
||||
buildStepper,
|
||||
buildSwitch,
|
||||
|
|
@ -131,6 +132,7 @@ const SERVER_BUILDERS: Record<string, BuilderFn> = {
|
|||
add_stepper_v0: (a) => buildStepper(a as Parameters<typeof buildStepper>[0]),
|
||||
add_form_field_v0: (a) => buildFormField(a as Parameters<typeof buildFormField>[0]),
|
||||
add_textarea_v0: (a) => buildTextarea(a as Parameters<typeof buildTextarea>[0]),
|
||||
add_skeleton_v0: (a) => buildSkeleton(a as Parameters<typeof buildSkeleton>[0]),
|
||||
add_nav_chip_row_v0: (a) => buildNavChipRow(a as Parameters<typeof buildNavChipRow>[0]),
|
||||
add_activity_ring_v0: (a) => buildActivityRing(a as Parameters<typeof buildActivityRing>[0]),
|
||||
add_rating_stars_v0: (a) => buildRatingStars(a as Parameters<typeof buildRatingStars>[0]),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<typeof buildTextarea>[0]),
|
||||
},
|
||||
{
|
||||
toolName: 'add_skeleton_v0',
|
||||
args: { rows: 3 },
|
||||
build: (a) => buildSkeleton(a as unknown as Parameters<typeof buildSkeleton>[0]),
|
||||
},
|
||||
{
|
||||
toolName: 'add_form_field_v0',
|
||||
args: { label: 'Email' },
|
||||
|
|
|
|||
|
|
@ -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<string, ElementShim> = {
|
|||
add_stepper_v0: wrap<StepperParams>(buildStepper),
|
||||
add_form_field_v0: wrap<FormFieldParams>(buildFormField),
|
||||
add_textarea_v0: wrap<TextareaParams>(buildTextarea),
|
||||
add_skeleton_v0: wrap<SkeletonParams>(buildSkeleton),
|
||||
add_nav_chip_row_v0: wrap<NavChipRowParams>(buildNavChipRow),
|
||||
add_activity_ring_v0: wrap<ActivityRingParams>(buildActivityRing),
|
||||
add_rating_stars_v0: wrap<RatingStarsParams>(buildRatingStars),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
74
packages/pen-core/src/element-builders/skeleton.ts
Normal file
74
packages/pen-core/src/element-builders/skeleton.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
143
packages/pen-mcp/src/__tests__/add-skeleton-v0.test.ts
Normal file
143
packages/pen-mcp/src/__tests__/add-skeleton-v0.test.ts
Normal file
|
|
@ -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<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_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<string, unknown>[];
|
||||
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<string, unknown>[];
|
||||
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<string, unknown>[])[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<string, unknown>[];
|
||||
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<string, unknown>[];
|
||||
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<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)));
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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<strin
|
|||
return JSON.stringify(await handleAddFormFieldV0(a), null, 2);
|
||||
case 'add_textarea_v0':
|
||||
return JSON.stringify(await handleAddTextareaV0(a), null, 2);
|
||||
case 'add_skeleton_v0':
|
||||
return JSON.stringify(await handleAddSkeletonV0(a), null, 2);
|
||||
case 'add_switch_v0':
|
||||
return JSON.stringify(await handleAddSwitchV0(a), null, 2);
|
||||
case 'add_checkbox_v0':
|
||||
|
|
|
|||
22
packages/pen-mcp/src/tools/add-skeleton-v0.ts
Normal file
22
packages/pen-mcp/src/tools/add-skeleton-v0.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { assignIdsRecursively, buildSkeleton, type SkeletonParams } from '@zseven-w/pen-core';
|
||||
import type { handleBatchDesign } from './batch-design';
|
||||
import { ensureParentExists, insertElementTree } from './element-tool-helpers';
|
||||
|
||||
export interface AddSkeletonV0Params extends SkeletonParams {
|
||||
parent_id?: string;
|
||||
filePath?: string;
|
||||
pageId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading skeleton: stacked gray rectangles mimicking future text
|
||||
* lines while content fetches. Tree build delegated to `buildSkeleton`.
|
||||
*/
|
||||
export async function handleAddSkeletonV0(
|
||||
params: AddSkeletonV0Params,
|
||||
): Promise<Awaited<ReturnType<typeof handleBatchDesign>>> {
|
||||
await ensureParentExists(params);
|
||||
const skeleton = buildSkeleton(params);
|
||||
assignIdsRecursively(skeleton);
|
||||
return insertElementTree({ binding: 's', tree: skeleton, ...params });
|
||||
}
|
||||
Loading…
Reference in a new issue