diff --git a/apps/web/server/api/mcp/exec-tool.post.ts b/apps/web/server/api/mcp/exec-tool.post.ts index 9b6827f10..0c5aa1c29 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 { buildModalShell, buildModalShellV1, buildUploadDropzone, + buildOtpInput, buildKbd, buildLink, buildListRow, @@ -191,6 +192,7 @@ const SERVER_BUILDERS: Record = { add_modal_shell_v1: (a) => buildModalShellV1(a as Parameters[0]), add_upload_dropzone_v0: (a) => buildUploadDropzone(a as Parameters[0]), + add_otp_input_v0: (a) => buildOtpInput(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 d9e535ac4..7534a2066 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 { buildModalShell, buildModalShellV1, buildUploadDropzone, + buildOtpInput, buildKbd, buildLink, buildListRow, @@ -447,6 +448,11 @@ const CASES: BuilderCase[] = [ args: { width: 480, title: 'Drop PDFs here', subtitle: 'Max 10 MB', icon: 'upload' }, build: (a) => buildUploadDropzone(a as unknown as Parameters[0]), }, + { + toolName: 'add_otp_input_v0', + args: { length: 6, digits: ['1', '2', '3'], focused_index: 3 }, + build: (a) => buildOtpInput(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 5f6336649..c5b8265ad 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 { buildModalShell, buildModalShellV1, buildUploadDropzone, + buildOtpInput, buildKbd, buildLink, buildListRow, @@ -118,6 +119,7 @@ import { type ModalShellParams, type ModalShellV1Params, type UploadDropzoneParams, + type OtpInputParams, type KbdParams, type LinkParams, type ListRowParams, @@ -302,6 +304,7 @@ export const ELEMENT_SHIMS: Record = { add_date_picker_v0: wrap(buildDatePicker), add_modal_shell_v1: wrap(buildModalShellV1), add_upload_dropzone_v0: wrap(buildUploadDropzone), + add_otp_input_v0: wrap(buildOtpInput), }; 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 69d49d075..99e1f6b3a 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements.md @@ -188,7 +188,11 @@ Upload / file intake: 58. File upload dropzone (dashed tile + cloud icon + "Drop files / click to browse") → `add_upload_dropzone_v0` -59. None match → fall through to `batch_design` +Auth / verification: + +59. OTP / PIN code input (row of N square slots, 4..8 digits; blank / partial / full states) → `add_otp_input_v0` + +60. 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. @@ -258,6 +262,7 @@ PREFER an element tool when the spec says any of: - "action menu", "context menu", "dropdown menu", "more menu", "kebab menu", "action sheet", "下拉菜单", "操作菜单" → `add_action_menu_v0` - "date picker", "date input", "date field", "due date", "picker closed", "日期选择器", "日期输入" → `add_date_picker_v0` (for the calendar grid shown after clicking, use `add_calendar_grid_v0`) - "upload", "drop files here", "drag and drop", "file picker", "dropzone", "upload area", "上传区", "文件拖放" → `add_upload_dropzone_v0` (visually similar to empty-chart but semantically different — pick by intent) +- "OTP", "PIN code", "verification code", "2FA code", "6-digit code", "enter code", "验证码", "PIN 码" → `add_otp_input_v0` STILL use batch_design when: @@ -480,6 +485,10 @@ add_date_picker_v0({ label: "Due date", value: "Jan 15, 2026", clearable: true } add_upload_dropzone_v0({}) // default 480×200 cloud icon add_upload_dropzone_v0({ icon: "file-up", title: "Drop resume here", subtitle: "PDF or DOCX, max 5 MB" }) + +add_otp_input_v0({}) // 6 blank slots, first focused +add_otp_input_v0({ length: 6, digits: ["1","2","3"], focused_index: 3 }) // partial state, 4th slot focused +add_otp_input_v0({ length: 4, digits: ["1","2","3","4"] }) // 4-digit PIN, all filled ``` ## Composition pattern diff --git a/packages/pen-core/src/element-builders/index.ts b/packages/pen-core/src/element-builders/index.ts index d667f52e1..83a3153f6 100644 --- a/packages/pen-core/src/element-builders/index.ts +++ b/packages/pen-core/src/element-builders/index.ts @@ -80,3 +80,4 @@ export { type ModalShellV1Theme, } from './modal-shell-v1.js'; export { buildUploadDropzone, type UploadDropzoneParams } from './upload-dropzone.js'; +export { buildOtpInput, type OtpInputParams } from './otp-input.js'; diff --git a/packages/pen-core/src/element-builders/otp-input.ts b/packages/pen-core/src/element-builders/otp-input.ts new file mode 100644 index 000000000..7a40da700 --- /dev/null +++ b/packages/pen-core/src/element-builders/otp-input.ts @@ -0,0 +1,107 @@ +import type { ElementTree } from './helpers.js'; + +export interface OtpInputParams { + /** Number of code digits (slots). Clamped 4..8. Default 6. */ + length?: number; + /** + * Optional digits to render inside filled slots. When provided, + * `digits[i]` fills slot `i`; omitted / shorter-than-length + * arrays leave the remaining slots empty. Pass an empty array + * (or omit) to render the blank "awaiting input" state. + */ + digits?: string[]; + /** Index of the currently-focused slot (0-based). Default 0. */ + focused_index?: number; + /** Slot size in px (square). Default 48. Clamped 32..80. */ + slot_size?: number; + /** Gap between slots. Default 12. Clamped 0..24. */ + gap?: number; + /** Primary accent color for the focused-slot border. Default #2563EB. */ + accent_color?: string; +} + +/** + * OTP / PIN code input — horizontal row of N square slots, each + * holding one digit. Common in 2FA verification and PIN unlock + * screens. Renders the caller-chosen state: + * + * - blank: all N slots empty (awaiting input) + * - partial: first M slots filled, the focused one outlined, the + * rest empty + * - full: all N slots filled (final submittable state) + * + * Structure: + * frame(horizontal, gap, role='otp-input', fit_content) + * └ frame(slot_size × slot_size, cornerRadius=8, border, + * role='otp-slot' | 'otp-slot-focused' | 'otp-slot-filled', + * layout=horizontal, center/center) + * └ text(digit, 20/600) [only when filled] + * + * Focused slot gets the accent-color border; filled slots get a + * solid slate-700 border; empty unfocused slots get a slate-300 + * border. Caller composes this with a labeled form wrapper if + * needed (v0 purposefully does NOT embed the "Enter code" label + * — callers vary widely). + */ +export function buildOtpInput(params: OtpInputParams): ElementTree { + const length = Math.max(4, Math.min(8, Math.floor(params.length ?? 6))); + const digits = params.digits ?? []; + const focusedIndex = Math.max(0, Math.min(length - 1, Math.floor(params.focused_index ?? 0))); + const slotSize = Math.max(32, Math.min(80, Math.floor(params.slot_size ?? 48))); + const gap = Math.max(0, Math.min(24, Math.floor(params.gap ?? 12))); + const accent = params.accent_color ?? '#2563EB'; + + const children: ElementTree[] = []; + for (let i = 0; i < length; i += 1) { + const digit = digits[i]; + const isFilled = typeof digit === 'string' && digit.length > 0; + const isFocused = i === focusedIndex && !isFilled; + + const borderColor = isFocused ? accent : isFilled ? '#334155' : '#CBD5E1'; + const role = isFocused ? 'otp-slot-focused' : isFilled ? 'otp-slot-filled' : 'otp-slot'; + + const slotChildren: ElementTree[] = isFilled + ? [ + { + type: 'text', + name: 'Digit', + role: 'otp-digit', + content: digit, + fontSize: 20, + fontWeight: 600, + fill: [{ type: 'solid', color: '#0F172A' }], + }, + ] + : []; + + children.push({ + type: 'frame', + name: `Slot ${i + 1}`, + role, + width: slotSize, + height: slotSize, + cornerRadius: 8, + layout: 'horizontal', + alignItems: 'center', + justifyContent: 'center', + fill: [{ type: 'solid', color: '#FFFFFF' }], + stroke: { + thickness: isFocused ? 2 : 1, + fill: [{ type: 'solid', color: borderColor }], + }, + children: slotChildren, + }); + } + + return { + type: 'frame', + name: 'OTP Input', + role: 'otp-input', + width: 'fit_content', + height: 'fit_content', + layout: 'horizontal', + alignItems: 'center', + gap, + children, + }; +} diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index 3ea1ecac8..f96e115de 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -251,6 +251,7 @@ export { buildDatePicker, buildModalShellV1, buildUploadDropzone, + buildOtpInput, cjkFontFamily, detectCjkScript, type ElementTree, @@ -335,4 +336,5 @@ export { type ModalShellV1Params, type ModalShellV1Theme, type UploadDropzoneParams, + type OtpInputParams, } from './element-builders/index.js'; diff --git a/packages/pen-mcp/src/__tests__/add-otp-input-v0.test.ts b/packages/pen-mcp/src/__tests__/add-otp-input-v0.test.ts new file mode 100644 index 000000000..ef41995f4 --- /dev/null +++ b/packages/pen-mcp/src/__tests__/add-otp-input-v0.test.ts @@ -0,0 +1,133 @@ +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 { handleAddOtpInputV0 } from '../tools/add-otp-input-v0'; +import { invalidateCache } from '../document-manager'; + +const TMP = join(tmpdir(), 'openpencil-add-otp-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; +} + +beforeEach(async () => { + await mkdir(TMP, { recursive: true }); +}); +afterEach(async () => { + for (const f of ['o.op']) { + try { + const fp = join(TMP, f); + invalidateCache(fp); + await unlink(fp); + } catch {} + } +}); + +describe('add_otp_input_v0', () => { + it('registered; required=[] (all optional)', () => { + expect(DESIGN_TOOL_NAMES.has('add_otp_input_v0')).toBe(true); + const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_otp_input_v0'); + expect(def?.inputSchema.required).toEqual([]); + }); + + it('defaults: 6 slots, first focused (accent outline), rest empty', async () => { + const fp = await fresh('o.op'); + await handleAddOtpInputV0({ filePath: fp }); + const root = getRoot(await readDoc(fp)); + expect(root.role).toBe('otp-input'); + const slots = root.children as Record[]; + expect(slots.length).toBe(6); + expect(slots[0].role).toBe('otp-slot-focused'); + expect(slots[1].role).toBe('otp-slot'); + // Focused slot has 2px stroke, accent color + const focusedStroke = slots[0].stroke as { thickness: number; fill: Array<{ color: string }> }; + expect(focusedStroke.thickness).toBe(2); + expect(focusedStroke.fill[0].color).toBe('#2563EB'); + }); + + it('partial state: filled slots have digit text + role=otp-slot-filled', async () => { + const fp = await fresh('o.op'); + await handleAddOtpInputV0({ + filePath: fp, + length: 6, + digits: ['1', '2', '3'], + focused_index: 3, + }); + const root = getRoot(await readDoc(fp)); + const slots = root.children as Record[]; + // Slots 0..2 filled, 3 focused, 4..5 empty + expect(slots[0].role).toBe('otp-slot-filled'); + expect(slots[1].role).toBe('otp-slot-filled'); + expect(slots[2].role).toBe('otp-slot-filled'); + expect(slots[3].role).toBe('otp-slot-focused'); + expect(slots[4].role).toBe('otp-slot'); + // Filled slots have a text child with the digit + const filledKids = slots[0].children as Record[]; + expect(filledKids.length).toBe(1); + expect(filledKids[0].content).toBe('1'); + expect(filledKids[0].role).toBe('otp-digit'); + }); + + it('full state: all slots filled, none focused', async () => { + const fp = await fresh('o.op'); + await handleAddOtpInputV0({ + filePath: fp, + length: 4, + digits: ['1', '2', '3', '4'], + focused_index: 0, // even though index=0, slot 0 is filled so takes filled role + }); + const root = getRoot(await readDoc(fp)); + const slots = root.children as Record[]; + expect(slots.length).toBe(4); + for (const s of slots) { + expect(s.role).toBe('otp-slot-filled'); + } + }); + + it('length clamps below to 4', async () => { + const fp = await fresh('o.op'); + await handleAddOtpInputV0({ filePath: fp, length: 2 }); + const root = getRoot(await readDoc(fp)); + expect((root.children as unknown[]).length).toBe(4); + }); + + it('length clamps above to 8', async () => { + const fp = await fresh('o.op'); + await handleAddOtpInputV0({ filePath: fp, length: 20 }); + const root = getRoot(await readDoc(fp)); + expect((root.children as unknown[]).length).toBe(8); + }); + + it('accent_color override changes the focused-slot border', async () => { + const fp = await fresh('o.op'); + await handleAddOtpInputV0({ filePath: fp, accent_color: '#EF4444' }); + const root = getRoot(await readDoc(fp)); + const focused = (root.children as Record[])[0]; + const stroke = focused.stroke as { fill: Array<{ color: string }> }; + expect(stroke.fill[0].color).toBe('#EF4444'); + }); + + it('throws on bogus parent_id AND leaves file untouched', async () => { + const fp = await fresh('o.op'); + const before = await readFile(fp, 'utf-8'); + await expect(handleAddOtpInputV0({ 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 ce916565f..11c982234 100644 --- a/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts +++ b/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts @@ -73,6 +73,7 @@ const ELEMENT_TOOL_NAMES = [ 'add_date_picker_v0', 'add_modal_shell_v1', 'add_upload_dropzone_v0', + 'add_otp_input_v0', ]; describe('element tools — v0-MUST contract', () => { diff --git a/packages/pen-mcp/src/routes/element-tool-defs-ext-2.ts b/packages/pen-mcp/src/routes/element-tool-defs-ext-2.ts index 6d98bf7e7..dfb627816 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs-ext-2.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs-ext-2.ts @@ -741,4 +741,47 @@ export const ELEMENT_TOOL_DEFINITIONS_EXT_2 = [ required: ['total'], }, }, + { + name: 'add_otp_input_v0', + description: + 'OTP / PIN code input — horizontal row of N square slots, one digit per slot. Common in 2FA ' + + 'verification, PIN unlock, email/phone confirmation flows. Renders the blank awaiting-input ' + + 'state by default, or a partial/full state if `digits` is supplied. The `focused_index` ' + + 'slot shows an accent-color 2px outline (the "currently typing here" visual). Use for ' + + '"OTP input", "PIN code", "verification code", "6-digit code", "2FA code", "验证码", "PIN 码". ' + + 'schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + length: { + type: 'number', + description: 'Number of code slots (clamped 4..8, default 6)', + }, + digits: { + type: 'array', + description: + 'Optional digits — digits[i] fills slot i. Omit (or pass shorter array) for the blank / partial state.', + items: { type: 'string' }, + }, + focused_index: { + type: 'number', + description: '0-based index of the slot with the accent outline (default 0)', + }, + slot_size: { + type: 'number', + description: 'Slot side length in px (clamped 32..80, default 48)', + }, + gap: { type: 'number', description: 'Gap between slots in px (clamped 0..24, default 12)' }, + accent_color: { + type: 'string', + description: 'Hex color for the focused-slot border (default #2563EB)', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: [], + }, + }, ]; diff --git a/packages/pen-mcp/src/routes/element-tool-defs.ts b/packages/pen-mcp/src/routes/element-tool-defs.ts index 61aa9ba7d..8c9103543 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs.ts @@ -79,6 +79,7 @@ import { handleAddActionMenuV0 } from '../tools/add-action-menu-v0'; import { handleAddDatePickerV0 } from '../tools/add-date-picker-v0'; import { handleAddModalShellV1 } from '../tools/add-modal-shell-v1'; import { handleAddUploadDropzoneV0 } from '../tools/add-upload-dropzone-v0'; +import { handleAddOtpInputV0 } from '../tools/add-otp-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'; @@ -237,6 +238,8 @@ async function dispatchElementToolCall(name: string, a: any): Promise { return JSON.stringify(await handleAddModalShellV1(a), null, 2); case 'add_upload_dropzone_v0': return JSON.stringify(await handleAddUploadDropzoneV0(a), null, 2); + case 'add_otp_input_v0': + return JSON.stringify(await handleAddOtpInputV0(a), null, 2); default: return ''; } diff --git a/packages/pen-mcp/src/tools/add-otp-input-v0.ts b/packages/pen-mcp/src/tools/add-otp-input-v0.ts new file mode 100644 index 000000000..1ef5a6f0b --- /dev/null +++ b/packages/pen-mcp/src/tools/add-otp-input-v0.ts @@ -0,0 +1,19 @@ +import { assignIdsRecursively, buildOtpInput, type OtpInputParams } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddOtpInputV0Params extends OtpInputParams { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** OTP / PIN code input. Tree build delegated to `buildOtpInput`. */ +export async function handleAddOtpInputV0( + params: AddOtpInputV0Params, +): Promise>> { + await ensureParentExists(params); + const o = buildOtpInput(params); + assignIdsRecursively(o); + return insertElementTree({ binding: 'otp', tree: o, ...params }); +}