diff --git a/.changeset/11365-input-otp-separator.md b/.changeset/11365-input-otp-separator.md
new file mode 100644
index 0000000000..0f2ede8930
--- /dev/null
+++ b/.changeset/11365-input-otp-separator.md
@@ -0,0 +1,12 @@
+---
+'@object-ui/components': minor
+'@object-ui/types': minor
+---
+
+`input-otp` draws the separator its docs page teaches (objectui#11365). The "With Separator" example on the `input-otp` docs page and two catalog entries (`with-visual-separator`, and the `input-otp` inside `verification-form`) author `separator: true`, but no type declared the key and the renderer read nothing for it, so they drew no separator at all. Triage ruled the enforce arm of ADR-0049: the shipped primitive already exports `InputOTPSeparator`, so the key is declared and drawn, and the docs page and catalog entries stay as they are.
+
+**Components.** With `separator: true`, the `input-otp` renderer splits its `length` slots into two groups at the midpoint and draws one `InputOTPSeparator` (`role="separator"`) between them, the shadcn "with separator" layout. An odd `length` puts the extra slot in the first group (5 slots draw as 3 and 2). One slot has nothing to separate and draws no separator. Without `separator`, or with `separator: false`, the markup is unchanged: one group holding every slot. The `input-otp` registration publishes `separator` as a boolean input.
+
+**Types.** `InputOTPSchema` gains `separator?: boolean` on both faces. The TypeScript face now refuses a non-boolean `separator`; before, `BaseSchema`'s index signature admitted any value. The zod mirror refuses a non-boolean `separator` with `invalid_type` at the key; before, `.passthrough()` kept it unjudged. The strict authoring face now accepts `separator`, where it used to report it as an unrecognized key. The "What it renders instead" list in the node's `body` / `children` refusal messages now names `separator`.
+
+**minor, not patch — the published face gains a member.** `separator` is a new member of the shipped `.d.ts` and of the mirror's `.shape`, and the mirror refuses a non-boolean value by name.
diff --git a/examples/schema-catalog/test/input-otp-separator-11365.test.tsx b/examples/schema-catalog/test/input-otp-separator-11365.test.tsx
new file mode 100644
index 0000000000..1ed1a3d218
--- /dev/null
+++ b/examples/schema-catalog/test/input-otp-separator-11365.test.tsx
@@ -0,0 +1,140 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectui#11365 — `input-otp` draws the separator its docs page and two
+ * catalog entries author.
+ *
+ * The docs page's "With Separator" section shows
+ * `components-form-input-otp/with-visual-separator`, and
+ * `components-form-input-otp/verification-form` authors the same key on an
+ * `input-otp` nested in a `flex` node's `properties` bag. Before this card no
+ * type declared `separator` on `InputOTPSchema` and the renderer read nothing
+ * for it, so both entries rendered ZERO `role="separator"` elements through the
+ * real `SchemaRenderer`. Triage ruled the enforce arm (ADR-0049): declare the
+ * key on both faces and draw the primitive's `InputOTPSeparator`.
+ *
+ * ## What each block pins
+ *
+ * - The render block counts the `role="separator"` elements the renderer
+ * actually emitted, on the two authoring entries and on the entry that does
+ * NOT author the key (the control: without `separator` nothing changes).
+ * - The parse block asks both faces. `safeValidateSchema` is `.passthrough()`,
+ * so a green there says nothing about an UNDECLARED key; the strict authoring
+ * face closes unknown keys, so a green there is the declaration's evidence.
+ * The value probe (a non-boolean `separator`) is what proves the declared
+ * member judges its value rather than merely being tolerated.
+ */
+import { describe, it, expect } from 'vitest';
+import { render } from '@testing-library/react';
+// Module scope, not a hook: registering the renderers is an unbounded module
+// load (AGENTS.md, test discipline — flaky tests: find the race).
+import '@object-ui/components';
+import { ComponentRegistry } from '@object-ui/core';
+import { SchemaRenderer, toRenderableSchema } from '@object-ui/react';
+import { safeValidateSchema, StrictAnyComponentSchema } from '@object-ui/types/zod';
+import { getExample } from '../src/index.js';
+
+const AUTHORING = [
+ 'components-form-input-otp/with-visual-separator',
+ 'components-form-input-otp/verification-form',
+] as const;
+
+/** Every issue as `code path: message`, so a red run says what broke. */
+function issuesOf(result: { success: boolean; error?: { issues: Array<{ code: string; path: PropertyKey[]; message: string }> } }): string[] {
+ return result.success || !result.error
+ ? []
+ : result.error.issues.map((i) => `${i.code} ${i.path.map(String).join('.')}: ${i.message}`);
+}
+
+function renderNode(schema: unknown): HTMLElement {
+ const { container } = render();
+ return container;
+}
+
+/**
+ * The slot count of each `InputOTPGroup`, in document order. The container's
+ * other children are the separator and the library's own wrapper around the
+ * hidden ``, which is not a group.
+ */
+function groupSizes(container: HTMLElement): number[] {
+ const host = container.querySelector('[data-input-otp-container]');
+ expect(host, 'no input-otp container rendered: nothing below would be a reading').not.toBeNull();
+ return Array.from((host as Element).children)
+ .filter((el) => el.getAttribute('role') !== 'separator' && !el.querySelector('input'))
+ .map((group) => group.children.length);
+}
+
+describe('objectui#11365 — the authored `separator` draws one InputOTPSeparator', () => {
+ it.each(AUTHORING)('%s renders exactly one role="separator"', (id) => {
+ const container = renderNode(getExample(id).schema);
+ expect(container.querySelector('[data-input-otp-container]'), 'no input-otp rendered').not.toBeNull();
+ expect(container.querySelectorAll('[role="separator"]')).toHaveLength(1);
+ });
+
+ it('the separator splits the six slots at the midpoint, between two groups', () => {
+ const container = renderNode(getExample('components-form-input-otp/with-visual-separator').schema);
+ expect(groupSizes(container)).toEqual([3, 3]);
+ });
+
+ it('an odd `length` puts the extra slot in the first group; one slot draws no separator', () => {
+ const five = renderNode({ type: 'input-otp', length: 5, separator: true });
+ expect(groupSizes(five)).toEqual([3, 2]);
+ expect(five.querySelectorAll('[role="separator"]')).toHaveLength(1);
+
+ const one = renderNode({ type: 'input-otp', length: 1, separator: true });
+ expect(groupSizes(one)).toEqual([1]);
+ expect(one.querySelectorAll('[role="separator"]')).toHaveLength(0);
+ });
+
+ it('the control: the entry without `separator` draws none, in one group', () => {
+ const container = renderNode(getExample('components-form-input-otp/6-digit-otp').schema);
+ expect(container.querySelectorAll('[role="separator"]')).toHaveLength(0);
+ expect(groupSizes(container)).toEqual([6]);
+ });
+
+ it('the registration publishes `separator` as a boolean input', () => {
+ // `inputs` is the html/jsx page tier's prop whitelist: without this row,
+ // `separator` on an `input-otp` tag draws an `unknown-prop` warning on a
+ // key the renderer honours (measured once on objectui#11365's PR).
+ const inputs = ComponentRegistry.getMeta('input-otp')?.inputs ?? [];
+ expect(inputs.filter((i) => i.name === 'separator')).toEqual([
+ expect.objectContaining({ name: 'separator', type: 'boolean' }),
+ ]);
+ });
+
+ it('`separator: false` renders the same markup as no `separator` at all', () => {
+ const absent = renderNode({ type: 'input-otp', length: 6 }).innerHTML;
+ const off = renderNode({ type: 'input-otp', length: 6, separator: false }).innerHTML;
+ expect(off).toBe(absent);
+ });
+});
+
+describe('objectui#11365 — both authoring entries validate, on both faces', () => {
+ it.each(AUTHORING)('%s validates under safeValidateSchema', (id) => {
+ expect(issuesOf(safeValidateSchema(getExample(id).schema))).toEqual([]);
+ });
+
+ it.each(AUTHORING)('%s validates under the strict authoring face (no unrecognized `separator`)', (id) => {
+ expect(issuesOf(StrictAnyComponentSchema.safeParse(getExample(id).schema))).toEqual([]);
+ });
+
+ it('a non-boolean `separator` is refused by value, at its path, in a bag child too', () => {
+ // Counter-probe: a passthrough hole would accept this; a declared boolean
+ // member refuses it with `invalid_type` at the key.
+ const flat = safeValidateSchema({ type: 'input-otp', separator: 'yes' });
+ expect(issuesOf(flat).map((s) => s.split(':')[0])).toEqual(['invalid_type separator']);
+
+ const nested = safeValidateSchema({
+ type: 'flex',
+ properties: { children: [{ type: 'input-otp', separator: 'yes' }] },
+ });
+ expect(nested.success).toBe(false);
+ expect(issuesOf(nested).map((s) => s.split(':')[0])).toEqual(['invalid_type properties.children.0.separator']);
+ });
+});
diff --git a/packages/components/src/renderers/form/input-otp.tsx b/packages/components/src/renderers/form/input-otp.tsx
index 96851d019f..34bcd41c22 100644
--- a/packages/components/src/renderers/form/input-otp.tsx
+++ b/packages/components/src/renderers/form/input-otp.tsx
@@ -8,7 +8,7 @@
import { ComponentRegistry } from '@object-ui/core';
import type { InputOTPSchema } from '@object-ui/types';
-import { InputOTP, InputOTPGroup, InputOTPSlot } from '../../ui';
+import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '../../ui';
import { toFormControlDomProps } from '../../lib/form-control-dom-props';
ComponentRegistry.register('input-otp',
@@ -22,7 +22,15 @@ ComponentRegistry.register('input-otp',
// published 4- and 8-digit examples drew six slots (objectui#11347). One
// spelling, no alias.
const length = schema.length || 6;
- const slots = Array.from({ length });
+ // `separator` (`InputOTPSchema.separator`, objectui#11365) splits the slots
+ // into two groups at the midpoint and draws the primitive's separator
+ // between them, the shadcn "with separator" layout. An odd `length` puts
+ // the extra slot in the first group; a single slot has nothing to separate.
+ // Absent or `false`, `split === length` and the markup is the one group
+ // it always was.
+ const split = schema.separator === true && length > 1 ? Math.ceil(length / 2) : length;
+ const slotsFrom = (from: number, to: number) =>
+ Array.from({ length: to - from }, (_, k) => );
const handleChange = (val: string) => {
if (onChange) {
@@ -39,11 +47,13 @@ ComponentRegistry.register('input-otp',
{...toFormControlDomProps(otpProps)}
style={style}
>
-
- {slots.map((_, i) => (
-
- ))}
-
+ {slotsFrom(0, split)}
+ {split < length && (
+ <>
+
+ {slotsFrom(split, length)}
+ >
+ )}
);
},
@@ -52,6 +62,7 @@ ComponentRegistry.register('input-otp',
label: 'Input OTP',
inputs: [
{ name: 'length', type: 'number' },
+ { name: 'separator', type: 'boolean' },
{ name: 'className', type: 'string' }
],
defaultProps: {
diff --git a/packages/types/src/__tests__/input-otp-separator-11365.test.ts b/packages/types/src/__tests__/input-otp-separator-11365.test.ts
new file mode 100644
index 0000000000..9d63642647
--- /dev/null
+++ b/packages/types/src/__tests__/input-otp-separator-11365.test.ts
@@ -0,0 +1,55 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectui#11365 — `InputOTPSchema.separator` is declared on BOTH faces, as a
+ * boolean (triage's enforce arm under ADR-0049).
+ *
+ * The TypeScript face carries `BaseSchema`'s `[key: string]: any`, so an
+ * undeclared `separator` already compiled whatever its value; the declared
+ * member is what makes a non-boolean refused. The compile-time rows below are
+ * checked by `tsc -p tsconfig.test.json`; the `describe` block reads the zod
+ * mirror at runtime, value probe and control included. The rendering half
+ * (one `role="separator"`, and none without the key) is pinned beside the
+ * catalog entries in `examples/schema-catalog/test/input-otp-separator-11365.test.tsx`.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { InputOTPSchema } from '../zod/form.zod';
+import type { InputOTPSchema as TsInputOTPSchema } from '../form';
+
+/** Compile-time truth assertion, erased at runtime — only `tsc` checks these. */
+type Expect = T;
+/** Compile-time equality, exact in both directions; `any` equals nothing but `any`. */
+type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false;
+
+export type _SeparatorIsABoolean = Expect>;
+
+/** CONTROL — the declared spelling compiles. */
+export const _separatorAccepted: TsInputOTPSchema = { type: 'input-otp', length: 6, separator: true };
+// @ts-expect-error -- `separator` is a declared boolean, so the index signature no longer admits a string
+export const _separatorRefused: TsInputOTPSchema = { type: 'input-otp', length: 6, separator: 'yes' };
+
+describe('InputOTPSchema.separator on the zod mirror (objectui#11365)', () => {
+ it('a boolean `separator` parses and survives', () => {
+ const result = InputOTPSchema.safeParse({ type: 'input-otp', separator: true });
+ expect(result.success).toBe(true);
+ expect(result.success && result.data.separator).toBe(true);
+ });
+
+ it('a non-boolean `separator` is refused with `invalid_type` at the key', () => {
+ const result = InputOTPSchema.safeParse({ type: 'input-otp', separator: 'yes' });
+ expect(result.success).toBe(false);
+ const found = result.success ? [] : result.error.issues.map((i) => [i.code, i.path.join('.')]);
+ expect(found).toEqual([['invalid_type', 'separator']]);
+ });
+
+ it('CONTROL — the same probe on an undeclared key passes through, so the refusal above is the declaration', () => {
+ expect(InputOTPSchema.safeParse({ type: 'input-otp', notAKey11365: 'yes' }).success).toBe(true);
+ });
+});
diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts
index 0108933915..0191a45581 100644
--- a/packages/types/src/form.ts
+++ b/packages/types/src/form.ts
@@ -1453,6 +1453,19 @@ export interface InputOTPSchema extends BaseSchema {
* @default 6
*/
length?: number;
+ /**
+ * Draw a visual separator between two halves of the slots
+ *
+ * READ SITE: `packages/components/src/renderers/form/input-otp.tsx`. `true`
+ * splits the {@link InputOTPSchema.length} slots into two groups at the
+ * midpoint and draws one separator (`role="separator"`) between them; an odd
+ * `length` puts the extra slot in the first group, and a single slot has
+ * nothing to separate. Absent or `false` draws one group. Declared by
+ * objectui#11365: the docs page and two catalog entries authored it while no
+ * type declared it and the renderer read nothing for it.
+ * @default false
+ */
+ separator?: boolean;
/**
* Default value
*/
@@ -1501,7 +1514,8 @@ export interface InputOTPSchema extends BaseSchema {
* `maxLength`, `value` (in
* `packages/components/src/renderers/form/input-otp.tsx`). The undeclared
* `maxLength` read has since moved to the declared {@link InputOTPSchema.length}
- * (objectui#11347).
+ * (objectui#11347), and objectui#11365 added a read of the declared
+ * {@link InputOTPSchema.separator}.
*
* Before objectui#9256 tombstoned them here, `body` and `children` were both
* inherited-and-optional from {@link BaseSchema} — so authoring either here
@@ -1530,7 +1544,8 @@ export interface InputOTPSchema extends BaseSchema {
* `maxLength`, `value` (in
* `packages/components/src/renderers/form/input-otp.tsx`). The undeclared
* `maxLength` read has since moved to the declared {@link InputOTPSchema.length}
- * (objectui#11347).
+ * (objectui#11347), and objectui#11365 added a read of the declared
+ * {@link InputOTPSchema.separator}.
*
* Before objectui#9256 tombstoned them here, `body` and `children` were both
* inherited-and-optional from {@link BaseSchema} — so authoring either here
diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts
index 51309b7176..3e1b429d79 100644
--- a/packages/types/src/zod/form.zod.ts
+++ b/packages/types/src/zod/form.zod.ts
@@ -748,6 +748,10 @@ export const InputOTPSchema = BaseSchema.extend({
name: z.string().optional().describe('Field name for form submission'),
label: z.string().optional().describe('OTP input label'),
length: z.number().optional().describe('Number of OTP digits'),
+ separator: z
+ .boolean()
+ .optional()
+ .describe('Draw a visual separator between two halves of the slots (objectui#11365)'),
defaultValue: z.string().optional().describe('Default value'),
value: z.string().optional().describe('Controlled value'),
description: z.string().optional().describe('Help text'),
@@ -759,14 +763,14 @@ export const InputOTPSchema = BaseSchema.extend({
+ 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or '
+ '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An '
+ 'authored value therefore rendered NOTHING — no render-time error or warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) noticed it. '
- + 'What it renders instead: `length`, `value`.',
+ + 'What it renders instead: `length`, `separator`, `value`.',
),
children: retirementTombstone(
'REFUSED (objectui#9256, ADR-0049) — `input-otp` reads NEITHER content channel: measured with the '
+ 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or '
+ '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An '
+ 'authored value therefore rendered NOTHING — no render-time error or warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) noticed it. '
- + 'What it renders instead: `length`, `value`.',
+ + 'What it renders instead: `length`, `separator`, `value`.',
),
});