feat(ai): add_otp_input_v0 — verification code input (65th tool)
Fills the auth-flow gap: 2FA / PIN / phone-verification codes.
Horizontal row of N square slots (4..8), one digit per slot.
Renders three states per caller intent:
- blank (no `digits`): all slots empty, `focused_index` marks
the currently-typing slot with an accent-color 2px outline
- partial: first M slots filled with digit text, slot M+1
focused, rest empty
- full: all N slots filled (final submittable state)
Filled slots get role=otp-slot-filled + slate-700 border + 20/600
digit text. Focused empty slot gets role=otp-slot-focused +
2px accent border. Blank unfocused slots get role=otp-slot +
1px slate-300 border.
Wired through all standard points — schema into ext-2 (shorter
shard) + shim + SERVER_BUILDERS + parity CASES + contract
allow-list + elements.md decision tree + triggers + minimal
usage for each state.
Handler test covers 8 cases: registration + defaults (6 blank
focused-first) + partial state / full state / length clamp low
(< 4 → 4) + length clamp high (> 8 → 8) + accent color override
+ bogus parent_id rejection.
This commit is contained in:
parent
45214efb70
commit
dc73885015
|
|
@ -35,6 +35,7 @@ import {
|
|||
buildModalShell,
|
||||
buildModalShellV1,
|
||||
buildUploadDropzone,
|
||||
buildOtpInput,
|
||||
buildKbd,
|
||||
buildLink,
|
||||
buildListRow,
|
||||
|
|
@ -191,6 +192,7 @@ const SERVER_BUILDERS: Record<string, BuilderFn> = {
|
|||
add_modal_shell_v1: (a) => buildModalShellV1(a as Parameters<typeof buildModalShellV1>[0]),
|
||||
add_upload_dropzone_v0: (a) =>
|
||||
buildUploadDropzone(a as Parameters<typeof buildUploadDropzone>[0]),
|
||||
add_otp_input_v0: (a) => buildOtpInput(a as Parameters<typeof buildOtpInput>[0]),
|
||||
};
|
||||
|
||||
interface ExecToolBody {
|
||||
|
|
|
|||
|
|
@ -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<typeof buildUploadDropzone>[0]),
|
||||
},
|
||||
{
|
||||
toolName: 'add_otp_input_v0',
|
||||
args: { length: 6, digits: ['1', '2', '3'], focused_index: 3 },
|
||||
build: (a) => buildOtpInput(a as unknown as Parameters<typeof buildOtpInput>[0]),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<string, ElementShim> = {
|
|||
add_date_picker_v0: wrap<DatePickerParams>(buildDatePicker),
|
||||
add_modal_shell_v1: wrap<ModalShellV1Params>(buildModalShellV1),
|
||||
add_upload_dropzone_v0: wrap<UploadDropzoneParams>(buildUploadDropzone),
|
||||
add_otp_input_v0: wrap<OtpInputParams>(buildOtpInput),
|
||||
};
|
||||
|
||||
export function getElementShim(name: string): ElementShim | undefined {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
107
packages/pen-core/src/element-builders/otp-input.ts
Normal file
107
packages/pen-core/src/element-builders/otp-input.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
133
packages/pen-mcp/src/__tests__/add-otp-input-v0.test.ts
Normal file
133
packages/pen-mcp/src/__tests__/add-otp-input-v0.test.ts
Normal file
|
|
@ -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<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;
|
||||
}
|
||||
|
||||
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<string, unknown>[];
|
||||
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<string, unknown>[];
|
||||
// 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<string, unknown>[];
|
||||
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<string, unknown>[];
|
||||
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<string, unknown>[])[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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
|||
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 '';
|
||||
}
|
||||
|
|
|
|||
19
packages/pen-mcp/src/tools/add-otp-input-v0.ts
Normal file
19
packages/pen-mcp/src/tools/add-otp-input-v0.ts
Normal file
|
|
@ -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<Awaited<ReturnType<typeof handleBatchDesign>>> {
|
||||
await ensureParentExists(params);
|
||||
const o = buildOtpInput(params);
|
||||
assignIdsRecursively(o);
|
||||
return insertElementTree({ binding: 'otp', tree: o, ...params });
|
||||
}
|
||||
Loading…
Reference in a new issue