From 912ed1cd5a905ffccd4d0b1addd4a36241dcbbfe Mon Sep 17 00:00:00 2001 From: Fini Date: Wed, 22 Apr 2026 06:25:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20add=5Fselect=5Fv0=20=E2=80=94=20dro?= =?UTF-8?q?pdown=20display=20element=20tool=20(45th)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropdown/picker closed-state display. Same label-above-input shape as add_form_field_v0 with: - always-present trailing chevron-down icon - when `value` is set: black value text + chevron - when absent: placeholder text styled gray (#94A3B8) + chevron - justifyContent=space_between pushes the chevron to the right edge Explicitly NOT modeled: open-menu state (dropdown list). An open dropdown needs absolute positioning + scrim + per-option states that belong in a different builder; compose via batch_design for now. Wired across all three paths + elements.md keyword map + examples. Tests: - pen-mcp: 7 new cases (value/placeholder rendering, custom trailing_icon, required suffix, id uniqueness, parent_id rollback) - Parametric coverage: auto-extended (+1 case each in 9 files). Total delta: 2955 → 2979 passing. Closes #48; covers part of #50 (select entry). --- apps/web/server/api/mcp/exec-tool.post.ts | 2 + .../anti-patterns-builder-clean.test.ts | 2 + .../__tests__/detectors-builder-clean.test.ts | 2 + .../role-resolver-builder-coverage.test.ts | 2 + .../ai/__tests__/shim-server-parity.test.ts | 6 + .../services/ai/element-tool-shims/index.ts | 3 + .../skills/phases/generation/elements.md | 4 + .../__tests__/element-builders-layout.test.ts | 6 + ...nt-builders-normalize-preservation.test.ts | 2 + .../element-builders-performance.test.ts | 2 + ...t-builders-post-process-idempotent.test.ts | 2 + .../pen-core/src/element-builders/index.ts | 1 + .../pen-core/src/element-builders/select.ts | 92 ++++++++++++ packages/pen-core/src/index.ts | 2 + .../src/__tests__/add-select-v0.test.ts | 134 ++++++++++++++++++ .../src/routes/element-tool-defs-ext.ts | 36 +++++ .../pen-mcp/src/routes/element-tool-defs.ts | 3 + packages/pen-mcp/src/tools/add-select-v0.ts | 23 +++ 18 files changed, 324 insertions(+) create mode 100644 packages/pen-core/src/element-builders/select.ts create mode 100644 packages/pen-mcp/src/__tests__/add-select-v0.test.ts create mode 100644 packages/pen-mcp/src/tools/add-select-v0.ts diff --git a/apps/web/server/api/mcp/exec-tool.post.ts b/apps/web/server/api/mcp/exec-tool.post.ts index ab07b04fa..0704253ec 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, + buildSelect, buildSkeleton, buildStatGrid, buildStepper, @@ -133,6 +134,7 @@ const SERVER_BUILDERS: Record = { 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_select_v0: (a) => buildSelect(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 2aa48b870..8c104d1b4 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, + buildSelect, buildSkeleton, buildStatGrid, buildStepper, @@ -174,6 +175,7 @@ const CASES: BuilderCase[] = [ { name: 'form-field', build: () => buildFormField({ label: 'Email' }) }, { name: 'textarea', build: () => buildTextarea({ label: 'Bio', rows: 5 }) }, { name: 'skeleton', build: () => buildSkeleton({ rows: 3 }) }, + { name: 'select', build: () => buildSelect({ label: 'Country', value: 'US' }) }, { 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 a0828472c..42ad2a4b1 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, + buildSelect, buildSkeleton, buildStatGrid, buildStepper, @@ -168,6 +169,7 @@ const CASES: BuilderCase[] = [ { name: 'form-field', build: () => buildFormField({ label: 'Email' }) }, { name: 'textarea', build: () => buildTextarea({ label: 'Bio', rows: 5 }) }, { name: 'skeleton', build: () => buildSkeleton({ rows: 3 }) }, + { name: 'select', build: () => buildSelect({ label: 'Country', value: 'US' }) }, { 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 d28ae0ed7..11bff9e33 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, + buildSelect, buildSkeleton, buildStatGrid, buildStepper, @@ -191,6 +192,7 @@ const CASES: BuilderCase[] = [ { name: 'form-field', build: () => buildFormField({ label: 'Email' }) }, { name: 'textarea', build: () => buildTextarea({ label: 'Bio', rows: 5 }) }, { name: 'skeleton', build: () => buildSkeleton({ rows: 3 }) }, + { name: 'select', build: () => buildSelect({ label: 'Country', value: 'US' }) }, { 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 3da0fb624..eb976391d 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, + buildSelect, buildSkeleton, buildStatGrid, buildStepper, @@ -248,6 +249,11 @@ const CASES: BuilderCase[] = [ args: { rows: 3 }, build: (a) => buildSkeleton(a as unknown as Parameters[0]), }, + { + toolName: 'add_select_v0', + args: { label: 'Country', value: 'US' }, + build: (a) => buildSelect(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 d5820dba1..baf19cdee 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, + buildSelect, buildSkeleton, buildStatGrid, buildStepper, @@ -98,6 +99,7 @@ import { type SearchBarParams, type SectionHeaderParams, type SegmentedControlParams, + type SelectParams, type SkeletonParams, type StatGridParams, type StepperParams, @@ -229,6 +231,7 @@ export const ELEMENT_SHIMS: Record = { add_form_field_v0: wrap(buildFormField), add_textarea_v0: wrap(buildTextarea), add_skeleton_v0: wrap(buildSkeleton), + add_select_v0: wrap(buildSelect), 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 0e04461fa..de19f7752 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements.md @@ -68,6 +68,7 @@ Forms: 18. Search bar (height=44, cornerRadius=22, leading search icon) → `add_search_bar_v0` 19. Form field (label + 48px input with optional affordance icons) → `add_form_field_v0` 19b. Multi-line textarea (label + N-row input for notes / bio / feedback) → `add_textarea_v0` + 19c. Dropdown select closed-state (label + 48px input w/ value text + chevron-down) → `add_select_v0` Controls (toggle / choice / tabs): @@ -154,6 +155,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` +- "dropdown", "select", "picker", "combo box", "下拉选择", "选择器" → `add_select_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` @@ -284,6 +286,8 @@ 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_select_v0({ label: "Country", value: "United States" }) +add_select_v0({ label: "Currency", placeholder: "Choose currency", required: true }) 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 961d75972..4f5f2ef58 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, + buildSelect, buildSkeleton, buildStatGrid, buildStepper, @@ -179,6 +180,11 @@ const CASES: LayoutCase[] = [ { 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: 'buildSelect', + tree: () => buildSelect({ label: 'Country', value: 'US' }), + 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 126c6c6e5..f68bd70c3 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, + buildSelect, buildSkeleton, buildStatGrid, buildStepper, @@ -170,6 +171,7 @@ const CASES: BuilderCase[] = [ { name: 'form-field', build: () => buildFormField({ label: 'Email' }) }, { name: 'textarea', build: () => buildTextarea({ label: 'Bio', rows: 5 }) }, { name: 'skeleton', build: () => buildSkeleton({ rows: 3 }) }, + { name: 'select', build: () => buildSelect({ label: 'Country', value: 'US' }) }, { 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 73cd631ff..09df2938d 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, + buildSelect, buildSkeleton, buildStatGrid, buildStepper, @@ -150,6 +151,7 @@ const CASES: PerfCase[] = [ { name: 'form-field', build: () => buildFormField({ label: 'Email' }) }, { name: 'textarea', build: () => buildTextarea({ label: 'Bio', rows: 5 }) }, { name: 'skeleton', build: () => buildSkeleton({ rows: 3 }) }, + { name: 'select', build: () => buildSelect({ label: 'Country', value: 'US' }) }, { 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 25f9b5e7c..b8868e90b 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, + buildSelect, buildSkeleton, buildStatGrid, buildStepper, @@ -174,6 +175,7 @@ const CASES: BuilderCase[] = [ { name: 'form-field', build: () => buildFormField({ label: 'Email' }) }, { name: 'textarea', build: () => buildTextarea({ label: 'Bio', rows: 5 }) }, { name: 'skeleton', build: () => buildSkeleton({ rows: 3 }) }, + { name: 'select', build: () => buildSelect({ label: 'Country', value: 'US' }) }, { 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 be71d0370..a2ce4d448 100644 --- a/packages/pen-core/src/element-builders/index.ts +++ b/packages/pen-core/src/element-builders/index.ts @@ -39,6 +39,7 @@ 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 { buildSelect, type SelectParams } from './select.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/select.ts b/packages/pen-core/src/element-builders/select.ts new file mode 100644 index 000000000..213f352c5 --- /dev/null +++ b/packages/pen-core/src/element-builders/select.ts @@ -0,0 +1,92 @@ +import type { ElementTree } from './helpers.js'; + +export interface SelectParams { + label: string; + /** Currently selected value. Shows in the input area. Omit to show placeholder. */ + value?: string; + /** Placeholder shown when no value is selected (gray). Default "Select…". */ + placeholder?: string; + /** Trailing affordance icon name. Default "chevron-down" (closed state). */ + trailing_icon?: string; + required?: boolean; +} + +/** + * Dropdown select — CLOSED/display state only. Same label-above- + * input pattern as form-field with: + * - no leading icon slot (not typical for selects) + * - trailing chevron-down icon (always present; override via + * trailing_icon if the caller wants a custom affordance) + * - when `value` is set: input text renders black (fontWeight=400) + * as a normal filled input + * - when `value` is absent: input text renders the placeholder + * and is styled gray (color #94A3B8) so users see it as a hint + * + * The OPEN state (menu + options list) is NOT modeled here. An open + * dropdown is a different design concern (absolute positioning, scrim + * layer, per-option hover/selected) that belongs in its own builder + * if/when needed. Users compose an open state via batch_design. + */ +export function buildSelect(params: SelectParams): ElementTree { + const labelText = params.required ? `${params.label} *` : params.label; + const placeholder = params.placeholder ?? 'Select…'; + const trailingIcon = params.trailing_icon ?? 'chevron-down'; + const displayText = params.value ?? placeholder; + const isEmpty = params.value === undefined; + + const textNode: ElementTree = { + type: 'text', + name: isEmpty ? 'Placeholder' : 'Selected Value', + content: displayText, + fontSize: 14, + fontWeight: 400, + }; + if (isEmpty) { + // Placeholder gray color — same token used by form-field's placeholder + textNode.fill = [{ type: 'solid', color: '#94A3B8' }]; + } + + return { + type: 'frame', + name: 'Select', + role: 'select', + width: 'fill_container', + height: 'fit_content', + layout: 'vertical', + gap: 6, + children: [ + { + type: 'text', + name: 'Label', + role: 'label', + content: labelText, + fontSize: 14, + fontWeight: 500, + }, + { + type: 'frame', + name: 'Input', + role: 'select-input', + width: 'fill_container', + height: 48, + cornerRadius: 8, + layout: 'horizontal', + alignItems: 'center', + justifyContent: 'space_between', + gap: 8, + padding: [12, 16], + children: [ + textNode, + { + type: 'icon_font', + name: 'Trailing Icon', + iconFontName: trailingIcon, + iconFontFamily: 'lucide', + width: 20, + height: 20, + }, + ], + }, + ], + }; +} diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index d69665044..4316eabce 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -205,6 +205,7 @@ export { buildFormField, buildTextarea, buildSkeleton, + buildSelect, buildNavChipRow, buildActivityRing, buildRatingStars, @@ -263,6 +264,7 @@ export { type FormFieldParams, type TextareaParams, type SkeletonParams, + type SelectParams, type NavChipRowItem, type NavChipRowParams, type ActivityRingParams, diff --git a/packages/pen-mcp/src/__tests__/add-select-v0.test.ts b/packages/pen-mcp/src/__tests__/add-select-v0.test.ts new file mode 100644 index 000000000..2f91762aa --- /dev/null +++ b/packages/pen-mcp/src/__tests__/add-select-v0.test.ts @@ -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 { handleAddSelectV0 } from '../tools/add-select-v0'; +import { invalidateCache } from '../document-manager'; + +const TMP = join(tmpdir(), 'openpencil-add-select-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_select_v0', () => { + it('registered + required label', () => { + expect(DESIGN_TOOL_NAMES.has('add_select_v0')).toBe(true); + const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_select_v0'); + expect(def?.inputSchema.required).toEqual(['label']); + }); + + it('with value set: black value text + chevron-down trailing', async () => { + const fp = await fresh('a.op'); + await handleAddSelectV0({ filePath: fp, label: 'Country', value: 'United States' }); + const sel = getRoot(await readDoc(fp)); + expect(sel.role).toBe('select'); + expect(sel.layout).toBe('vertical'); + const [label, input] = sel.children as Record[]; + expect(label.content).toBe('Country'); + expect(input.role).toBe('select-input'); + expect(input.justifyContent).toBe('space_between'); + const [textNode, trailing] = input.children as Record[]; + expect(textNode.content).toBe('United States'); + // NOT gray when value is set + expect(textNode.fill).toBeUndefined(); + expect(trailing.type).toBe('icon_font'); + expect(trailing.iconFontName).toBe('chevron-down'); + }); + + it('no value: placeholder text with gray fill #94A3B8', async () => { + const fp = await fresh('a.op'); + await handleAddSelectV0({ filePath: fp, label: 'Country' }); + const sel = getRoot(await readDoc(fp)); + const input = (sel.children as Record[])[1]; + const textNode = (input.children as Record[])[0]; + expect(textNode.content).toBe('Select…'); + const fill = textNode.fill as Array<{ color: string }>; + expect(fill[0].color).toBe('#94A3B8'); + }); + + it('custom placeholder overrides default "Select…"', async () => { + const fp = await fresh('a.op'); + await handleAddSelectV0({ + filePath: fp, + label: 'Currency', + placeholder: 'Choose currency', + }); + const input = (getRoot(await readDoc(fp)).children as Record[])[1]; + const textNode = (input.children as Record[])[0]; + expect(textNode.content).toBe('Choose currency'); + }); + + it('custom trailing_icon overrides default chevron-down', async () => { + const fp = await fresh('a.op'); + await handleAddSelectV0({ + filePath: fp, + label: 'Country', + value: 'US', + trailing_icon: 'chevrons-up-down', + }); + const input = (getRoot(await readDoc(fp)).children as Record[])[1]; + const trailing = (input.children as Record[])[1]; + expect(trailing.iconFontName).toBe('chevrons-up-down'); + }); + + it('required=true appends " *" to label', async () => { + const fp = await fresh('a.op'); + await handleAddSelectV0({ filePath: fp, label: 'Country', required: true }); + const label = (getRoot(await readDoc(fp)).children as Record[])[0]; + expect(label.content).toBe('Country *'); + }); + + it('every node has a unique id', async () => { + const fp = await fresh('a.op'); + await handleAddSelectV0({ filePath: fp, label: 'X', value: 'Y' }); + 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))); + // select + label + input + (value text + trailing 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( + handleAddSelectV0({ filePath: fp, label: 'X', 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 c14e5f99b..ab5a1e985 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,42 @@ export const ELEMENT_TOOL_DEFINITIONS_EXT = [ required: [], }, }, + { + name: 'add_select_v0', + description: + 'Dropdown select (display / closed state). Label above input box matching add_form_field_v0 ' + + 'shape, but the input trailing slot is ALWAYS a chevron-down icon affordance. When `value` ' + + 'is set: input shows value text (black). When absent: input shows placeholder text (gray ' + + '#94A3B8). The OPEN state (menu list) is NOT modeled here — compose via batch_design when ' + + 'needed. Use for "dropdown", "select", "picker", "下拉选择". schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + label: { type: 'string', description: 'Field label text' }, + value: { + type: 'string', + description: 'Currently selected value text. Omit to show placeholder instead.', + }, + placeholder: { + type: 'string', + description: 'Placeholder when value is absent. Default "Select…".', + }, + trailing_icon: { + type: 'string', + description: 'Trailing icon. Default "chevron-down" (closed state).', + }, + required: { + type: 'boolean', + description: 'When true, appends " *" to the label text', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['label'], + }, + }, { name: 'add_skeleton_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 a63b169a4..dd7ebccda 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs.ts @@ -36,6 +36,7 @@ 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 { handleAddSelectV0 } from '../tools/add-select-v0'; import { handleAddSwitchV0 } from '../tools/add-switch-v0'; import { handleAddCheckboxV0 } from '../tools/add-checkbox-v0'; import { handleAddRadioV0 } from '../tools/add-radio-v0'; @@ -116,6 +117,8 @@ export async function handleElementToolCall(name: string, a: any): Promise>> { + await ensureParentExists(params); + const sel = buildSelect(params); + assignIdsRecursively(sel); + return insertElementTree({ binding: 'sel', tree: sel, ...params }); +}