diff --git a/apps/web/server/api/mcp/exec-tool.post.ts b/apps/web/server/api/mcp/exec-tool.post.ts index 7969505b3..49f793a32 100644 --- a/apps/web/server/api/mcp/exec-tool.post.ts +++ b/apps/web/server/api/mcp/exec-tool.post.ts @@ -45,6 +45,7 @@ import { buildRangeSlider, buildEmptyChartV1, buildPhoneInput, + buildInputWithAction, buildKbd, buildLink, buildListRow, @@ -212,6 +213,8 @@ const SERVER_BUILDERS: Record = { add_range_slider_v0: (a) => buildRangeSlider(a as Parameters[0]), add_empty_chart_v1: (a) => buildEmptyChartV1(a as Parameters[0]), add_phone_input_v0: (a) => buildPhoneInput(a as Parameters[0]), + add_input_with_action_v0: (a) => + buildInputWithAction(a as Parameters[0]), }; interface ExecToolBody { 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 a9d977205..7d4e35ff0 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 @@ -38,6 +38,7 @@ import { buildHeading, buildIconButton, buildIconLabel, + buildInputWithAction, buildImagePlaceholder, buildVideoPlaceholder, buildModalShell, @@ -519,6 +520,11 @@ const CASES: BuilderCase[] = [ args: { label: 'Phone number', country_code: '+1', country_flag: '๐Ÿ‡บ๐Ÿ‡ธ', value: '555 123 4567' }, build: (a) => buildPhoneInput(a as unknown as Parameters[0]), }, + { + toolName: 'add_input_with_action_v0', + args: { placeholder: 'Enter your email', action_label: 'Subscribe', leading_icon: 'mail' }, + build: (a) => buildInputWithAction(a as unknown as Parameters[0]), + }, ]; /** 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 f394064c9..355696b41 100644 --- a/apps/web/src/services/ai/element-tool-shims/index.ts +++ b/apps/web/src/services/ai/element-tool-shims/index.ts @@ -63,6 +63,7 @@ import { buildRangeSlider, buildEmptyChartV1, buildPhoneInput, + buildInputWithAction, buildKbd, buildLink, buildListRow, @@ -138,6 +139,7 @@ import { type RangeSliderParams, type EmptyChartV1Params, type PhoneInputParams, + type InputWithActionParams, type KbdParams, type LinkParams, type ListRowParams, @@ -332,6 +334,7 @@ export const ELEMENT_SHIMS: Record = { add_range_slider_v0: wrap(buildRangeSlider), add_empty_chart_v1: wrap(buildEmptyChartV1), add_phone_input_v0: wrap(buildPhoneInput), + add_input_with_action_v0: wrap(buildInputWithAction), }; export function getElementShim(name: string): ElementShim | undefined { diff --git a/packages/pen-ai-skills/corpus/ab-v1/landing-newsletter-signup.yaml b/packages/pen-ai-skills/corpus/ab-v1/landing-newsletter-signup.yaml new file mode 100644 index 000000000..a7c957b38 --- /dev/null +++ b/packages/pen-ai-skills/corpus/ab-v1/landing-newsletter-signup.yaml @@ -0,0 +1,22 @@ +id: landing-newsletter-signup +category: landing +difficulty: obvious +prompt: | + Design ONLY the newsletter signup row at the bottom of a landing + page: a single 44px-tall row with two side-by-side elements: + - on the left, a wide rounded input field with a small mail + icon inside on the left and the placeholder text "Enter + your email" in muted slate + - on the right, a "Subscribe" pill button in accent blue with + white text + Both pieces are on the same horizontal row at the same height. + Do NOT render a heading, supporting copy, privacy disclaimer, + or surrounding section โ€” just the email-input + Subscribe row. +expected: + must_contain_roles: + - input-with-action + - input-with-action-input + - input-with-action-text + - input-with-action-button + - input-with-action-label +expected_tool_if_any: add_input_with_action_v0 diff --git a/packages/pen-ai-skills/skills/phases/generation/elements.md b/packages/pen-ai-skills/skills/phases/generation/elements.md index cb7de75ae..857a2b235 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements.md @@ -218,7 +218,9 @@ Input / forms: 66. International phone number input with country-code prefix selector โ†’ `add_phone_input_v0` -67. None match โ†’ fall through to `batch_design` +67. Input field with inline action button (newsletter signup, apply discount code, send chat message) โ†’ `add_input_with_action_v0` + +68. 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. @@ -298,6 +300,7 @@ PREFER an element tool when the spec says any of: - "pricing card", "plan card", "SaaS tier", "subscription plan", "pricing tier", "billing card", "ไปทๆ ผๅก", "ๅฅ—้คๅก", "ๅฎšไปทๅก็‰‡" โ†’ `add_pricing_card_v0` (set one tile's `emphasis: "featured"` to visually recommend it โ€” auto-gets "Most popular" badge unless `badge` overrides). For a 3-tier pricing section, call this 3ร— under the same parent section. - "slider", "range input", "volume control", "opacity slider", "brightness slider", "filter slider", "ๆป‘ๅ—", "ๆป‘ๅŠจๆก", "้Ÿณ้‡ๆก" โ†’ `add_range_slider_v0` (single-handle; set `show_value=true` + `value_suffix="%"` to render the readout). For a dual-handle range (min+max), still fall through to batch_design. - "phone input", "phone field", "international phone", "country code input", "+1 (555) ...", "็”ต่ฏๅท็ ", "ๆ‰‹ๆœบๅท่พ“ๅ…ฅ", "ๅ›ฝ้™…็”ต่ฏ" โ†’ `add_phone_input_v0` (renders country dial code button + digits input in a 44px row; pass `country_flag` for emoji prefix). For a plain single-line text input without the country prefix, use `add_form_field_v0`. +- "newsletter signup", "subscribe form", "subscribe to newsletter", "promo code input", "apply discount", "send message input", "chat composer", "search with submit", "่ฎข้˜…", "ๅบ”็”จไผ˜ๆƒ ็ ", "ๅ‘้€ๆถˆๆฏ" โ†’ `add_input_with_action_v0` (action_kind="text" for "Subscribe" pill button, action_kind="icon" for chat send arrow). Different from `add_form_field_v0` (no inline button) and `add_search_bar_v0` (no trailing action button). STILL use batch_design when: @@ -554,6 +557,10 @@ add_empty_chart_v1({ icon: "bar-chart-2", theme: "system" }) // $color- add_phone_input_v0({ label: "Phone number", country_code: "+1", country_flag: "๐Ÿ‡บ๐Ÿ‡ธ", required: true }) add_phone_input_v0({ country_code: "+86", country_flag: "๐Ÿ‡จ๐Ÿ‡ณ", value: "138 0000 0000" }) // populated state + +add_input_with_action_v0({ placeholder: "Enter your email", action_label: "Subscribe", leading_icon: "mail" }) // newsletter signup +add_input_with_action_v0({ placeholder: "Apply discount code", action_label: "Apply" }) // checkout discount +add_input_with_action_v0({ placeholder: "Type a messageโ€ฆ", action_kind: "icon", action_icon: "send" }) // chat composer ``` ## Composition pattern diff --git a/packages/pen-ai-skills/src/corpus/__tests__/corpus-loader.test.ts b/packages/pen-ai-skills/src/corpus/__tests__/corpus-loader.test.ts index b58d5bb81..3026a6a3c 100644 --- a/packages/pen-ai-skills/src/corpus/__tests__/corpus-loader.test.ts +++ b/packages/pen-ai-skills/src/corpus/__tests__/corpus-loader.test.ts @@ -65,20 +65,20 @@ describe('loadCorpus โ€” real corpus', () => { describe('loadCorpus โ€” v1 supplemental corpus (new tools)', () => { // v1 covers tools added after the v0 freeze, in chronological batches: // 2026-04-22 (12 tools) / 2026-04-24 (5 tools, 4 v0 + first v1) / - // 2026-04-25 (7 tools, 5 v0 + 2 v1). All obvious โ€” one prompt per + // 2026-04-25 (8 tools, 6 v0 + 2 v1). All obvious โ€” one prompt per // tool so A/B runs can measure routing + legality on the new surface // without re-running the v0 corpus. See `corpus/ab-v1/README.md`. - it('loads 24 prompts, all obvious, one per new tool', () => { + it('loads 25 prompts, all obvious, one per new tool', () => { const prompts = loadCorpus(REPO_CORPUS_V1_DIR); - expect(prompts).toHaveLength(24); - expect(new Set(prompts.map((p) => p.id)).size).toBe(24); + expect(prompts).toHaveLength(25); + expect(new Set(prompts.map((p) => p.id)).size).toBe(25); for (const p of prompts) { expect(p.difficulty).toBe('obvious'); expect(p.expected_tool_if_any).toMatch(/^add_[a-z_]+_v\d+$/); } }); - it('covers 2026-04-22 (12) + 2026-04-24 (5) + 2026-04-25 (7) batches', () => { + it('covers 2026-04-22 (12) + 2026-04-24 (5) + 2026-04-25 (8) batches', () => { const prompts = loadCorpus(REPO_CORPUS_V1_DIR); const tools = new Set(prompts.map((p) => p.expected_tool_if_any)); expect(tools).toEqual( @@ -102,12 +102,13 @@ describe('loadCorpus โ€” v1 supplemental corpus (new tools)', () => { 'add_attachment_row_v0', 'add_chat_bubble_v0', 'add_modal_shell_v1', - // 2026-04-25 batch (7 tools) โ€” 5 new v0 + 2 new v1 + // 2026-04-25 batch (8 tools) โ€” 6 new v0 + 2 new v1 'add_social_login_row_v0', 'add_pricing_card_v0', 'add_stat_card_v0', 'add_range_slider_v0', 'add_phone_input_v0', + 'add_input_with_action_v0', 'add_toast_v1', 'add_empty_chart_v1', ]), diff --git a/packages/pen-core/src/element-builders/index.ts b/packages/pen-core/src/element-builders/index.ts index 6d95c5df4..ff1b2e232 100644 --- a/packages/pen-core/src/element-builders/index.ts +++ b/packages/pen-core/src/element-builders/index.ts @@ -98,3 +98,8 @@ export { type EmptyChartV1Theme, } from './empty-chart-v1.js'; export { buildPhoneInput, type PhoneInputParams } from './phone-input.js'; +export { + buildInputWithAction, + type InputWithActionParams, + type InputWithActionKind, +} from './input-with-action.js'; diff --git a/packages/pen-core/src/element-builders/input-with-action.ts b/packages/pen-core/src/element-builders/input-with-action.ts new file mode 100644 index 000000000..f6f8b7a57 --- /dev/null +++ b/packages/pen-core/src/element-builders/input-with-action.ts @@ -0,0 +1,167 @@ +import type { ElementTree } from './helpers.js'; + +export type InputWithActionKind = 'text' | 'icon'; + +export interface InputWithActionParams { + /** Placeholder text shown when value is empty. Required. */ + placeholder: string; + /** Pre-filled input value. Omit for placeholder state. */ + value?: string; + /** Action button label (e.g. "Subscribe", "Apply", "Send"). Required when action_kind="text". */ + action_label?: string; + /** Lucide icon name. Required when action_kind="icon". */ + action_icon?: string; + /** + * Kind of action button. Default `'text'` (label-only pill). Use + * `'icon'` for icon-only square button (chat send / search apply). + */ + action_kind?: InputWithActionKind; + /** Optional leading icon shown inside the input. */ + leading_icon?: string; + /** Total field width in px. Default 400. Min 280. */ + width?: number; +} + +const FIELD_HEIGHT = 44; + +/** + * Input field with inline action button on the right โ€” the + * "Subscribe to newsletter" / "Apply discount" / "Send message" + * pattern. Different from: + * + * - `add_form_field_v0`: input with label-above, no inline button + * - `add_search_bar_v0`: input with leading search icon, no + * trailing action button + * - `add_chip_input_v0`: input that grows chip pills inline + * + * Two action variants: + * + * - `text` (default): pill button with label like "Subscribe". + * Padding [12, 20], accent fill, white text. + * - `icon`: square 44ร—44 icon button (chat send arrow, search- + * apply magnifying glass). Centered icon only. + * + * Structure: + * frame(width, h=44, horizontal, gap=8, role='input-with-action') + * โ”œ frame(input, fill_container, cornerRadius=10, stroke=slate-300, + * โ”‚ layout=horizontal, align=center, role='input-with-action-input') + * โ”‚ โ”œ icon_font(leading_icon, role='input-with-action-leading-icon') โ† if leading_icon + * โ”‚ โ”” text(value or placeholder, role='input-with-action-text') + * โ”” frame(action button, fit_content or 44ร—44 square, role='input-with-action-button') + * โ”” text(label) OR icon_font(action_icon) + */ +export function buildInputWithAction(params: InputWithActionParams): ElementTree { + const width = Math.max(280, Math.floor(params.width ?? 400)); + const kind: InputWithActionKind = params.action_kind ?? 'text'; + const isFilled = params.value !== undefined && params.value !== ''; + const inputContent = isFilled ? params.value! : params.placeholder; + const inputColor = isFilled ? '#0F172A' : '#94A3B8'; + + const inputChildren: ElementTree[] = []; + if (params.leading_icon) { + inputChildren.push({ + type: 'icon_font', + name: 'Leading Icon', + role: 'input-with-action-leading-icon', + iconFontName: params.leading_icon, + iconFontFamily: 'lucide', + width: 18, + height: 18, + fill: [{ type: 'solid', color: '#64748B' }], + }); + } + inputChildren.push({ + type: 'text', + name: 'Text', + role: 'input-with-action-text', + content: inputContent, + fontSize: 14, + fontWeight: 400, + fill: [{ type: 'solid', color: inputColor }], + }); + + const inputFrame: ElementTree = { + type: 'frame', + name: 'Input', + role: 'input-with-action-input', + width: 'fill_container', + height: FIELD_HEIGHT, + cornerRadius: 10, + layout: 'horizontal', + alignItems: 'center', + gap: params.leading_icon ? 8 : 0, + paddingLeft: 14, + paddingRight: 14, + fill: [{ type: 'solid', color: '#FFFFFF' }], + stroke: { thickness: 1, fill: [{ type: 'solid', color: '#CBD5E1' }] }, + children: inputChildren, + }; + + let buttonFrame: ElementTree; + if (kind === 'icon') { + const icon = params.action_icon ?? 'arrow-right'; + buttonFrame = { + type: 'frame', + name: 'Action', + role: 'input-with-action-button', + width: FIELD_HEIGHT, + height: FIELD_HEIGHT, + cornerRadius: 10, + layout: 'horizontal', + alignItems: 'center', + justifyContent: 'center', + fill: [{ type: 'solid', color: '#2563EB' }], + children: [ + { + type: 'icon_font', + name: 'Action Icon', + role: 'input-with-action-icon', + iconFontName: icon, + iconFontFamily: 'lucide', + width: 18, + height: 18, + fill: [{ type: 'solid', color: '#FFFFFF' }], + }, + ], + }; + } else { + const label = params.action_label ?? 'Submit'; + buttonFrame = { + type: 'frame', + name: 'Action', + role: 'input-with-action-button', + width: 'fit_content', + height: FIELD_HEIGHT, + cornerRadius: 10, + layout: 'horizontal', + alignItems: 'center', + justifyContent: 'center', + paddingLeft: 20, + paddingRight: 20, + fill: [{ type: 'solid', color: '#2563EB' }], + children: [ + { + type: 'text', + name: 'Action Label', + role: 'input-with-action-label', + content: label, + fontSize: 14, + fontWeight: 600, + fill: [{ type: 'solid', color: '#FFFFFF' }], + }, + ], + }; + } + + return { + type: 'frame', + name: 'Input With Action', + role: 'input-with-action', + width, + height: FIELD_HEIGHT, + layout: 'horizontal', + alignItems: 'center', + gap: 8, + children: [inputFrame, buttonFrame], + }; +} diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index 48b0b0f90..a1bda1534 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -261,6 +261,7 @@ export { buildRangeSlider, buildEmptyChartV1, buildPhoneInput, + buildInputWithAction, cjkFontFamily, detectCjkScript, type ElementTree, @@ -360,4 +361,6 @@ export { type EmptyChartV1Params, type EmptyChartV1Theme, type PhoneInputParams, + type InputWithActionParams, + type InputWithActionKind, } from './element-builders/index.js'; diff --git a/packages/pen-mcp/src/__tests__/add-input-with-action-v0.test.ts b/packages/pen-mcp/src/__tests__/add-input-with-action-v0.test.ts new file mode 100644 index 000000000..d1f091334 --- /dev/null +++ b/packages/pen-mcp/src/__tests__/add-input-with-action-v0.test.ts @@ -0,0 +1,169 @@ +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 { handleAddInputWithActionV0 } from '../tools/add-input-with-action-v0'; +import { invalidateCache } from '../document-manager'; + +const TMP = join(tmpdir(), 'openpencil-add-input-with-action-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; +} +function findByRole(n: Record, role: string): Record | undefined { + if (n.role === role) return n; + const kids = (n.children ?? []) as Record[]; + for (const c of kids) { + const hit = findByRole(c, role); + if (hit) return hit; + } + return undefined; +} +function fillColor(n: Record | undefined): string | undefined { + const fills = n?.fill as Array<{ color?: string }> | undefined; + return fills?.[0]?.color; +} + +beforeEach(async () => { + await mkdir(TMP, { recursive: true }); +}); +afterEach(async () => { + for (const f of ['i.op']) { + try { + const fp = join(TMP, f); + invalidateCache(fp); + await unlink(fp); + } catch {} + } +}); + +describe('add_input_with_action_v0', () => { + it('registered; required=[placeholder]', () => { + expect(DESIGN_TOOL_NAMES.has('add_input_with_action_v0')).toBe(true); + const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_input_with_action_v0'); + expect(def?.inputSchema.required).toEqual(['placeholder']); + }); + + it('default text variant: pill button with "Submit" label and accent fill', async () => { + const fp = await fresh('i.op'); + await handleAddInputWithActionV0({ filePath: fp, placeholder: 'Enter email' }); + const root = getRoot(await readDoc(fp)); + expect(root.role).toBe('input-with-action'); + const button = findByRole(root, 'input-with-action-button')!; + expect(button.width).toBe('fit_content'); + expect(fillColor(button)).toBe('#2563EB'); + expect(findByRole(root, 'input-with-action-label')!.content).toBe('Submit'); + }); + + it('custom action_label renders as button text', async () => { + const fp = await fresh('i.op'); + await handleAddInputWithActionV0({ + filePath: fp, + placeholder: 'Enter email', + action_label: 'Subscribe', + }); + const root = getRoot(await readDoc(fp)); + expect(findByRole(root, 'input-with-action-label')!.content).toBe('Subscribe'); + }); + + it('icon variant: 44x44 square button with action icon', async () => { + const fp = await fresh('i.op'); + await handleAddInputWithActionV0({ + filePath: fp, + placeholder: 'Type a message', + action_kind: 'icon', + action_icon: 'send', + }); + const root = getRoot(await readDoc(fp)); + const button = findByRole(root, 'input-with-action-button')!; + expect(button.width).toBe(44); + expect(button.height).toBe(44); + expect(findByRole(root, 'input-with-action-icon')!.iconFontName).toBe('send'); + expect(findByRole(root, 'input-with-action-label')).toBeUndefined(); + }); + + it('icon variant defaults to arrow-right when action_icon omitted', async () => { + const fp = await fresh('i.op'); + await handleAddInputWithActionV0({ + filePath: fp, + placeholder: 'Search', + action_kind: 'icon', + }); + const root = getRoot(await readDoc(fp)); + expect(findByRole(root, 'input-with-action-icon')!.iconFontName).toBe('arrow-right'); + }); + + it('value renders populated state in slate-900', async () => { + const fp = await fresh('i.op'); + await handleAddInputWithActionV0({ + filePath: fp, + placeholder: 'Enter email', + value: 'user@example.com', + }); + const root = getRoot(await readDoc(fp)); + expect(findByRole(root, 'input-with-action-text')!.content).toBe('user@example.com'); + expect(fillColor(findByRole(root, 'input-with-action-text'))).toBe('#0F172A'); + }); + + it('placeholder state has slate-400 muted color', async () => { + const fp = await fresh('i.op'); + await handleAddInputWithActionV0({ filePath: fp, placeholder: 'Enter email' }); + const root = getRoot(await readDoc(fp)); + expect(fillColor(findByRole(root, 'input-with-action-text'))).toBe('#94A3B8'); + }); + + it('leading icon renders inside input frame on the left', async () => { + const fp = await fresh('i.op'); + await handleAddInputWithActionV0({ + filePath: fp, + placeholder: 'Enter email', + leading_icon: 'mail', + }); + const root = getRoot(await readDoc(fp)); + const leadingIcon = findByRole(root, 'input-with-action-leading-icon')!; + expect(leadingIcon.iconFontName).toBe('mail'); + // Leading icon is BEFORE the text in the input frame + const inputFrame = findByRole(root, 'input-with-action-input')!; + const inputKids = inputFrame.children as Array>; + expect(inputKids[0].role).toBe('input-with-action-leading-icon'); + expect(inputKids[1].role).toBe('input-with-action-text'); + }); + + it('row height is 44px (matches form-field standard)', async () => { + const fp = await fresh('i.op'); + await handleAddInputWithActionV0({ filePath: fp, placeholder: 'X' }); + const root = getRoot(await readDoc(fp)); + expect(root.height).toBe(44); + expect(findByRole(root, 'input-with-action-input')!.height).toBe(44); + }); + + it('width clamps (< 280 โ†’ 280)', async () => { + const fp = await fresh('i.op'); + await handleAddInputWithActionV0({ filePath: fp, placeholder: 'X', width: 100 }); + const root = getRoot(await readDoc(fp)); + expect(root.width).toBe(280); + }); + + it('throws on bogus parent_id AND leaves file untouched', async () => { + const fp = await fresh('i.op'); + const before = await readFile(fp, 'utf-8'); + await expect( + handleAddInputWithActionV0({ filePath: fp, placeholder: '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/__tests__/element-tools-contract.test.ts b/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts index 8f3565082..fc82eaf2e 100644 --- a/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts +++ b/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts @@ -83,6 +83,7 @@ const ELEMENT_TOOL_NAMES = [ 'add_range_slider_v0', 'add_empty_chart_v1', 'add_phone_input_v0', + 'add_input_with_action_v0', ]; describe('element tools โ€” v0-MUST contract', () => { diff --git a/packages/pen-mcp/src/routes/element-tool-defs-ext-3.ts b/packages/pen-mcp/src/routes/element-tool-defs-ext-3.ts index 85ffe5b29..5ae1457a5 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs-ext-3.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs-ext-3.ts @@ -651,4 +651,51 @@ export const ELEMENT_TOOL_DEFINITIONS_EXT_3 = [ }, }, }, + { + name: 'add_input_with_action_v0', + description: + 'Input field with inline action button on the right โ€” the "Subscribe to newsletter" / ' + + '"Apply discount code" / "Send chat message" pattern. Different from add_form_field_v0 ' + + '(label-above, no inline button) and add_search_bar_v0 (leading search icon, no trailing ' + + 'action). Two action variants: action_kind="text" (default, pill button with label like ' + + '"Subscribe") or action_kind="icon" (44ร—44 square icon button โ€” chat send arrow / search ' + + 'apply). Set `value` to render populated state (slate-900 text); omit for placeholder ' + + 'state (slate-400). Optional `leading_icon` adds an icon inside the input itself. Use for ' + + '"newsletter signup", "apply discount code", "send message", "subscribe form", "promo code", ' + + '"่ฎข้˜…่พ“ๅ…ฅ", "ๅ‘้€ๆถˆๆฏ่พ“ๅ…ฅ". schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + placeholder: { type: 'string', description: 'Placeholder text (e.g. "Enter email")' }, + value: { + type: 'string', + description: 'Pre-filled input value. Omit for placeholder state.', + }, + action_label: { + type: 'string', + description: 'Button text when action_kind="text" (default "Submit")', + }, + action_icon: { + type: 'string', + description: 'Lucide icon name when action_kind="icon" (default "arrow-right")', + }, + action_kind: { + type: 'string', + enum: ['text', 'icon'], + description: + '"text" (default) = pill button with label. "icon" = 44ร—44 square icon button.', + }, + leading_icon: { + type: 'string', + description: 'Optional lucide icon shown inside the input itself (left side)', + }, + width: { type: 'number', description: 'Field width in px (default 400, min 280)' }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['placeholder'], + }, + }, ]; diff --git a/packages/pen-mcp/src/routes/element-tool-defs.ts b/packages/pen-mcp/src/routes/element-tool-defs.ts index f4f7b94ee..55c70f2d3 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs.ts @@ -89,6 +89,7 @@ import { handleAddToastV1 } from '../tools/add-toast-v1'; import { handleAddRangeSliderV0 } from '../tools/add-range-slider-v0'; import { handleAddEmptyChartV1 } from '../tools/add-empty-chart-v1'; import { handleAddPhoneInputV0 } from '../tools/add-phone-input-v0'; +import { handleAddInputWithActionV0 } from '../tools/add-input-with-action-v0'; import { recordElementToolCall } from '../metrics/element-tool-metrics'; import { ELEMENT_TOOL_DEFINITIONS_BASE } from './element-tool-defs-base'; import { ELEMENT_TOOL_DEFINITIONS_EXT } from './element-tool-defs-ext'; @@ -269,6 +270,8 @@ async function dispatchElementToolCall(name: string, a: any): Promise { return JSON.stringify(await handleAddEmptyChartV1(a), null, 2); case 'add_phone_input_v0': return JSON.stringify(await handleAddPhoneInputV0(a), null, 2); + case 'add_input_with_action_v0': + return JSON.stringify(await handleAddInputWithActionV0(a), null, 2); default: return ''; } diff --git a/packages/pen-mcp/src/tools/add-input-with-action-v0.ts b/packages/pen-mcp/src/tools/add-input-with-action-v0.ts new file mode 100644 index 000000000..ac456653d --- /dev/null +++ b/packages/pen-mcp/src/tools/add-input-with-action-v0.ts @@ -0,0 +1,23 @@ +import { + assignIdsRecursively, + buildInputWithAction, + type InputWithActionParams, +} from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddInputWithActionV0Params extends InputWithActionParams { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** Input field with inline action button. Tree build delegated to `buildInputWithAction`. */ +export async function handleAddInputWithActionV0( + params: AddInputWithActionV0Params, +): Promise>> { + await ensureParentExists(params); + const r = buildInputWithAction(params); + assignIdsRecursively(r); + return insertElementTree({ binding: 'inputWithAction', tree: r, ...params }); +}