feat(ai): add_phone_input_v0 — international phone input (74th tool)

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).
This commit is contained in:
Fini 2026-04-25 07:30:00 +08:00
parent 008bf0ca46
commit 577488546a
12 changed files with 500 additions and 1 deletions

View file

@ -44,6 +44,7 @@ import {
buildToastV1,
buildRangeSlider,
buildEmptyChartV1,
buildPhoneInput,
buildKbd,
buildLink,
buildListRow,
@ -210,6 +211,7 @@ const SERVER_BUILDERS: Record<string, BuilderFn> = {
add_toast_v1: (a) => buildToastV1(a as Parameters<typeof buildToastV1>[0]),
add_range_slider_v0: (a) => buildRangeSlider(a as Parameters<typeof buildRangeSlider>[0]),
add_empty_chart_v1: (a) => buildEmptyChartV1(a as Parameters<typeof buildEmptyChartV1>[0]),
add_phone_input_v0: (a) => buildPhoneInput(a as Parameters<typeof buildPhoneInput>[0]),
};
interface ExecToolBody {

View file

@ -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<typeof buildEmptyChartV1>[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<typeof buildPhoneInput>[0]),
},
];
/**

View file

@ -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<string, ElementShim> = {
add_toast_v1: wrap<ToastV1Params>(buildToastV1),
add_range_slider_v0: wrap<RangeSliderParams>(buildRangeSlider),
add_empty_chart_v1: wrap<EmptyChartV1Params>(buildEmptyChartV1),
add_phone_input_v0: wrap<PhoneInputParams>(buildPhoneInput),
};
export function getElementShim(name: string): ElementShim | undefined {

View file

@ -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: "<section-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: "<foo>", content: "Welcome back" })
add_body_text_v0({ parent_id: "<foo>", content: "Sign in to continue" })
add_phone_input_v0({ parent_id: "<foo>", label: "Phone number", country_code: "+1", country_flag: "🇺🇸", required: true })
add_form_field_v0({ parent_id: "<foo>", label: "Password", required: true })
add_text_button_v0({ parent_id: "<foo>", label: "Sign in" }) // primary CTA
add_link_v0({ parent_id: "<foo>", label: "Forgot password?" })
add_divider_v0({ parent_id: "<foo>" }) // "or continue with" — caller adds text via batch_design after
add_social_login_row_v0({ parent_id: "<foo>", providers: [{ name: "Google" }, { name: "Apple" }] })
```
### Signup form (email + password + agreement)
```
add_heading_v0({ parent_id: "<form>", content: "Create your account" })
add_form_field_v0({ parent_id: "<form>", label: "Email", required: true })
add_form_field_v0({ parent_id: "<form>", label: "Password", required: true })
add_form_field_v0({ parent_id: "<form>", label: "Confirm password", required: true })
add_checkbox_v0({ parent_id: "<form>", label: "I agree to the Terms of Service", checked: false })
add_text_button_v0({ parent_id: "<form>", label: "Sign up" })
```
### Settings page (groups of list rows)
```
add_section_header_v0({ parent_id: "<page>", title: "Account" })
add_list_row_v0({ parent_id: "<page>", title: "Profile", trailing_icon: "chevron-right" })
add_list_row_v0({ parent_id: "<page>", title: "Email", trailing_icon: "chevron-right" })
add_divider_v0({ parent_id: "<page>" })
add_section_header_v0({ parent_id: "<page>", title: "Notifications" })
add_list_row_v0({ parent_id: "<page>", title: "Push notifications", trailing_kind: "switch" })
add_list_row_v0({ parent_id: "<page>", 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: "<row>", tier: "Starter", price: "0", period: "/month", features: ["3 projects", "Community support"] })
add_pricing_card_v0({ parent_id: "<row>", tier: "Pro", price: "29", period: "/month", features: ["Unlimited projects", "Priority support", "Advanced analytics"], emphasis: "featured" })
add_pricing_card_v0({ parent_id: "<row>", 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: "<row>", label: "Revenue", value: "$12.4k", icon: "trending-up", delta: "+8%", trend: "up" })
add_stat_card_v0({ parent_id: "<row>", label: "Active users", value: "1,284", icon: "users", delta: "+3%", trend: "up" })
add_stat_card_v0({ parent_id: "<row>", label: "Churn", value: "3.2%", icon: "user-minus", delta: "-0.4%", trend: "down" })
add_stat_card_v0({ parent_id: "<row>", label: "Sessions", value: "5,471", icon: "activity" }) // no delta
```
### OTP / 2FA verification screen
```
add_heading_v0({ parent_id: "<page>", content: "Enter verification code" })
add_body_text_v0({ parent_id: "<page>", content: "We sent a 6-digit code to +1 (555) 123-4567" })
add_otp_input_v0({ parent_id: "<page>", length: 6, digits: ["1", "2", "3"], focused_index: 3 })
add_text_button_v0({ parent_id: "<page>", label: "Verify" })
add_link_v0({ parent_id: "<page>", label: "Resend code" })
```
### Support chat thread
```
add_chat_bubble_v0({ parent_id: "<thread>", message: "Hi! How can I help today?", side: "left", author: "Sarah", timestamp: "Just now" })
add_chat_bubble_v0({ parent_id: "<thread>", message: "My order hasn't arrived.", side: "right", timestamp: "2m" })
add_chat_bubble_v0({ parent_id: "<thread>", message: "Sorry to hear! Let me check on that.", side: "left", author: "Sarah", timestamp: "1m" })
add_attachment_row_v0({ parent_id: "<thread>", filename: "receipt.pdf", size: "240 KB", icon: "file-text" })
```
### Empty inbox / first-run onboarding
```
add_empty_state_v0({ parent_id: "<page>", 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:

View file

@ -97,3 +97,4 @@ export {
type EmptyChartV1Params,
type EmptyChartV1Theme,
} from './empty-chart-v1.js';
export { buildPhoneInput, type PhoneInputParams } from './phone-input.js';

View file

@ -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,
};
}

View file

@ -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';

View file

@ -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<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;
}
function findByRole(n: Record<string, unknown>, role: string): Record<string, unknown> | undefined {
if (n.role === role) return n;
const kids = (n.children ?? []) as Record<string, unknown>[];
for (const c of kids) {
const hit = findByRole(c, role);
if (hit) return hit;
}
return undefined;
}
function fillColor(n: Record<string, unknown> | 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<Record<string, unknown>>;
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);
});
});

View file

@ -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', () => {

View file

@ -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,
},
},
},
];

View file

@ -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<string> {
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 '';
}

View file

@ -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<Awaited<ReturnType<typeof handleBatchDesign>>> {
await ensureParentExists(params);
const r = buildPhoneInput(params);
assignIdsRecursively(r);
return insertElementTree({ binding: 'phoneInput', tree: r, ...params });
}