From 577488546adc31071be97102a7a7b3889b3b13aa Mon Sep 17 00:00:00 2001 From: Fini Date: Sat, 25 Apr 2026 07:30:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20add=5Fphone=5Finput=5Fv0=20?= =?UTF-8?q?=E2=80=94=20international=20phone=20input=20(74th=20tool)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "+1 (555) โ€ฆ" pattern from every modern signup / login screen. A 44px row with leading country selector (flag + dial code + chevron-down), a 1px slate divider, and the digits input on the right. Country selector is a button-shape (no actual dropdown menu); caller handles picker UX as a separate concern. `value` toggles between placeholder (slate-400) and populated (slate-900). --- apps/web/server/api/mcp/exec-tool.post.ts | 2 + .../ai/__tests__/shim-server-parity.test.ts | 6 + .../services/ai/element-tool-shims/index.ts | 3 + .../skills/phases/generation/elements.md | 104 ++++++++++- .../pen-core/src/element-builders/index.ts | 1 + .../src/element-builders/phone-input.ts | 170 ++++++++++++++++++ packages/pen-core/src/index.ts | 2 + .../src/__tests__/add-phone-input-v0.test.ts | 146 +++++++++++++++ .../__tests__/element-tools-contract.test.ts | 1 + .../src/routes/element-tool-defs-ext-3.ts | 44 +++++ .../pen-mcp/src/routes/element-tool-defs.ts | 3 + .../pen-mcp/src/tools/add-phone-input-v0.ts | 19 ++ 12 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 packages/pen-core/src/element-builders/phone-input.ts create mode 100644 packages/pen-mcp/src/__tests__/add-phone-input-v0.test.ts create mode 100644 packages/pen-mcp/src/tools/add-phone-input-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 bfb6db4c5..7969505b3 100644 --- a/apps/web/server/api/mcp/exec-tool.post.ts +++ b/apps/web/server/api/mcp/exec-tool.post.ts @@ -44,6 +44,7 @@ import { buildToastV1, buildRangeSlider, buildEmptyChartV1, + buildPhoneInput, buildKbd, buildLink, buildListRow, @@ -210,6 +211,7 @@ const SERVER_BUILDERS: Record = { add_toast_v1: (a) => buildToastV1(a as Parameters[0]), 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]), }; 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 d8d3bda54..a9d977205 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 @@ -57,6 +57,7 @@ import { buildNavChipRow, buildNotificationRow, buildPagination, + buildPhoneInput, buildPrice, buildProgressBar, buildQuoteBlock, @@ -513,6 +514,11 @@ const CASES: BuilderCase[] = [ args: { width: 320, height: 200, icon: 'line-chart', title: 'No data yet', theme: 'dark' }, build: (a) => buildEmptyChartV1(a as unknown as Parameters[0]), }, + { + toolName: 'add_phone_input_v0', + args: { label: 'Phone number', country_code: '+1', country_flag: '๐Ÿ‡บ๐Ÿ‡ธ', value: '555 123 4567' }, + build: (a) => buildPhoneInput(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 1b272d735..f394064c9 100644 --- a/apps/web/src/services/ai/element-tool-shims/index.ts +++ b/apps/web/src/services/ai/element-tool-shims/index.ts @@ -62,6 +62,7 @@ import { buildToastV1, buildRangeSlider, buildEmptyChartV1, + buildPhoneInput, buildKbd, buildLink, buildListRow, @@ -136,6 +137,7 @@ import { type ToastV1Params, type RangeSliderParams, type EmptyChartV1Params, + type PhoneInputParams, type KbdParams, type LinkParams, type ListRowParams, @@ -329,6 +331,7 @@ export const ELEMENT_SHIMS: Record = { add_toast_v1: wrap(buildToastV1), add_range_slider_v0: wrap(buildRangeSlider), add_empty_chart_v1: wrap(buildEmptyChartV1), + add_phone_input_v0: wrap(buildPhoneInput), }; export function getElementShim(name: string): ElementShim | undefined { diff --git a/packages/pen-ai-skills/skills/phases/generation/elements.md b/packages/pen-ai-skills/skills/phases/generation/elements.md index 0beef609d..cb7de75ae 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements.md @@ -216,7 +216,9 @@ Input / forms: 65. Range slider (single-thumb horizontal slider showing current value: volume, opacity, brightness, price range) โ†’ `add_range_slider_v0` -66. None match โ†’ fall through to `batch_design` +66. International phone number input with country-code prefix selector โ†’ `add_phone_input_v0` + +67. 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. @@ -295,6 +297,7 @@ PREFER an element tool when the spec says any of: - "Continue with Google", "Sign in with Apple", "social login", "OAuth buttons", "SSO providers", "third-party login", "็ฌฌไธ‰ๆ–น็™ปๅฝ•", "็คพไบค็™ปๅฝ•", "OAuth ็™ปๅฝ•" โ†’ `add_social_login_row_v0` (orientation="vertical" for stacked full-width on mobile; orientation="horizontal" for the compact "or sign in with..." icon-only row) - "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`. STILL use batch_design when: @@ -548,6 +551,9 @@ add_range_slider_v0({ value: 128, min: 0, max: 255, label: "Brightness", show_va add_empty_chart_v1({ icon: "line-chart" }) // default light = v0 parity add_empty_chart_v1({ icon: "pie-chart", theme: "dark" }) // dashboard dark-mode "no data" slot add_empty_chart_v1({ icon: "bar-chart-2", theme: "system" }) // $color-* refs โ€” requires applySemanticPalette(doc) seeded + +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 ``` ## Composition pattern @@ -558,6 +564,102 @@ For a dashboard that needs a metric row inside a page: 2. Call `add_metric_row_v0({ parent_id: "", items: [...] })` to insert the row under that section 3. Optional: a second `batch_design` U-op to style (fill, theme variables) +## Common compositions (cookbook) + +When the user asks for a recognizable screen pattern (login, signup, +settings, paywall, dashboard tile row, support chat), STACK existing +element tools under one parent rather than reaching for batch_design. +The recipes below are by-frequency-of-real-use; each one fits in 4-7 +tool calls. + +### Login screen (phone + password + social) + +``` +batch_design: foo = I("page", { type: "frame", layout: "vertical", gap: 24, padding: [40, 24] }) +add_heading_v0({ parent_id: "", content: "Welcome back" }) +add_body_text_v0({ parent_id: "", content: "Sign in to continue" }) +add_phone_input_v0({ parent_id: "", label: "Phone number", country_code: "+1", country_flag: "๐Ÿ‡บ๐Ÿ‡ธ", required: true }) +add_form_field_v0({ parent_id: "", label: "Password", required: true }) +add_text_button_v0({ parent_id: "", label: "Sign in" }) // primary CTA +add_link_v0({ parent_id: "", label: "Forgot password?" }) +add_divider_v0({ parent_id: "" }) // "or continue with" โ€” caller adds text via batch_design after +add_social_login_row_v0({ parent_id: "", providers: [{ name: "Google" }, { name: "Apple" }] }) +``` + +### Signup form (email + password + agreement) + +``` +add_heading_v0({ parent_id: "
", content: "Create your account" }) +add_form_field_v0({ parent_id: "", label: "Email", required: true }) +add_form_field_v0({ parent_id: "", label: "Password", required: true }) +add_form_field_v0({ parent_id: "", label: "Confirm password", required: true }) +add_checkbox_v0({ parent_id: "", label: "I agree to the Terms of Service", checked: false }) +add_text_button_v0({ parent_id: "", label: "Sign up" }) +``` + +### Settings page (groups of list rows) + +``` +add_section_header_v0({ parent_id: "", title: "Account" }) +add_list_row_v0({ parent_id: "", title: "Profile", trailing_icon: "chevron-right" }) +add_list_row_v0({ parent_id: "", title: "Email", trailing_icon: "chevron-right" }) +add_divider_v0({ parent_id: "" }) +add_section_header_v0({ parent_id: "", title: "Notifications" }) +add_list_row_v0({ parent_id: "", title: "Push notifications", trailing_kind: "switch" }) +add_list_row_v0({ parent_id: "", title: "Email digest", trailing_kind: "switch" }) +``` + +### Pricing section (3 tiers, middle one featured) + +``` +batch_design: row = I("page", { type: "frame", layout: "horizontal", gap: 16 }) +add_pricing_card_v0({ parent_id: "", tier: "Starter", price: "0", period: "/month", features: ["3 projects", "Community support"] }) +add_pricing_card_v0({ parent_id: "", tier: "Pro", price: "29", period: "/month", features: ["Unlimited projects", "Priority support", "Advanced analytics"], emphasis: "featured" }) +add_pricing_card_v0({ parent_id: "", tier: "Enterprise", price: "Custom", features: ["Dedicated support", "SSO", "SLA"], cta: "Contact sales" }) +``` + +### Dashboard KPI strip (4 stat cards in a row) + +``` +batch_design: row = I("page", { type: "frame", layout: "horizontal", gap: 16 }) +add_stat_card_v0({ parent_id: "", label: "Revenue", value: "$12.4k", icon: "trending-up", delta: "+8%", trend: "up" }) +add_stat_card_v0({ parent_id: "", label: "Active users", value: "1,284", icon: "users", delta: "+3%", trend: "up" }) +add_stat_card_v0({ parent_id: "", label: "Churn", value: "3.2%", icon: "user-minus", delta: "-0.4%", trend: "down" }) +add_stat_card_v0({ parent_id: "", label: "Sessions", value: "5,471", icon: "activity" }) // no delta +``` + +### OTP / 2FA verification screen + +``` +add_heading_v0({ parent_id: "", content: "Enter verification code" }) +add_body_text_v0({ parent_id: "", content: "We sent a 6-digit code to +1 (555) 123-4567" }) +add_otp_input_v0({ parent_id: "", length: 6, digits: ["1", "2", "3"], focused_index: 3 }) +add_text_button_v0({ parent_id: "", label: "Verify" }) +add_link_v0({ parent_id: "", label: "Resend code" }) +``` + +### Support chat thread + +``` +add_chat_bubble_v0({ parent_id: "", message: "Hi! How can I help today?", side: "left", author: "Sarah", timestamp: "Just now" }) +add_chat_bubble_v0({ parent_id: "", message: "My order hasn't arrived.", side: "right", timestamp: "2m" }) +add_chat_bubble_v0({ parent_id: "", message: "Sorry to hear! Let me check on that.", side: "left", author: "Sarah", timestamp: "1m" }) +add_attachment_row_v0({ parent_id: "", filename: "receipt.pdf", size: "240 KB", icon: "file-text" }) +``` + +### Empty inbox / first-run onboarding + +``` +add_empty_state_v0({ parent_id: "", title: "No messages yet", subtitle: "When someone messages you, it'll show up here.", icon: "inbox", cta_label: "Find friends" }) +``` + +### Composition rules of thumb + +- **One parent for one row of siblings.** Don't pass `parent_id` of an unrelated container. +- **Order matters.** Tools insert as the LAST child of `parent_id`, so call sequence is render order top-to-bottom (vertical) or left-to-right (horizontal). +- **Don't mix N-tool and batch_design DSL ops in a single call.** They're separate calls โ€” chain them, don't merge. +- **Style overrides come AFTER structure.** First call the element tools to lay the structure, then a `batch_design` U-op to apply role-targeted fills / typography / variables. + ## Invariants you don't need to think about The tool guarantees โ€” you cannot break them from the input side: diff --git a/packages/pen-core/src/element-builders/index.ts b/packages/pen-core/src/element-builders/index.ts index 9ecc5cfe5..6d95c5df4 100644 --- a/packages/pen-core/src/element-builders/index.ts +++ b/packages/pen-core/src/element-builders/index.ts @@ -97,3 +97,4 @@ export { type EmptyChartV1Params, type EmptyChartV1Theme, } from './empty-chart-v1.js'; +export { buildPhoneInput, type PhoneInputParams } from './phone-input.js'; diff --git a/packages/pen-core/src/element-builders/phone-input.ts b/packages/pen-core/src/element-builders/phone-input.ts new file mode 100644 index 000000000..61c7342d9 --- /dev/null +++ b/packages/pen-core/src/element-builders/phone-input.ts @@ -0,0 +1,170 @@ +import type { ElementTree } from './helpers.js'; + +export interface PhoneInputParams { + /** Optional label above the input (e.g. "Phone number"). */ + label?: string; + /** Country code shown in the leading button. Default "+1". */ + country_code?: string; + /** Optional flag emoji or country abbreviation shown next to the dial code. */ + country_flag?: string; + /** Placeholder for the digits input. Default "(555) 555-5555". */ + placeholder?: string; + /** + * Pre-filled phone digits value (without country code). When set, + * renders as the populated state (slate-900 text); when omitted, + * renders as placeholder state (slate-400 text). + */ + value?: string; + /** When true, appends " *" to the label. */ + required?: boolean; + /** Total field width in px. Default 320. Min 240. */ + width?: number; +} + +const FIELD_HEIGHT = 44; + +/** + * Phone-number input with leading country-code selector โ€” the + * "+1 (555) โ€ฆ" pattern from every modern signup / login screen. + * Different from `add_form_field_v0` (single text input, no + * prefix); use this when the spec calls for an international + * phone field with country selector. + * + * Structure: + * frame(width, fit_content, vertical, gap=6, role='phone-input-field') + * โ”œ text(label, 13/500, role='form-label') โ† if label + * โ”” frame(horizontal, height=44, cornerRadius=10, stroke=slate-300, role='phone-input-row') + * โ”œ frame(country selector, fixed-width, role='phone-input-country') + * โ”‚ โ”œ text(flag, 16, role='phone-input-flag') โ† if flag + * โ”‚ โ”œ text(code, 14/500, role='phone-input-code') + * โ”‚ โ”” icon_font(chevron-down, 14, role='phone-input-chevron') + * โ”œ rectangle(divider, w=1, fill_container_height, role='phone-input-divider') + * โ”” frame(digits, fill_container, role='phone-input-digits') + * โ”” text(value-or-placeholder, 14/400, role='phone-input-digits-text') + * + * Country selector renders as a button-shape (no actual menu); the + * caller is expected to handle the picker UX as a separate concern. + */ +export function buildPhoneInput(params: PhoneInputParams): ElementTree { + const width = Math.max(240, Math.floor(params.width ?? 320)); + const code = params.country_code ?? '+1'; + const placeholder = params.placeholder ?? '(555) 555-5555'; + const isFilled = params.value !== undefined && params.value !== ''; + const digitsContent = isFilled ? params.value! : placeholder; + const digitsColor = isFilled ? '#0F172A' : '#94A3B8'; + + const countryChildren: ElementTree[] = []; + if (params.country_flag) { + countryChildren.push({ + type: 'text', + name: 'Flag', + role: 'phone-input-flag', + content: params.country_flag, + fontSize: 16, + fontWeight: 400, + }); + } + countryChildren.push({ + type: 'text', + name: 'Code', + role: 'phone-input-code', + content: code, + fontSize: 14, + fontWeight: 500, + fill: [{ type: 'solid', color: '#0F172A' }], + }); + countryChildren.push({ + type: 'icon_font', + name: 'Chevron', + role: 'phone-input-chevron', + iconFontName: 'chevron-down', + iconFontFamily: 'lucide', + width: 14, + height: 14, + fill: [{ type: 'solid', color: '#64748B' }], + }); + + const inputRow: ElementTree = { + type: 'frame', + name: 'Input Row', + role: 'phone-input-row', + width: 'fill_container', + height: FIELD_HEIGHT, + cornerRadius: 10, + layout: 'horizontal', + alignItems: 'center', + fill: [{ type: 'solid', color: '#FFFFFF' }], + stroke: { thickness: 1, fill: [{ type: 'solid', color: '#CBD5E1' }] }, + children: [ + { + type: 'frame', + name: 'Country', + role: 'phone-input-country', + width: 'fit_content', + height: 'fill_container', + layout: 'horizontal', + alignItems: 'center', + gap: 6, + paddingLeft: 14, + paddingRight: 12, + children: countryChildren, + }, + { + type: 'rectangle', + name: 'Divider', + role: 'phone-input-divider', + width: 1, + height: 28, + fill: [{ type: 'solid', color: '#E2E8F0' }], + }, + { + type: 'frame', + name: 'Digits', + role: 'phone-input-digits', + width: 'fill_container', + height: 'fill_container', + layout: 'horizontal', + alignItems: 'center', + paddingLeft: 14, + paddingRight: 14, + children: [ + { + type: 'text', + name: 'Digits Text', + role: 'phone-input-digits-text', + content: digitsContent, + fontSize: 14, + fontWeight: 400, + fill: [{ type: 'solid', color: digitsColor }], + }, + ], + }, + ], + }; + + const children: ElementTree[] = []; + if (params.label) { + const labelText = params.required ? `${params.label} *` : params.label; + children.push({ + type: 'text', + name: 'Label', + role: 'form-label', + content: labelText, + fontSize: 13, + fontWeight: 500, + fill: [{ type: 'solid', color: '#334155' }], + }); + } + children.push(inputRow); + + return { + type: 'frame', + name: 'Phone Input Field', + role: 'phone-input-field', + width, + height: 'fit_content', + layout: 'vertical', + gap: 6, + children, + }; +} diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index 4db28c390..48b0b0f90 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -260,6 +260,7 @@ export { buildToastV1, buildRangeSlider, buildEmptyChartV1, + buildPhoneInput, cjkFontFamily, detectCjkScript, type ElementTree, @@ -358,4 +359,5 @@ export { type RangeSliderParams, type EmptyChartV1Params, type EmptyChartV1Theme, + type PhoneInputParams, } from './element-builders/index.js'; diff --git a/packages/pen-mcp/src/__tests__/add-phone-input-v0.test.ts b/packages/pen-mcp/src/__tests__/add-phone-input-v0.test.ts new file mode 100644 index 000000000..ed7bf9f1e --- /dev/null +++ b/packages/pen-mcp/src/__tests__/add-phone-input-v0.test.ts @@ -0,0 +1,146 @@ +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 { handleAddPhoneInputV0 } from '../tools/add-phone-input-v0'; +import { invalidateCache } from '../document-manager'; + +const TMP = join(tmpdir(), 'openpencil-add-phone-input-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 ['p.op']) { + try { + const fp = join(TMP, f); + invalidateCache(fp); + await unlink(fp); + } catch {} + } +}); + +describe('add_phone_input_v0', () => { + it('registered; no required fields (all defaults)', () => { + expect(DESIGN_TOOL_NAMES.has('add_phone_input_v0')).toBe(true); + const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_phone_input_v0'); + const req = def?.inputSchema.required as string[] | undefined; + expect(req === undefined || req.length === 0).toBe(true); + }); + + it('defaults: country=+1, placeholder text, no label, slate-400 (placeholder) digits color', async () => { + const fp = await fresh('p.op'); + await handleAddPhoneInputV0({ filePath: fp }); + const root = getRoot(await readDoc(fp)); + expect(root.role).toBe('phone-input-field'); + expect(findByRole(root, 'phone-input-code')!.content).toBe('+1'); + expect(findByRole(root, 'phone-input-digits-text')!.content).toBe('(555) 555-5555'); + expect(fillColor(findByRole(root, 'phone-input-digits-text'))).toBe('#94A3B8'); + expect(findByRole(root, 'form-label')).toBeUndefined(); + expect(findByRole(root, 'phone-input-flag')).toBeUndefined(); + }); + + it('with value renders populated state in slate-900', async () => { + const fp = await fresh('p.op'); + await handleAddPhoneInputV0({ filePath: fp, value: '555 123 4567' }); + const root = getRoot(await readDoc(fp)); + expect(findByRole(root, 'phone-input-digits-text')!.content).toBe('555 123 4567'); + expect(fillColor(findByRole(root, 'phone-input-digits-text'))).toBe('#0F172A'); + }); + + it('label appears with required asterisk', async () => { + const fp = await fresh('p.op'); + await handleAddPhoneInputV0({ filePath: fp, label: 'Phone number', required: true }); + const root = getRoot(await readDoc(fp)); + expect(findByRole(root, 'form-label')!.content).toBe('Phone number *'); + }); + + it('country flag renders before code when provided', async () => { + const fp = await fresh('p.op'); + await handleAddPhoneInputV0({ + filePath: fp, + country_code: '+86', + country_flag: '๐Ÿ‡จ๐Ÿ‡ณ', + }); + const root = getRoot(await readDoc(fp)); + expect(findByRole(root, 'phone-input-flag')!.content).toBe('๐Ÿ‡จ๐Ÿ‡ณ'); + expect(findByRole(root, 'phone-input-code')!.content).toBe('+86'); + // Country block has flag before code (children order) + const country = findByRole(root, 'phone-input-country')!; + const countryKids = country.children as Array>; + expect(countryKids[0].role).toBe('phone-input-flag'); + expect(countryKids[1].role).toBe('phone-input-code'); + }); + + it('always emits a chevron-down icon for the country selector', async () => { + const fp = await fresh('p.op'); + await handleAddPhoneInputV0({ filePath: fp }); + const root = getRoot(await readDoc(fp)); + const chevron = findByRole(root, 'phone-input-chevron')!; + expect(chevron.iconFontName).toBe('chevron-down'); + expect(chevron.iconFontFamily).toBe('lucide'); + }); + + it('always emits the divider rectangle between country and digits', async () => { + const fp = await fresh('p.op'); + await handleAddPhoneInputV0({ filePath: fp }); + const root = getRoot(await readDoc(fp)); + const divider = findByRole(root, 'phone-input-divider')!; + expect(divider.type).toBe('rectangle'); + expect(divider.width).toBe(1); + }); + + it('row height is 44px (matches form-field standard)', async () => { + const fp = await fresh('p.op'); + await handleAddPhoneInputV0({ filePath: fp }); + const root = getRoot(await readDoc(fp)); + expect(findByRole(root, 'phone-input-row')!.height).toBe(44); + }); + + it('width clamps (< 240 โ†’ 240)', async () => { + const fp = await fresh('p.op'); + await handleAddPhoneInputV0({ filePath: fp, width: 100 }); + const root = getRoot(await readDoc(fp)); + expect(root.width).toBe(240); + }); + + it('throws on bogus parent_id AND leaves file untouched', async () => { + const fp = await fresh('p.op'); + const before = await readFile(fp, 'utf-8'); + await expect(handleAddPhoneInputV0({ 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/__tests__/element-tools-contract.test.ts b/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts index b9e38999c..8f3565082 100644 --- a/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts +++ b/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts @@ -82,6 +82,7 @@ const ELEMENT_TOOL_NAMES = [ 'add_toast_v1', 'add_range_slider_v0', 'add_empty_chart_v1', + 'add_phone_input_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 5fa746243..85ffe5b29 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 @@ -607,4 +607,48 @@ export const ELEMENT_TOOL_DEFINITIONS_EXT_3 = [ }, }, }, + { + name: 'add_phone_input_v0', + description: + 'International phone input with leading country-code selector โ€” the "+1 (555) โ€ฆ" pattern from ' + + 'every modern signup / login screen. Different from add_form_field_v0 (single text input, no ' + + 'prefix); use this when the spec calls for an international phone field with a country picker. ' + + 'Country selector renders as a button-shape (no actual menu); caller handles the picker UX as ' + + 'a separate concern. Set `value` to render the populated state (slate-900 text); omit for ' + + 'placeholder state (slate-400 text). Set `country_flag` (emoji or abbrev) to add a leading ' + + 'flag glyph next to the dial code. Use for "phone input", "phone field", "international phone", ' + + '"country code input", "็”ต่ฏๅท็ ่พ“ๅ…ฅ", "ๆ‰‹ๆœบๅท่พ“ๅ…ฅ". schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + label: { type: 'string', description: 'Optional label above the input' }, + country_code: { + type: 'string', + description: 'Dial code shown in the leading button (default "+1")', + }, + country_flag: { + type: 'string', + description: 'Optional flag emoji or country abbreviation shown next to the dial code', + }, + placeholder: { + type: 'string', + description: 'Placeholder for the digits input (default "(555) 555-5555")', + }, + value: { + type: 'string', + description: + 'Pre-filled phone digits (without country code). Omit for placeholder state.', + }, + required: { + type: 'boolean', + description: 'When true, appends " *" to the label', + }, + width: { type: 'number', description: 'Field width in px (default 320, min 240)' }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + }, + }, ]; diff --git a/packages/pen-mcp/src/routes/element-tool-defs.ts b/packages/pen-mcp/src/routes/element-tool-defs.ts index 9128a2362..f4f7b94ee 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs.ts @@ -88,6 +88,7 @@ import { handleAddPricingCardV0 } from '../tools/add-pricing-card-v0'; 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 { 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'; @@ -266,6 +267,8 @@ async function dispatchElementToolCall(name: string, a: any): Promise { return JSON.stringify(await handleAddRangeSliderV0(a), null, 2); case 'add_empty_chart_v1': return JSON.stringify(await handleAddEmptyChartV1(a), null, 2); + case 'add_phone_input_v0': + return JSON.stringify(await handleAddPhoneInputV0(a), null, 2); default: return ''; } diff --git a/packages/pen-mcp/src/tools/add-phone-input-v0.ts b/packages/pen-mcp/src/tools/add-phone-input-v0.ts new file mode 100644 index 000000000..6aced6174 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-phone-input-v0.ts @@ -0,0 +1,19 @@ +import { assignIdsRecursively, buildPhoneInput, type PhoneInputParams } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddPhoneInputV0Params extends PhoneInputParams { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** International phone input with country-code prefix selector. Tree build delegated to `buildPhoneInput`. */ +export async function handleAddPhoneInputV0( + params: AddPhoneInputV0Params, +): Promise>> { + await ensureParentExists(params); + const r = buildPhoneInput(params); + assignIdsRecursively(r); + return insertElementTree({ binding: 'phoneInput', tree: r, ...params }); +}