diff --git a/.changeset/8762-shorthand-input-type-refusal.md b/.changeset/8762-shorthand-input-type-refusal.md
new file mode 100644
index 0000000000..f0ceda92d3
--- /dev/null
+++ b/.changeset/8762-shorthand-input-type-refusal.md
@@ -0,0 +1,43 @@
+---
+'@object-ui/types': minor
+---
+
+`InputShorthandSchema` (and its TS twin) now REFUSES `inputType` on the `email` / `password`
+node shorthands BY NAME and points at `{ "type": "input", "inputType": "email" }` — the
+spelling that is actually read (objectui#8762).
+
+⚠️ Shipped as `minor`, not `patch`, because this is a NARROWING of a published accept
+surface, and the precedent it follows is objectui#7694's named alias refusal, which says the
+same thing in the same words. (`major` is not available: AGENTS.md §版本号策略 keeps this
+repository's major aligned with `@objectstack`, so objectui's own breaking changes ship as
+`minor` with the breaking semantics spelled out in the body — mechanically enforced by
+`scripts/check-changeset-no-major.mjs`.)
+
+- **Before:** `{ "type": "password", "inputType": "text" }` validated green, and the renderer
+ drew a MASKED field anyway. `packages/components/src/renderers/form/input.tsx` registers both
+ shorthands by wrapping the `input` renderer and spreading its own `inputType` LAST, so the
+ authored value was overwritten before the renderer read it. `BaseSchema` is `.passthrough()`,
+ so the key even survived into `safeParse`'s output. Nothing anywhere said so.
+- **After:** the same document is refused at `path: ['inputType']` with guidance naming the
+ key, the reason, and the spelling to write instead. One string feeds both the parse-time
+ message and `.describe()`, so the generated docs cannot drift from the error.
+
+**What is NOT changed, and was measured to make sure:**
+
+- `{ "type": "input", "inputType": "…" }` — the honoured spelling and the one the guidance
+ points at — parses and renders exactly as before, at every value.
+- `{ "type": "email" }` / `{ "type": "password" }` without the key are untouched, and every
+ other key on the arm is still judged.
+- A form FIELD is a different position with the opposite precedence: inside `fields: [ … ]`,
+ `{ "name": "contact", "type": "email", "inputType": "text" }` still renders a text box, and
+ this refusal does not reach there. The message says so, and the sentence is pinned.
+- ⛔ The wrapper's precedence is deliberately NOT flipped. Letting an authored `inputType` win
+ would render `{ "type": "password", "inputType": "text" }` as an UNMASKED field under a
+ `password` key — a secret in clear text, which is worse than refusing the key.
+
+**Who this can break.** Any document authoring `inputType` on an `email` / `password` node
+stops validating — that is the defect surfacing, not collateral damage, since the value never
+did anything. A census of every corpus in this repository found NONE: over 546 parsed JSON
+documents plus every fenced JSON block in `content/**` and the package READMEs, six nodes
+carry a shorthand `type` and not one of them authors `inputType`. The `hotcrm` and
+`objectstack` corpora are outside this repository and were NOT measured.
diff --git a/packages/components/src/renderers/form/__tests__/shorthand-input-type-discarded-8762.test.tsx b/packages/components/src/renderers/form/__tests__/shorthand-input-type-discarded-8762.test.tsx
new file mode 100644
index 0000000000..b4675bd433
--- /dev/null
+++ b/packages/components/src/renderers/form/__tests__/shorthand-input-type-discarded-8762.test.tsx
@@ -0,0 +1,113 @@
+/**
+ * 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.
+ */
+
+/**
+ * The runtime half of objectui#8762: an `inputType` authored on the `email` /
+ * `password` NODE shorthands never reaches the DOM, while the two neighbours
+ * that DO honour the key keep honouring it.
+ *
+ * ## Why this file exists beside the refusal and not instead of it
+ *
+ * `@object-ui/types` refuses the key by name (`zod/form.zod.ts#InputShorthandSchema`,
+ * pinned in `packages/types/src/__tests__/shorthand-input-type-refusal-8762.test.ts`).
+ * That refusal REASONS from a runtime fact — the registration wrapper spreads its
+ * own `inputType` last — and `@object-ui/types` cannot render React, so nothing
+ * over there could check the reason. Measured on this card's base (681d3f10e),
+ * before the refusal landed:
+ *
+ * { type: 'password', inputType: 'text' } -> DISCARDED
+ * { type: 'email', inputType: 'text' } -> DISCARDED
+ * { type: 'input', inputType: 'text' } -> HONOURED
+ *
+ * ⚠️ The documents in the first two rows are REFUSED by the validator as of this
+ * card. Rendering them here is deliberate and is the point: the refusal is
+ * warranted exactly because the runtime ignores what they wrote.
+ *
+ * ## ⛔ What must NOT be "fixed" to make these pass
+ *
+ * The cheaper-looking repair is to flip the wrapper's precedence so the author's
+ * value wins. That would render `{ type: 'password', inputType: 'text' }` as an
+ * UNMASKED field under a `password` key — a secret in clear text, which is worse
+ * than refusing the key. If a change makes the first two rows "honour" the
+ * authored value, this file is the one that must go red.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { renderComponent } from '../../../__tests__/test-utils';
+// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
+// cold transform is billed to `hookTimeout` (objectui#3010/#3021).
+import '../../../renderers';
+
+/** The `type` attribute the rendered `` actually carries. */
+function renderedInputType(schema: Record): string | null {
+ const { container } = renderComponent(schema as never);
+ const input = container.querySelector('input');
+ expect(input, `no rendered for ${JSON.stringify(schema)}`).toBeTruthy();
+ return input!.getAttribute('type');
+}
+
+describe('objectui#8762 — the shorthand wrapper discards an authored `inputType`', () => {
+ it.each([
+ ['password', 'text'],
+ ['password', 'email'],
+ ['email', 'text'],
+ ['email', 'password'],
+ ])('`%s` renders its pinned type, not the authored `%s`', (type, authored) => {
+ const rendered = renderedInputType({ type, inputType: authored, name: 'x' });
+ expect(rendered).toBe(type);
+ expect(rendered, 'the wrapper stopped pinning — see the ⛔ note in this header')
+ .not.toBe(authored);
+ });
+
+ it.each(['email', 'password'])('`%s` renders its pinned type with nothing authored', (type) => {
+ // The baseline the pin is measured against: the wrapper's value is what a
+ // correct document gets, and that is unchanged by this card.
+ expect(renderedInputType({ type, name: 'x' })).toBe(type);
+ });
+});
+
+describe('objectui#8762 — the neighbours that DO honour `inputType`', () => {
+ it.each(['text', 'email', 'password', 'tel', 'url'])(
+ '`input` honours an authored `%s` — the spelling the refusal points at',
+ (authored) => {
+ // ⭐ THE FIRING CONTROL for the whole card. `{ "type": "input", "inputType":
+ // "email" }` is what the refusal's guidance tells the author to write, so a
+ // narrowing that also broke it would be worse than the defect it repairs.
+ expect(renderedInputType({ type: 'input', inputType: authored, name: 'x' })).toBe(authored);
+ },
+ );
+
+ it('a form FIELD keeps the OPPOSITE precedence — the carve-out the guidance claims', () => {
+ // `renderers/form/form.tsx`: `type={inputType || NATIVE_INPUT_FIELD_TYPES[declaredType] || 'text'}`
+ // — at a FIELD position the authored value wins over the one the field type
+ // implies. The refusal's message says so, so the sentence is checked here
+ // rather than left as prose that can rot. Same two literals, different
+ // position, different answer.
+ const { container } = renderComponent({
+ type: 'form',
+ fields: [{ name: 'contact', label: 'Contact', type: 'email', inputType: 'text' }],
+ } as never);
+ const input = container.querySelector('input[name="contact"], input#contact, input');
+ expect(input, 'no field control rendered').toBeTruthy();
+ expect(input!.getAttribute('type')).toBe('text');
+ });
+
+ it('and a form field with NO authored `inputType` still masks a secret', () => {
+ // The control for the control: the carve-out above must not read as "a field
+ // ignores its declared type". A plain `{ name, type: 'password' }` authors no
+ // `inputType`, and `NATIVE_INPUT_FIELD_TYPES` is what keeps it masked
+ // (objectui#5254 / #5375) — without it the secret would render as clear text.
+ const { container } = renderComponent({
+ type: 'form',
+ fields: [{ name: 'secretField', label: 'Secret', type: 'password' }],
+ } as never);
+ const input = container.querySelector('input');
+ expect(input, 'no field control rendered').toBeTruthy();
+ expect(input!.getAttribute('type')).toBe('password');
+ });
+});
diff --git a/packages/types/src/__tests__/shorthand-input-type-refusal-8762.test.ts b/packages/types/src/__tests__/shorthand-input-type-refusal-8762.test.ts
new file mode 100644
index 0000000000..23d2ec75fa
--- /dev/null
+++ b/packages/types/src/__tests__/shorthand-input-type-refusal-8762.test.ts
@@ -0,0 +1,227 @@
+/**
+ * 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.
+ */
+
+/**
+ * `inputType` is refused BY NAME on the `email` / `password` shorthands, and the
+ * neighbour that HONOURS the key still parses (objectui#8762).
+ *
+ * ## The defect
+ *
+ * `packages/components/src/renderers/form/input.tsx` registers both shorthands by
+ * wrapping the `input` renderer and spreading its own `inputType` LAST, so an
+ * authored value is overwritten before the renderer reads it. objectui#8499 gave
+ * the arm its declared face with the key OMITTED, and recorded that omission is
+ * not refusal while `BaseSchema` is `.passthrough()`. Measured on this card's base
+ * (681d3f10e) before the change:
+ *
+ * ACCEPT { type: 'password', inputType: 'text' } parsed data KEPT inputType: 'text'
+ * ACCEPT { type: 'input', inputType: 'text' } <- the control, where the key IS read
+ *
+ * DOM { type: 'password', inputType: 'text' } -> DISCARDED
+ * DOM { type: 'input', inputType: 'text' } -> HONOURED
+ *
+ * So the author wrote a key, every check passed, and the runtime threw the value
+ * away — the class-(c) trap. This file pins the repair and, just as importantly,
+ * the CONTROL: a narrowing with nothing proving the neighbour still parses is not
+ * a measurement.
+ *
+ * ## What is deliberately NOT pinned here
+ *
+ * ⛔ Not the wrapper's precedence being flipped so the author wins — that would
+ * render `{ type: 'password', inputType: 'text' }` as an UNMASKED field under a
+ * `password` key, which is worse than refusing it. The runtime half of the
+ * evidence (both precedences, in the DOM) lives beside the renderer, in
+ * `packages/components/src/renderers/form/__tests__/shorthand-input-type-discarded-8762.test.tsx`;
+ * this package cannot render React.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { z } from 'zod';
+
+import { AnyComponentSchema } from '../zod/index.zod.js';
+import { InputSchema, InputShorthandSchema } from '../zod/form.zod.js';
+
+// Root the file reads on THIS FILE, never on `process.cwd()` — the two test
+// invocation forms give cwd two different values (AGENTS.md §怎么跑测试).
+const HERE = dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = join(HERE, '..', '..', '..', '..');
+const INPUT_RENDERER = 'packages/components/src/renderers/form/input.tsx';
+const read = (relative: string): string => readFileSync(join(REPO_ROOT, relative), 'utf8');
+
+/** The `type` literals a schema declares to Zod's discriminator dispatch. */
+function literalsOf(schema: unknown): string[] {
+ const values = (schema as { _zod?: { propValues?: { type?: Set } } })._zod?.propValues
+ ?.type;
+ return values === undefined ? [] : [...values];
+}
+
+const SHORTHANDS = ['email', 'password'] as const;
+
+function issuesFor(doc: unknown, schema: z.ZodType = AnyComponentSchema) {
+ const result = schema.safeParse(doc);
+ return result.success ? [] : result.error.issues;
+}
+
+describe('objectui#8762 — the shorthands refuse `inputType` BY NAME', () => {
+ it.each(SHORTHANDS)('refuses `inputType` on `%s` at the document root', (type) => {
+ const issues = issuesFor({ type, inputType: 'text' });
+ expect(issues.length, 'the trap still parses green').toBeGreaterThan(0);
+ // BY NAME: the issue is addressed at the key's own path, so a consumer that
+ // reports paths blames `inputType` and nothing else.
+ const own = issues.filter((i) => i.path.join('.') === 'inputType');
+ expect(own.length, JSON.stringify(issues)).toBe(1);
+ expect(own[0].code).toBe('invalid_type');
+ // WITH GUIDANCE: and the guidance names the spelling that IS honoured. A
+ // refusal that only says "invalid" sends the author to delete the node.
+ expect(own[0].message).toContain('`inputType`');
+ expect(own[0].message).toContain('{ "type": "input", "inputType": "email" }');
+ });
+
+ it.each(SHORTHANDS)('refuses it nested at a declared node slot too, on `%s`', (type) => {
+ // objectui#8344 pointed the node recursion point at this union, so a nested
+ // node is judged by its own component schema. A root-only refusal would leave
+ // every real document — where inputs live inside a form or a stack — untouched.
+ expect(issuesFor({ type: 'div', children: [{ type, inputType: 'text' }] }).length)
+ .toBeGreaterThan(0);
+ });
+
+ it('refuses it on the arm read directly, not only through the union', () => {
+ const issues = issuesFor({ type: 'password', inputType: 'text' }, InputShorthandSchema);
+ expect(issues.map((i) => i.path.join('.'))).toEqual(['inputType']);
+ });
+
+ it('feeds ONE string into both author-facing channels', () => {
+ // The `./zod/tombstone.zod.ts` discipline: the parse-time message and the
+ // `.describe()` metadata generated docs publish cannot drift apart, because
+ // there is only one string. Compared here rather than asserted separately.
+ const described = (InputShorthandSchema.shape.inputType as z.ZodType).description;
+ const parseMessage = issuesFor({ type: 'password', inputType: 'text' }, InputShorthandSchema)[0]
+ ?.message;
+ expect(described, 'the member lost its `.describe()`').toBeTruthy();
+ expect(parseMessage).toBe(described);
+ });
+
+ it('addresses the author it is actually addressed to — the message keeps its FIELD carve-out', () => {
+ // A form FIELD is a different position with the OPPOSITE precedence
+ // (`renderers/form/form.tsx`: `inputType || NATIVE_INPUT_FIELD_TYPES[…]`), and
+ // this refusal does not reach there. Without the sentence, an author who meets
+ // this message while looking at `fields: [{ type: 'email', inputType: 'text' }]`
+ // is told their working document is wrong. Pinned because it is a claim about
+ // a surface this file does not otherwise touch.
+ const described = (InputShorthandSchema.shape.inputType as z.ZodType).description ?? '';
+ expect(described).toContain('form FIELD');
+ expect(described).toContain('fields:');
+ });
+});
+
+describe('objectui#8762 — the controls that keep the narrowing honest', () => {
+ it('the neighbour that HONOURS `inputType` still parses', () => {
+ // ⭐ THE FIRING CONTROL. `input` is where the key is read, and it must be
+ // untouched — a narrowing that also broke the spelling the guidance points at
+ // would be worse than the defect.
+ for (const inputType of ['text', 'email', 'password']) {
+ expect(issuesFor({ type: 'input', inputType }), `input/${inputType}`).toEqual([]);
+ expect(issuesFor({ type: 'input', inputType }, InputSchema), `input/${inputType}`).toEqual([]);
+ }
+ });
+
+ it.each(SHORTHANDS)('`%s` without `inputType` is untouched', (type) => {
+ expect(issuesFor({ type })).toEqual([]);
+ expect(issuesFor({ type, label: 'X', name: 'x', required: true, placeholder: 'p' })).toEqual([]);
+ expect(issuesFor({ type: 'div', children: [{ type }] })).toEqual([]);
+ });
+
+ it.each(SHORTHANDS)('`%s` still JUDGES its other values — this is not a dead arm', (type) => {
+ // An arm that refused one key and validated nothing else would satisfy every
+ // assertion above. One green reading and one red reading on the same key.
+ expect(issuesFor({ type, required: true })).toEqual([]);
+ expect(issuesFor({ type, required: 'yes' }).length).toBeGreaterThan(0);
+ });
+
+ it('`BaseSchema` is still passthrough — so this is a DECLARED refusal, not strictness', () => {
+ // The premise the repair rests on, re-measured rather than inherited: if the
+ // base had become strict, every undeclared key would already be refused and
+ // this arm would be solving a problem that had moved. It has not — an
+ // undeclared key still rides through on the very same document.
+ expect(issuesFor({ type: 'password', zzzUndeclaredKey: 1 })).toEqual([]);
+ expect(issuesFor({ type: 'input', zzzUndeclaredKey: 1 })).toEqual([]);
+ });
+
+ it('stays representable as JSON Schema, and does not make the arm any less so', () => {
+ // Why the `z.never` primitive and not `z.custom`: `z.toJSONSchema` THROWS on
+ // a `z.custom` arm and represents a `z.never` arm as `{ not: {} }` carrying
+ // the description — the reading `aliasKeyRefusal`'s docblock records. The
+ // docs surface is generated from these schemas.
+ const alone = z.toJSONSchema(InputShorthandSchema.shape.inputType as z.ZodType, {
+ io: 'input',
+ }) as { not?: unknown; description?: string };
+ expect(alone.not, 'the member stopped converting on its own').toEqual({});
+ expect(alone.description).toContain('objectui#8762');
+
+ // ⚠️ MEASURED, not assumed, and it corrected this file's first draft: the
+ // WHOLE arm does not convert under bare options, and did not before this card
+ // either. `InputSchema` — untouched here — throws the same way, because
+ // `handlerKeyRefusal('onChange', …)` is a `z.custom` and `ZodUndefined` has no
+ // JSON Schema form. Every live caller in this repo
+ // (`app-shell/src/views/metadata-admin/*-schema.ts`) passes
+ // `unrepresentable: 'any'`, so this is the reading that describes production.
+ const opts = { io: 'input', unrepresentable: 'any' } as const;
+ expect(() => z.toJSONSchema(InputSchema, opts)).not.toThrow();
+ const json = z.toJSONSchema(InputShorthandSchema, opts) as {
+ properties?: Record;
+ };
+ expect(json.properties?.inputType?.description).toContain('objectui#8762');
+ });
+});
+
+describe('objectui#8762 — ONE rule, and the next shorthand cannot slip past it', () => {
+ it('the refusal is one member on one arm, covering every literal the arm claims', () => {
+ // Not an enumeration: `InputShorthandSchema` is a single arm over a `type`
+ // enum, so a literal added to that enum inherits the refusal with no second
+ // edit. That is what makes a future sibling covered BY CONSTRUCTION.
+ expect([...literalsOf(InputShorthandSchema)].sort()).toEqual([...SHORTHANDS].sort());
+ expect(Object.keys(InputShorthandSchema.shape)).toContain('inputType');
+ });
+
+ it('the arm names exactly the shorthands `input.tsx` registers with a pinned `inputType`', () => {
+ // ⭐ THE COMPARISON INSTRUMENT, the shape objectui#8499 established for the
+ // two family arms. The two faces can only diverge where nothing compares
+ // them: register a third shorthand and forget the enum, and this goes red
+ // instead of leaving a literal that renders and validates nowhere.
+ const source = read(INPUT_RENDERER);
+ const pinned = [
+ ...source.matchAll(
+ /ComponentRegistry\.register\(\s*'([^']+)'\s*,[\s\S]{0,200}?inputType:\s*'([^']+)'/g,
+ ),
+ ]
+ .filter((m) => m[1] === m[2])
+ .map((m) => m[1]);
+ // Non-vacuity: a reader that has stopped reading must not be able to pass.
+ expect(pinned.length, 'the registration read went vacuous — check `input.tsx`').toBe(2);
+ expect([...pinned].sort()).toEqual([...literalsOf(InputShorthandSchema)].sort());
+ });
+
+ it('detects a registered-but-unarmed shorthand, and fails closed on an unreadable source', () => {
+ // ⚠️ What this control must NOT be: comparing a list against itself plus an
+ // element. Both halves below run the REAL reader. `input` is registered in
+ // the same file WITHOUT a pinned inputType, so the reader must not pick it up
+ // — that is the same instrument answering a case it must exclude.
+ const source = read(INPUT_RENDERER);
+ const allRegistered = [...source.matchAll(/ComponentRegistry\.register\(\s*'([^']+)'/g)].map(
+ (m) => m[1],
+ );
+ expect(allRegistered.length, 'the registration read went vacuous').toBe(3);
+ expect(allRegistered).toContain('input');
+ expect([...literalsOf(InputShorthandSchema)]).not.toContain('input');
+ // And the reader must throw rather than fabricate a pass when the file moves.
+ expect(() => read('packages/components/src/renderers/form/not-a-file.tsx')).toThrow();
+ });
+});
diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts
index ac33618b12..65b9980c4f 100644
--- a/packages/types/src/form.ts
+++ b/packages/types/src/form.ts
@@ -1644,20 +1644,35 @@ export interface CodeEditorSchema extends BaseSchema {
* Both are the SAME renderer as `input`, wrapped so `inputType` is pinned:
* ``.
*
- * ⛔ `inputType` is therefore absent here, and that absence is the whole
+ * ⛔ `inputType` is therefore not a member here, and that is the whole
* difference from {@link InputSchema}. The wrapper spreads its own value LAST,
- * so an authored `inputType` is silently overwritten; declaring it would publish
- * a key the runtime discards. Write `{ type: 'input', inputType: 'email' }` when
- * the input type is the choice.
+ * so an authored `inputType` never reaches the renderer. Write
+ * `{ type: 'input', inputType: 'email' }` when the input type is the choice.
*
- * ⚠️ {@link BaseSchema} carries an index signature and its mirror passes unknown
- * keys through, so omitting the key states the contract — it does not refuse the
- * value. Refusing it by name is an accept-set narrowing left to its own ruling.
+ * ⚠️ objectui#8499 shipped this interface with the key merely OMITTED, and
+ * recorded why that was not enough: {@link BaseSchema} carries an index
+ * signature and its mirror is `.passthrough()`, so `inputType` rode through
+ * both faces unchallenged while the renderer threw the value away. objectui#8762
+ * closes that — the key is DECLARED and unwritable on both faces, so `tsc`
+ * refuses it at the authoring site and the zod twin refuses it BY NAME with
+ * guidance pointing at `{ type: 'input', inputType: 'email' }`.
*
* Mirror: `zod/form.zod.ts#InputShorthandSchema`.
*/
export interface InputShorthandSchema extends Omit {
type: 'email' | 'password';
+ /**
+ * ⛔ UNWRITABLE at this position (objectui#8762). The `email` / `password`
+ * registration wrapper pins `inputType` itself and spreads it LAST, so an
+ * authored value is discarded — `{ type: 'password', inputType: 'text' }`
+ * renders a MASKED field. Write `{ type: 'input', inputType: 'email' }`
+ * instead. The zod twin refuses it by name and carries the same guidance.
+ *
+ * ⚠️ Not a retirement of `inputType`: the key is alive and honoured on
+ * {@link InputSchema}, and on the FORM FIELD path an authored `inputType`
+ * still wins. It is this POSITION that cannot author it.
+ */
+ inputType?: never;
}
/**
diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts
index ec2a40a718..0d7db28821 100644
--- a/packages/types/src/zod/form.zod.ts
+++ b/packages/types/src/zod/form.zod.ts
@@ -686,6 +686,30 @@ export const FormSchema = BaseSchema.extend({
showActions: z.boolean().optional().describe('Show action buttons'),
});
+/**
+ * The one string this arm's `inputType` refusal carries into BOTH author-facing
+ * channels — the parse-time issue message and the `.describe()` metadata — so
+ * they cannot drift apart ({@link retirementTombstone}'s discipline).
+ *
+ * ⚠️ The closing sentence is a claim about a DIFFERENT surface and is pinned as
+ * such: on the FORM FIELD path the precedence is the other way round
+ * (`renderers/form/form.tsx`, `type={inputType || NATIVE_INPUT_FIELD_TYPES[declaredType] || 'text'}`),
+ * so an author who meets this message inside `fields: [ … ]` is not the author it
+ * is addressed to. `__tests__/shorthand-input-type-refusal-8762.test.ts` fails if
+ * the message stops saying it; the components-side pin
+ * (`renderers/form/__tests__/shorthand-input-type-discarded-8762.test.tsx`) fails
+ * if either precedence moves.
+ */
+const SHORTHAND_INPUT_TYPE_REFUSAL =
+ '`inputType` is NOT authorable on the `email` / `password` shorthands (objectui#8762). ' +
+ '`packages/components/src/renderers/form/input.tsx` registers each of them by wrapping the ' +
+ '`input` renderer and spreading its OWN `inputType` LAST, so an authored value is overwritten ' +
+ 'before the renderer reads it: `{ "type": "password", "inputType": "text" }` renders a MASKED ' +
+ 'field, not a text one. Write the input node itself when the input type is the choice — ' +
+ '`{ "type": "input", "inputType": "email" }`, the spelling that IS read. ' +
+ '(A form FIELD is a different position: inside `fields: [ … ]` an authored `inputType` still ' +
+ 'wins over the one the field type implies, and this refusal does not reach there.)';
+
/**
* Input Shorthand Schema — the `email` / `password` aliases
* `packages/components/src/renderers/form/input.tsx` registers (objectui#8499).
@@ -697,18 +721,45 @@ export const FormSchema = BaseSchema.extend({
* (props) => , …)
* ```
*
- * ⛔ `inputType` is therefore ABSENT from this arm, deliberately, and that
- * absence is the whole difference from {@link InputSchema}. The wrapper spreads
- * its own value LAST, so an authored `inputType` is silently overwritten;
- * declaring it here would publish a key the runtime discards. Write
+ * ⛔ `inputType` is therefore NOT a member here, and that is the whole difference
+ * from {@link InputSchema}. The wrapper spreads its own value LAST, so an
+ * authored `inputType` never reaches the renderer. Write
* `{ type: 'input', inputType: 'email' }` when the input type is the choice.
*
- * ⚠️ MEASURED, so the omission is not over-read: `BaseSchema` passes unknown
- * keys through, so `{ type: 'password', inputType: 'text' }` still PARSES —
- * omitting the key states the contract on the declared face, it does not refuse
- * the value. Refusing it by name (the `./tombstone.zod.ts` mechanism) would be
- * an accept-set NARROWING in the opposite direction from this card and is
- * deliberately left to its own ruling; objectui#8499's report records it.
+ * ## Why the key is DECLARED-AND-REFUSED rather than merely omitted (objectui#8762)
+ *
+ * ⚠️ objectui#8499 shipped this arm with the key simply OMITTED and recorded the
+ * reading that made that insufficient: `BaseSchema` is `.passthrough()`, so
+ * `{ type: 'password', inputType: 'text' }` PARSED GREEN and the value survived
+ * into `safeParse`'s output — omission states the contract on the declared face,
+ * it does not refuse the value. Re-measured on this card's base before the
+ * change: ACCEPT, with `inputType: 'text'` still present in the parsed data,
+ * while the DOM rendered `type="password"`. That is the class-(c) trap — a
+ * validated key the runtime discards — so #8499's report left the repair to its
+ * own ruling, which is this card.
+ *
+ * The key therefore stays DECLARED and unwritable, refused BY NAME with guidance
+ * ({@link retirementTombstone}, the mechanism `./tombstone.zod.ts` owns). ⚠️ Not
+ * an ADR-0049 retirement of `inputType` itself — the key is alive and honoured on
+ * {@link InputSchema}; what is unwritable is this POSITION. That is the same
+ * reading `MenuItemSchema.type` (`overlay.zod.ts`) is filed under, and
+ * `aliasKeyRefusal`'s docblock records why declaration history is not what picks
+ * the helper. ⛔ Not `aliasKeyRefusal` either: the remedy here is a different
+ * `type`, not a sibling spelling of a key on this same object, so its
+ * "Did you mean `inputType` → `inputType`?" lead would be nonsense.
+ *
+ * ⛔ The repair is NOT to flip the wrapper's precedence so the author wins:
+ * `{ type: 'password', inputType: 'text' }` would then render an UNMASKED field
+ * under a `password` key, which is worse than refusing it. The card records this
+ * so the cheaper-looking route is not taken by accident.
+ *
+ * ONE rule, not an enumeration: the refusal is a single member on this ONE arm,
+ * whose `type` is an enum, so it covers both literals and any literal later added
+ * to that enum. A shorthand registered in `input.tsx` and NOT added to the enum
+ * is refused whole by `AnyComponentSchema` (no arm claims the literal) rather
+ * than silently accepted — the loud direction — and
+ * `__tests__/shorthand-input-type-refusal-8762.test.ts` compares the enum against
+ * the registration site so the gap turns red instead of widening in silence.
*
* Every other key is {@link InputSchema}'s, because it is literally the same
* renderer reading the same schema.
@@ -716,6 +767,9 @@ export const FormSchema = BaseSchema.extend({
export const InputShorthandSchema = InputSchema.omit({ type: true, inputType: true }).extend({
type: z.enum(['email', 'password'])
.describe('Input shorthand — `renderers/form/input.tsx` pins `inputType` to match'),
+ // Declared and unwritable — the `.omit()` above removes the HONOURED enum
+ // {@link InputSchema} carries, and this puts a named refusal in its place.
+ inputType: retirementTombstone(SHORTHAND_INPUT_TYPE_REFUSAL),
// Declared here and not (yet) on {@link InputSchema}, which is the pair
// `__tests__/zod-mirror-parity.test.ts` records as unmirrored for this very key.
// The renderer both arms share reads it — `renderers/form/input.tsx:42`,