Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/5905-componentinput-retire-constraint-keys.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
'@object-ui/types': minor
---

Retire `ComponentInput`'s four inert constraint keys — `min`, `max`, `step` and
`placeholder` (objectui#5905, ADR-0049 enforce-or-remove).

All four were declared on `ComponentInput` and read by nothing, on either path. No consumer
reads them off a `ComponentInput` value, and the manifest serializer
(`packages/sdui-parser/src/index.ts`) forwards exactly six keys per input — `name`, `type`,
`required`, `enum`, `binding`, `description` — so a value authored here could not reach the
published `sdui.manifest.json` even in principle. Re-measured on this branch's merge-base
rather than inherited from the card: a structural census over every `inputs:` array in the
repository (219 regions, all tracked files) scores `min` **0**, `max` **0**, `step` **0**
and `placeholder` **0**, against `name` 926, `type` 926, `description` 161, `enum` 114 and
`required` 87 in the same pass over the same regions — the instrument was not blind.

FROM → TO, per key:

- `min: number` → **removed**. Spell the numeric domain out in `description`, which IS
published (`'A positive integer — the contract rejects 0 and fractional values'`).
- `max: number` → **removed**. Same remedy.
- `step: number` → **removed**. Same remedy.
- `placeholder: string` → **removed**. Put the hint in `description`. ⚠️
`BaseSchema.placeholder` — the node-level prop a renderer does read — is a DIFFERENT key
and is unaffected.

The retirement kit: `?: never` on the interface (`packages/types/src/base.ts`), so authoring
one is a `tsc` error at the registration site; `retirementTombstone()` on the Zod mirror
(`packages/types/src/zod/base.zod.ts`), so an authored value is REFUSED at parse time with
`code: 'invalid_type'`, the key named in the issue `path`, and the migration note as the
message. Deleting the members outright was the option NOT taken: `ComponentInputSchema` is
a non-strict `z.object`, which strips an undeclared key silently — one silent no-op traded
for another. Pinned in
`packages/types/src/__tests__/component-input-retired-constraint-keys.test.ts`.

Two limits worth stating rather than papering over:

- The in-repo zero is what was measured. Whether anything OUTSIDE this repository writes
these keys is **not measurable from here** (the same limit objectui#5674 recorded for
`PluginComponentInput`). Converting such a write from a silent drop into a named refusal
is exactly what the tombstone buys.
- The fifth key objectui#5905 named, `inputType`, is **NOT retired here**.
`packages/plugin-markdown` authors it (`inputType: 'textarea'`), so it is
declared-and-DROPPED — a different defect that needs a ruling, not a removal.

This is not a verdict that constraint slots on `ComponentInput` were a mistake. The
neighbouring `type` field carries a maintainer ruling of 2026-08-17 recording that giving
`ComponentInput` real constraint slots was **deferred, not rejected** — `min`/`max`/`step`
read exactly like the slots that ruling declined to add. What is retired is this inert
spelling; the ruling's own reopen condition still stands.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
/**
* 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.
*/

/**
* `ComponentInput`'s four inert constraint keys are ADR-0049 RETIREMENT
* TOMBSTONES, and the refusal is LOUD (objectui#5905).
*
* ## What was measured
*
* `min` / `max` / `step` / `placeholder` were declared on `ComponentInput` and
* read by nothing, on either path:
*
* - no consumer reads them off a `ComponentInput` value; and
* - the manifest serializer (`packages/sdui-parser/src/index.ts`) forwards
* exactly six keys per input — `name`, `type`, `required`, `enum`,
* `binding`, `description` — so an authored value could not reach the
* published `sdui.manifest.json` even in principle.
*
* A structural census over EVERY `inputs:` array in the repository found zero
* authoring sites for the four; the same pass, over the same regions, counted
* 926 `name`, 926 `type` and 161 `description` sites, so the instrument was
* demonstrably not blind. Authorship from OUTSIDE this repository is not
* measurable from here (the limit objectui#5674 recorded for
* `PluginComponentInput`) — and that unmeasurable half is precisely what the
* tombstone serves: an outside write becomes a NAMED REFUSAL carrying its own
* remedy instead of a silent drop.
*
* ## Why tombstones and not deletions
*
* `ComponentInputSchema` is a NON-STRICT `z.object`, so a deleted key would be
* silently STRIPPED — one silent no-op traded for another. The tombstone keeps
* the key declared and unwritable: `?: never` on the interface (a `tsc` error
* at the authoring site) and `retirementTombstone()` on the mirror (a parse
* refusal whose message IS the migration note). Both halves are pinned below,
* plus the CONTRAST against a genuinely undeclared key, so nobody can "simplify"
* the tombstones into deletions without this file going red.
*
* ## `inputType` is NOT here, deliberately
*
* The fifth key objectui#5905 named is still live and still writable, because
* the repository AUTHORS it: `packages/plugin-markdown/src/index.tsx` declares
* `inputType: 'textarea'` on its `content` input (pinned by that package's own
* test). That is declared-and-DROPPED — a different defect from the
* declared-and-unread four — and it needs a ruling, not a removal. Its liveness
* is pinned below so the fork stays visible and closing it stays a deliberate
* edit to this file.
*
* The `@ts-expect-error` directives are REAL enforcement: this package
* type-checks its tests through `tsconfig.test.json`, so re-widening the
* declaration fails the build on the unused directive.
*/

import { describe, it, expect } from 'vitest';
import type { ComponentInput } from '../base';
import { ComponentInputSchema } from '../zod/base.zod';

/** The four retired keys, with a value an author would plausibly have written. */
const RETIRED = {
min: 0,
max: 100,
step: 1,
placeholder: 'Type here…',
} as const;

type RetiredKey = keyof typeof RETIRED;

/** A fully live input — every key here is declared AND forwarded by the serializer. */
const LIVE_INPUT = {
name: 'content',
type: 'string',
label: 'Markdown Content',
required: true,
description: 'A positive integer — the contract rejects 0 and fractional values',
} as const;

const shapeOf = (schema: unknown): Record<string, unknown> =>
(schema as { shape: Record<string, unknown> }).shape;

const describeOf = (schema: unknown, key: string): string | undefined =>
(shapeOf(schema)[key] as { description?: string } | undefined)?.description;

/* ── type-level pins: the `tsc` channel ──────────────────────────────────── */

describe('the interface tombstones make authoring a `tsc` error', () => {
it('refuses each retired key at the authoring site', () => {
const input: ComponentInput = {
name: 'content',
type: 'string',
// @ts-expect-error `min` is a retirement tombstone (objectui#5905)
min: 0,
// @ts-expect-error `max` is a retirement tombstone (objectui#5905)
max: 100,
// @ts-expect-error `step` is a retirement tombstone (objectui#5905)
step: 1,
// @ts-expect-error `placeholder` is a retirement tombstone (objectui#5905)
placeholder: 'Type here…',
};
expect(input.name).toBe('content');
});

it('keeps `inputType` WRITABLE — the fork objectui#5905 reported, not an oversight', () => {
// No `@ts-expect-error`: `plugin-markdown` authors this key today, so
// retiring it is a ruling about that registration, not a cleanup. If this
// line ever needs a directive, the fork was closed — say so on the card.
const input: ComponentInput = { name: 'content', type: 'string', inputType: 'textarea' };
expect(input.inputType).toBe('textarea');
});
});

/* ── the mirror refuses, and the refusal carries its remedy ──────────────── */

describe('the zod tombstones REFUSE, loudly (objectui#5905)', () => {
it('a fully live input still parses GREEN — the non-vacuity control, in this test', () => {
// Without this, a mirror that refused everything would satisfy every
// assertion below by accident.
const control = ComponentInputSchema.safeParse(LIVE_INPUT);
expect(control.success).toBe(true);
if (control.success) {
expect(control.data.name).toBe('content');
expect(control.data.description).toBe(LIVE_INPUT.description);
}
});

it('`inputType` still parses green — the fork half of the same control', () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, inputType: 'textarea' });
expect(result.success).toBe(true);
if (result.success) expect(result.data.inputType).toBe('textarea');
});

for (const key of Object.keys(RETIRED) as RetiredKey[]) {
it(`refuses \`${key}\`, names it in the path, and answers with its own guidance`, () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, [key]: RETIRED[key] });
expect(result.success, key).toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => String(i.path[0]) === key);
expect(issue, `no issue addressed to \`${key}\``).toBeDefined();

// The accept-set contract: same address, same code a bare `z.never()`
// reports. A `refine`-based spelling would report `custom` and was
// rejected for exactly that reason (objectui#6105).
expect(issue!.code, key).toBe('invalid_type');
expect(issue!.path, key).toEqual([key]);

// The message is the migration note, not zod's generic string.
expect(issue!.message, key).not.toContain('Invalid input: expected never, received ');
expect(issue!.message, key).toContain('RETIRED (objectui#5905)');
expect(issue!.message, key).toContain(`\`ComponentInput.${key}\``);
expect(issue!.message, key).toContain('`description`');

// ONE string, BOTH channels — the invariant `retirementTombstone()`
// exists to make unbreakable. Asserted derived (nothing hand-copied to
// rot), which is why the literal anchors above sit beside it: two empty
// strings are also equal.
expect(issue!.message, key).toBe(describeOf(ComponentInputSchema, key));
});
}

it('`placeholder` answers with the full string, including the `BaseSchema` disambiguation', () => {
// One member pinned as a LITERAL so the derived assertions above cannot all
// drift together. `BaseSchema.placeholder` is a different, live key — an
// author who trips this one must not read it as that one being retired.
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, placeholder: 'Type here…' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toBe(
'RETIRED (objectui#5905) — `ComponentInput.placeholder` was never read, and never published: '
+ 'the manifest serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and '
+ 'this is not one of them, so an authored value was silently dropped. Delete the key; put the '
+ 'hint in `description`, which IS published. `BaseSchema.placeholder`, the node-level prop, is '
+ 'a DIFFERENT key and is unaffected.',
);
}
});
});

/* ── the contrast a deletion would have produced ─────────────────────────── */

describe('a tombstone is not a deletion — the contrast, measured in one run', () => {
it('an UNDECLARED key is silently stripped, which is what deleting these four would have bought', () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, notAKeyAtAll: 'anything' });
expect(result.success).toBe(true);
if (result.success) expect(result.data).not.toHaveProperty('notAKeyAtAll');
});

it('the four stay in the mirror\'s shape — a tombstone is DECLARED, just unwritable', () => {
for (const key of Object.keys(RETIRED)) {
expect(shapeOf(ComponentInputSchema)).toHaveProperty(key);
expect(describeOf(ComponentInputSchema, key)).toContain('RETIRED (objectui#5905)');
}
});
});
94 changes: 76 additions & 18 deletions packages/types/src/base.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -544,28 +544,86 @@ export interface ComponentInput {

/**
* Specific input type (e.g., 'email', 'password' for string)
*
* ⚠️ NOT retired alongside the four tombstones below (objectui#5905), and the
* difference is measured rather than stylistic. `plugin-markdown`'s
* registration AUTHORS this key — `inputs: [{ name: 'content', …, inputType:
* 'textarea' }]` in `packages/plugin-markdown/src/index.tsx`, pinned by that
* package's own test — while the manifest serializer still drops it. That is
* declared-and-DROPPED, a different defect from the declared-and-unread four
* below: retiring it would convert one registration's silent no-op into a
* build failure without first deciding what that registration should say
* instead (delete the line, or teach the publication path to carry it). The
* fork is recorded on objectui#5905 for a ruling; until then this stays a
* live, writable key that nothing publishes.
*/
inputType?: string;

/**
* Minimum value (for number/date)
*/
min?: number;

/**
* Maximum value (for number/date)
*/
max?: number;

/**
* Step value (for number)
*/
step?: number;

/**
* Placeholder text
*/
placeholder?: string;
* ADR-0049 RETIREMENT TOMBSTONES — `min` / `max` / `step` / `placeholder`
* (objectui#5905).
*
* `?: never` is this package's tombstone convention (see `crud.ts` `confirm`
* and {@link StaticTableColumn} in `data-display.ts`): the key stays
* DECLARED and becomes UNWRITABLE, so authoring one is a `tsc` error here and
* a named parse refusal in the Zod twin (`zod/base.zod.ts`
* `ComponentInputSchema`, via `retirementTombstone()`). Deleting the members
* outright would have been the quiet option — an undeclared key is silently
* stripped by the non-strict mirror, which trades one silent no-op for
* another.
*
* What was measured (objectui#5905, re-measured on the merge-base of the
* retiring PR): no consumer reads any of the four, and the manifest
* serializer (`packages/sdui-parser/src/index.ts`) forwards exactly six keys
* per input — `name`, `type`, `required`, `enum`, `binding`, `description` —
* so a value authored here could not reach the published
* `sdui.manifest.json` even in principle. A structural census over every
* `inputs:` array in the repository found ZERO authoring sites for the four
* (the same pass counted 926 `name`, 926 `type` and 161 `description` sites,
* so the instrument was not blind). Authorship from OUTSIDE the repository is
* not measurable from here — the limit objectui#5674 recorded for
* `PluginComponentInput` — and converting such a write from a silent drop
* into a NAMED REFUSAL is exactly what these tombstones buy.
*
* ⚠️ Why a future reader must NOT read this as "these keys were a mistake":
* the neighbouring `type` field carries a maintainer ruling of 2026-08-17
* (quoted in full above) recording that giving `ComponentInput` real
* constraint slots was **DEFERRED, NOT REJECTED** — two sources of truth,
* free to drift, was the stated cost. `min` / `max` / `step` read exactly
* like the slots that ruling declined to add. What is retired is this inert
* spelling of them, not the idea; the ruling's own reopen condition (a
* measured case of an author shipping a spec-rejected value objectui's
* silence let through) is still the route back.
*
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
min?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
max?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
step?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Put the
* hint in `description`, which IS published. `BaseSchema.placeholder` — the
* node-level prop a renderer does read — is a DIFFERENT key and is
* unaffected.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
placeholder?: never;
}

/**
Expand Down
10 changes: 10 additions & 0 deletions packages/types/src/widget.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,6 +219,16 @@ export interface WidgetSourceRegistry {
* these. Copying them here would mirror surface that nothing reads on the
* face it already lives on.
*
* ⚠️ FOUR of those five are now ADR-0049 RETIREMENT TOMBSTONES on
* `ComponentInput` (`min` / `max` / `step` / `placeholder` — `?: never` plus
* a named Zod refusal, objectui#5905), so what this clause records is no
* longer "five keys this face declines to copy" but ONE live key
* (`inputType`) plus four unwritable ones. Copying any of them here is now
* doubly wrong: the four are REFUSED on the face they already live on, and
* `inputType` is the open fork objectui#5905 reported — `plugin-markdown`
* authors it and the serializer still drops it, which is a ruling to make,
* not a surface to mirror.
*
* Pin: `__tests__/widget-input-control-vocabulary.test.ts`.
*/
export interface WidgetInput {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/5905-componentinput-retire-constraint-keys.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
'@object-ui/types': minor
---

Retire `ComponentInput`'s four inert constraint keys — `min`, `max`, `step` and
`placeholder` (objectui#5905, ADR-0049 enforce-or-remove).

All four were declared on `ComponentInput` and read by nothing, on either path. No consumer
reads them off a `ComponentInput` value, and the manifest serializer
(`packages/sdui-parser/src/index.ts`) forwards exactly six keys per input — `name`, `type`,
`required`, `enum`, `binding`, `description` — so a value authored here could not reach the
published `sdui.manifest.json` even in principle. Re-measured on this branch's merge-base
rather than inherited from the card: a structural census over every `inputs:` array in the
repository (219 regions, all tracked files) scores `min` **0**, `max` **0**, `step` **0**
and `placeholder` **0**, against `name` 926, `type` 926, `description` 161, `enum` 114 and
`required` 87 in the same pass over the same regions — the instrument was not blind.

FROM → TO, per key:

- `min: number` → **removed**. Spell the numeric domain out in `description`, which IS
published (`'A positive integer — the contract rejects 0 and fractional values'`).
- `max: number` → **removed**. Same remedy.
- `step: number` → **removed**. Same remedy.
- `placeholder: string` → **removed**. Put the hint in `description`. ⚠️
`BaseSchema.placeholder` — the node-level prop a renderer does read — is a DIFFERENT key
and is unaffected.

The retirement kit: `?: never` on the interface (`packages/types/src/base.ts`), so authoring
one is a `tsc` error at the registration site; `retirementTombstone()` on the Zod mirror
(`packages/types/src/zod/base.zod.ts`), so an authored value is REFUSED at parse time with
`code: 'invalid_type'`, the key named in the issue `path`, and the migration note as the
message. Deleting the members outright was the option NOT taken: `ComponentInputSchema` is
a non-strict `z.object`, which strips an undeclared key silently — one silent no-op traded
for another. Pinned in
`packages/types/src/__tests__/component-input-retired-constraint-keys.test.ts`.

Two limits worth stating rather than papering over:

- The in-repo zero is what was measured. Whether anything OUTSIDE this repository writes
these keys is **not measurable from here** (the same limit objectui#5674 recorded for
`PluginComponentInput`). Converting such a write from a silent drop into a named refusal
is exactly what the tombstone buys.
- The fifth key objectui#5905 named, `inputType`, is **NOT retired here**.
`packages/plugin-markdown` authors it (`inputType: 'textarea'`), so it is
declared-and-DROPPED — a different defect that needs a ruling, not a removal.

This is not a verdict that constraint slots on `ComponentInput` were a mistake. The
neighbouring `type` field carries a maintainer ruling of 2026-08-17 recording that giving
`ComponentInput` real constraint slots was **deferred, not rejected** — `min`/`max`/`step`
read exactly like the slots that ruling declined to add. What is retired is this inert
spelling; the ruling's own reopen condition still stands.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
/**
* 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.
*/

/**
* `ComponentInput`'s four inert constraint keys are ADR-0049 RETIREMENT
* TOMBSTONES, and the refusal is LOUD (objectui#5905).
*
* ## What was measured
*
* `min` / `max` / `step` / `placeholder` were declared on `ComponentInput` and
* read by nothing, on either path:
*
* - no consumer reads them off a `ComponentInput` value; and
* - the manifest serializer (`packages/sdui-parser/src/index.ts`) forwards
* exactly six keys per input — `name`, `type`, `required`, `enum`,
* `binding`, `description` — so an authored value could not reach the
* published `sdui.manifest.json` even in principle.
*
* A structural census over EVERY `inputs:` array in the repository found zero
* authoring sites for the four; the same pass, over the same regions, counted
* 926 `name`, 926 `type` and 161 `description` sites, so the instrument was
* demonstrably not blind. Authorship from OUTSIDE this repository is not
* measurable from here (the limit objectui#5674 recorded for
* `PluginComponentInput`) — and that unmeasurable half is precisely what the
* tombstone serves: an outside write becomes a NAMED REFUSAL carrying its own
* remedy instead of a silent drop.
*
* ## Why tombstones and not deletions
*
* `ComponentInputSchema` is a NON-STRICT `z.object`, so a deleted key would be
* silently STRIPPED — one silent no-op traded for another. The tombstone keeps
* the key declared and unwritable: `?: never` on the interface (a `tsc` error
* at the authoring site) and `retirementTombstone()` on the mirror (a parse
* refusal whose message IS the migration note). Both halves are pinned below,
* plus the CONTRAST against a genuinely undeclared key, so nobody can "simplify"
* the tombstones into deletions without this file going red.
*
* ## `inputType` is NOT here, deliberately
*
* The fifth key objectui#5905 named is still live and still writable, because
* the repository AUTHORS it: `packages/plugin-markdown/src/index.tsx` declares
* `inputType: 'textarea'` on its `content` input (pinned by that package's own
* test). That is declared-and-DROPPED — a different defect from the
* declared-and-unread four — and it needs a ruling, not a removal. Its liveness
* is pinned below so the fork stays visible and closing it stays a deliberate
* edit to this file.
*
* The `@ts-expect-error` directives are REAL enforcement: this package
* type-checks its tests through `tsconfig.test.json`, so re-widening the
* declaration fails the build on the unused directive.
*/

import { describe, it, expect } from 'vitest';
import type { ComponentInput } from '../base';
import { ComponentInputSchema } from '../zod/base.zod';

/** The four retired keys, with a value an author would plausibly have written. */
const RETIRED = {
min: 0,
max: 100,
step: 1,
placeholder: 'Type here…',
} as const;

type RetiredKey = keyof typeof RETIRED;

/** A fully live input — every key here is declared AND forwarded by the serializer. */
const LIVE_INPUT = {
name: 'content',
type: 'string',
label: 'Markdown Content',
required: true,
description: 'A positive integer — the contract rejects 0 and fractional values',
} as const;

const shapeOf = (schema: unknown): Record<string, unknown> =>
(schema as { shape: Record<string, unknown> }).shape;

const describeOf = (schema: unknown, key: string): string | undefined =>
(shapeOf(schema)[key] as { description?: string } | undefined)?.description;

/* ── type-level pins: the `tsc` channel ──────────────────────────────────── */

describe('the interface tombstones make authoring a `tsc` error', () => {
it('refuses each retired key at the authoring site', () => {
const input: ComponentInput = {
name: 'content',
type: 'string',
// @ts-expect-error `min` is a retirement tombstone (objectui#5905)
min: 0,
// @ts-expect-error `max` is a retirement tombstone (objectui#5905)
max: 100,
// @ts-expect-error `step` is a retirement tombstone (objectui#5905)
step: 1,
// @ts-expect-error `placeholder` is a retirement tombstone (objectui#5905)
placeholder: 'Type here…',
};
expect(input.name).toBe('content');
});

it('keeps `inputType` WRITABLE — the fork objectui#5905 reported, not an oversight', () => {
// No `@ts-expect-error`: `plugin-markdown` authors this key today, so
// retiring it is a ruling about that registration, not a cleanup. If this
// line ever needs a directive, the fork was closed — say so on the card.
const input: ComponentInput = { name: 'content', type: 'string', inputType: 'textarea' };
expect(input.inputType).toBe('textarea');
});
});

/* ── the mirror refuses, and the refusal carries its remedy ──────────────── */

describe('the zod tombstones REFUSE, loudly (objectui#5905)', () => {
it('a fully live input still parses GREEN — the non-vacuity control, in this test', () => {
// Without this, a mirror that refused everything would satisfy every
// assertion below by accident.
const control = ComponentInputSchema.safeParse(LIVE_INPUT);
expect(control.success).toBe(true);
if (control.success) {
expect(control.data.name).toBe('content');
expect(control.data.description).toBe(LIVE_INPUT.description);
}
});

it('`inputType` still parses green — the fork half of the same control', () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, inputType: 'textarea' });
expect(result.success).toBe(true);
if (result.success) expect(result.data.inputType).toBe('textarea');
});

for (const key of Object.keys(RETIRED) as RetiredKey[]) {
it(`refuses \`${key}\`, names it in the path, and answers with its own guidance`, () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, [key]: RETIRED[key] });
expect(result.success, key).toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => String(i.path[0]) === key);
expect(issue, `no issue addressed to \`${key}\``).toBeDefined();

// The accept-set contract: same address, same code a bare `z.never()`
// reports. A `refine`-based spelling would report `custom` and was
// rejected for exactly that reason (objectui#6105).
expect(issue!.code, key).toBe('invalid_type');
expect(issue!.path, key).toEqual([key]);

// The message is the migration note, not zod's generic string.
expect(issue!.message, key).not.toContain('Invalid input: expected never, received ');
expect(issue!.message, key).toContain('RETIRED (objectui#5905)');
expect(issue!.message, key).toContain(`\`ComponentInput.${key}\``);
expect(issue!.message, key).toContain('`description`');

// ONE string, BOTH channels — the invariant `retirementTombstone()`
// exists to make unbreakable. Asserted derived (nothing hand-copied to
// rot), which is why the literal anchors above sit beside it: two empty
// strings are also equal.
expect(issue!.message, key).toBe(describeOf(ComponentInputSchema, key));
});
}

it('`placeholder` answers with the full string, including the `BaseSchema` disambiguation', () => {
// One member pinned as a LITERAL so the derived assertions above cannot all
// drift together. `BaseSchema.placeholder` is a different, live key — an
// author who trips this one must not read it as that one being retired.
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, placeholder: 'Type here…' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toBe(
'RETIRED (objectui#5905) — `ComponentInput.placeholder` was never read, and never published: '
+ 'the manifest serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and '
+ 'this is not one of them, so an authored value was silently dropped. Delete the key; put the '
+ 'hint in `description`, which IS published. `BaseSchema.placeholder`, the node-level prop, is '
+ 'a DIFFERENT key and is unaffected.',
);
}
});
});

/* ── the contrast a deletion would have produced ─────────────────────────── */

describe('a tombstone is not a deletion — the contrast, measured in one run', () => {
it('an UNDECLARED key is silently stripped, which is what deleting these four would have bought', () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, notAKeyAtAll: 'anything' });
expect(result.success).toBe(true);
if (result.success) expect(result.data).not.toHaveProperty('notAKeyAtAll');
});

it('the four stay in the mirror\'s shape — a tombstone is DECLARED, just unwritable', () => {
for (const key of Object.keys(RETIRED)) {
expect(shapeOf(ComponentInputSchema)).toHaveProperty(key);
expect(describeOf(ComponentInputSchema, key)).toContain('RETIRED (objectui#5905)');
}
});
});
94 changes: 76 additions & 18 deletions packages/types/src/base.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -544,28 +544,86 @@ export interface ComponentInput {

/**
* Specific input type (e.g., 'email', 'password' for string)
*
* ⚠️ NOT retired alongside the four tombstones below (objectui#5905), and the
* difference is measured rather than stylistic. `plugin-markdown`'s
* registration AUTHORS this key — `inputs: [{ name: 'content', …, inputType:
* 'textarea' }]` in `packages/plugin-markdown/src/index.tsx`, pinned by that
* package's own test — while the manifest serializer still drops it. That is
* declared-and-DROPPED, a different defect from the declared-and-unread four
* below: retiring it would convert one registration's silent no-op into a
* build failure without first deciding what that registration should say
* instead (delete the line, or teach the publication path to carry it). The
* fork is recorded on objectui#5905 for a ruling; until then this stays a
* live, writable key that nothing publishes.
*/
inputType?: string;

/**
* Minimum value (for number/date)
*/
min?: number;

/**
* Maximum value (for number/date)
*/
max?: number;

/**
* Step value (for number)
*/
step?: number;

/**
* Placeholder text
*/
placeholder?: string;
* ADR-0049 RETIREMENT TOMBSTONES — `min` / `max` / `step` / `placeholder`
* (objectui#5905).
*
* `?: never` is this package's tombstone convention (see `crud.ts` `confirm`
* and {@link StaticTableColumn} in `data-display.ts`): the key stays
* DECLARED and becomes UNWRITABLE, so authoring one is a `tsc` error here and
* a named parse refusal in the Zod twin (`zod/base.zod.ts`
* `ComponentInputSchema`, via `retirementTombstone()`). Deleting the members
* outright would have been the quiet option — an undeclared key is silently
* stripped by the non-strict mirror, which trades one silent no-op for
* another.
*
* What was measured (objectui#5905, re-measured on the merge-base of the
* retiring PR): no consumer reads any of the four, and the manifest
* serializer (`packages/sdui-parser/src/index.ts`) forwards exactly six keys
* per input — `name`, `type`, `required`, `enum`, `binding`, `description` —
* so a value authored here could not reach the published
* `sdui.manifest.json` even in principle. A structural census over every
* `inputs:` array in the repository found ZERO authoring sites for the four
* (the same pass counted 926 `name`, 926 `type` and 161 `description` sites,
* so the instrument was not blind). Authorship from OUTSIDE the repository is
* not measurable from here — the limit objectui#5674 recorded for
* `PluginComponentInput` — and converting such a write from a silent drop
* into a NAMED REFUSAL is exactly what these tombstones buy.
*
* ⚠️ Why a future reader must NOT read this as "these keys were a mistake":
* the neighbouring `type` field carries a maintainer ruling of 2026-08-17
* (quoted in full above) recording that giving `ComponentInput` real
* constraint slots was **DEFERRED, NOT REJECTED** — two sources of truth,
* free to drift, was the stated cost. `min` / `max` / `step` read exactly
* like the slots that ruling declined to add. What is retired is this inert
* spelling of them, not the idea; the ruling's own reopen condition (a
* measured case of an author shipping a spec-rejected value objectui's
* silence let through) is still the route back.
*
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
min?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
max?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
step?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Put the
* hint in `description`, which IS published. `BaseSchema.placeholder` — the
* node-level prop a renderer does read — is a DIFFERENT key and is
* unaffected.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
placeholder?: never;
}

/**
Expand Down
10 changes: 10 additions & 0 deletions packages/types/src/widget.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,6 +219,16 @@ export interface WidgetSourceRegistry {
* these. Copying them here would mirror surface that nothing reads on the
* face it already lives on.
*
* ⚠️ FOUR of those five are now ADR-0049 RETIREMENT TOMBSTONES on
* `ComponentInput` (`min` / `max` / `step` / `placeholder` — `?: never` plus
* a named Zod refusal, objectui#5905), so what this clause records is no
* longer "five keys this face declines to copy" but ONE live key
* (`inputType`) plus four unwritable ones. Copying any of them here is now
* doubly wrong: the four are REFUSED on the face they already live on, and
* `inputType` is the open fork objectui#5905 reported — `plugin-markdown`
* authors it and the serializer still drops it, which is a ruling to make,
* not a surface to mirror.
*
* Pin: `__tests__/widget-input-control-vocabulary.test.ts`.
*/
export interface WidgetInput {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/5905-componentinput-retire-constraint-keys.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
'@object-ui/types': minor
---

Retire `ComponentInput`'s four inert constraint keys — `min`, `max`, `step` and
`placeholder` (objectui#5905, ADR-0049 enforce-or-remove).

All four were declared on `ComponentInput` and read by nothing, on either path. No consumer
reads them off a `ComponentInput` value, and the manifest serializer
(`packages/sdui-parser/src/index.ts`) forwards exactly six keys per input — `name`, `type`,
`required`, `enum`, `binding`, `description` — so a value authored here could not reach the
published `sdui.manifest.json` even in principle. Re-measured on this branch's merge-base
rather than inherited from the card: a structural census over every `inputs:` array in the
repository (219 regions, all tracked files) scores `min` **0**, `max` **0**, `step` **0**
and `placeholder` **0**, against `name` 926, `type` 926, `description` 161, `enum` 114 and
`required` 87 in the same pass over the same regions — the instrument was not blind.

FROM → TO, per key:

- `min: number` → **removed**. Spell the numeric domain out in `description`, which IS
published (`'A positive integer — the contract rejects 0 and fractional values'`).
- `max: number` → **removed**. Same remedy.
- `step: number` → **removed**. Same remedy.
- `placeholder: string` → **removed**. Put the hint in `description`. ⚠️
`BaseSchema.placeholder` — the node-level prop a renderer does read — is a DIFFERENT key
and is unaffected.

The retirement kit: `?: never` on the interface (`packages/types/src/base.ts`), so authoring
one is a `tsc` error at the registration site; `retirementTombstone()` on the Zod mirror
(`packages/types/src/zod/base.zod.ts`), so an authored value is REFUSED at parse time with
`code: 'invalid_type'`, the key named in the issue `path`, and the migration note as the
message. Deleting the members outright was the option NOT taken: `ComponentInputSchema` is
a non-strict `z.object`, which strips an undeclared key silently — one silent no-op traded
for another. Pinned in
`packages/types/src/__tests__/component-input-retired-constraint-keys.test.ts`.

Two limits worth stating rather than papering over:

- The in-repo zero is what was measured. Whether anything OUTSIDE this repository writes
these keys is **not measurable from here** (the same limit objectui#5674 recorded for
`PluginComponentInput`). Converting such a write from a silent drop into a named refusal
is exactly what the tombstone buys.
- The fifth key objectui#5905 named, `inputType`, is **NOT retired here**.
`packages/plugin-markdown` authors it (`inputType: 'textarea'`), so it is
declared-and-DROPPED — a different defect that needs a ruling, not a removal.

This is not a verdict that constraint slots on `ComponentInput` were a mistake. The
neighbouring `type` field carries a maintainer ruling of 2026-08-17 recording that giving
`ComponentInput` real constraint slots was **deferred, not rejected** — `min`/`max`/`step`
read exactly like the slots that ruling declined to add. What is retired is this inert
spelling; the ruling's own reopen condition still stands.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
/**
* 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.
*/

/**
* `ComponentInput`'s four inert constraint keys are ADR-0049 RETIREMENT
* TOMBSTONES, and the refusal is LOUD (objectui#5905).
*
* ## What was measured
*
* `min` / `max` / `step` / `placeholder` were declared on `ComponentInput` and
* read by nothing, on either path:
*
* - no consumer reads them off a `ComponentInput` value; and
* - the manifest serializer (`packages/sdui-parser/src/index.ts`) forwards
* exactly six keys per input — `name`, `type`, `required`, `enum`,
* `binding`, `description` — so an authored value could not reach the
* published `sdui.manifest.json` even in principle.
*
* A structural census over EVERY `inputs:` array in the repository found zero
* authoring sites for the four; the same pass, over the same regions, counted
* 926 `name`, 926 `type` and 161 `description` sites, so the instrument was
* demonstrably not blind. Authorship from OUTSIDE this repository is not
* measurable from here (the limit objectui#5674 recorded for
* `PluginComponentInput`) — and that unmeasurable half is precisely what the
* tombstone serves: an outside write becomes a NAMED REFUSAL carrying its own
* remedy instead of a silent drop.
*
* ## Why tombstones and not deletions
*
* `ComponentInputSchema` is a NON-STRICT `z.object`, so a deleted key would be
* silently STRIPPED — one silent no-op traded for another. The tombstone keeps
* the key declared and unwritable: `?: never` on the interface (a `tsc` error
* at the authoring site) and `retirementTombstone()` on the mirror (a parse
* refusal whose message IS the migration note). Both halves are pinned below,
* plus the CONTRAST against a genuinely undeclared key, so nobody can "simplify"
* the tombstones into deletions without this file going red.
*
* ## `inputType` is NOT here, deliberately
*
* The fifth key objectui#5905 named is still live and still writable, because
* the repository AUTHORS it: `packages/plugin-markdown/src/index.tsx` declares
* `inputType: 'textarea'` on its `content` input (pinned by that package's own
* test). That is declared-and-DROPPED — a different defect from the
* declared-and-unread four — and it needs a ruling, not a removal. Its liveness
* is pinned below so the fork stays visible and closing it stays a deliberate
* edit to this file.
*
* The `@ts-expect-error` directives are REAL enforcement: this package
* type-checks its tests through `tsconfig.test.json`, so re-widening the
* declaration fails the build on the unused directive.
*/

import { describe, it, expect } from 'vitest';
import type { ComponentInput } from '../base';
import { ComponentInputSchema } from '../zod/base.zod';

/** The four retired keys, with a value an author would plausibly have written. */
const RETIRED = {
min: 0,
max: 100,
step: 1,
placeholder: 'Type here…',
} as const;

type RetiredKey = keyof typeof RETIRED;

/** A fully live input — every key here is declared AND forwarded by the serializer. */
const LIVE_INPUT = {
name: 'content',
type: 'string',
label: 'Markdown Content',
required: true,
description: 'A positive integer — the contract rejects 0 and fractional values',
} as const;

const shapeOf = (schema: unknown): Record<string, unknown> =>
(schema as { shape: Record<string, unknown> }).shape;

const describeOf = (schema: unknown, key: string): string | undefined =>
(shapeOf(schema)[key] as { description?: string } | undefined)?.description;

/* ── type-level pins: the `tsc` channel ──────────────────────────────────── */

describe('the interface tombstones make authoring a `tsc` error', () => {
it('refuses each retired key at the authoring site', () => {
const input: ComponentInput = {
name: 'content',
type: 'string',
// @ts-expect-error `min` is a retirement tombstone (objectui#5905)
min: 0,
// @ts-expect-error `max` is a retirement tombstone (objectui#5905)
max: 100,
// @ts-expect-error `step` is a retirement tombstone (objectui#5905)
step: 1,
// @ts-expect-error `placeholder` is a retirement tombstone (objectui#5905)
placeholder: 'Type here…',
};
expect(input.name).toBe('content');
});

it('keeps `inputType` WRITABLE — the fork objectui#5905 reported, not an oversight', () => {
// No `@ts-expect-error`: `plugin-markdown` authors this key today, so
// retiring it is a ruling about that registration, not a cleanup. If this
// line ever needs a directive, the fork was closed — say so on the card.
const input: ComponentInput = { name: 'content', type: 'string', inputType: 'textarea' };
expect(input.inputType).toBe('textarea');
});
});

/* ── the mirror refuses, and the refusal carries its remedy ──────────────── */

describe('the zod tombstones REFUSE, loudly (objectui#5905)', () => {
it('a fully live input still parses GREEN — the non-vacuity control, in this test', () => {
// Without this, a mirror that refused everything would satisfy every
// assertion below by accident.
const control = ComponentInputSchema.safeParse(LIVE_INPUT);
expect(control.success).toBe(true);
if (control.success) {
expect(control.data.name).toBe('content');
expect(control.data.description).toBe(LIVE_INPUT.description);
}
});

it('`inputType` still parses green — the fork half of the same control', () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, inputType: 'textarea' });
expect(result.success).toBe(true);
if (result.success) expect(result.data.inputType).toBe('textarea');
});

for (const key of Object.keys(RETIRED) as RetiredKey[]) {
it(`refuses \`${key}\`, names it in the path, and answers with its own guidance`, () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, [key]: RETIRED[key] });
expect(result.success, key).toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => String(i.path[0]) === key);
expect(issue, `no issue addressed to \`${key}\``).toBeDefined();

// The accept-set contract: same address, same code a bare `z.never()`
// reports. A `refine`-based spelling would report `custom` and was
// rejected for exactly that reason (objectui#6105).
expect(issue!.code, key).toBe('invalid_type');
expect(issue!.path, key).toEqual([key]);

// The message is the migration note, not zod's generic string.
expect(issue!.message, key).not.toContain('Invalid input: expected never, received ');
expect(issue!.message, key).toContain('RETIRED (objectui#5905)');
expect(issue!.message, key).toContain(`\`ComponentInput.${key}\``);
expect(issue!.message, key).toContain('`description`');

// ONE string, BOTH channels — the invariant `retirementTombstone()`
// exists to make unbreakable. Asserted derived (nothing hand-copied to
// rot), which is why the literal anchors above sit beside it: two empty
// strings are also equal.
expect(issue!.message, key).toBe(describeOf(ComponentInputSchema, key));
});
}

it('`placeholder` answers with the full string, including the `BaseSchema` disambiguation', () => {
// One member pinned as a LITERAL so the derived assertions above cannot all
// drift together. `BaseSchema.placeholder` is a different, live key — an
// author who trips this one must not read it as that one being retired.
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, placeholder: 'Type here…' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toBe(
'RETIRED (objectui#5905) — `ComponentInput.placeholder` was never read, and never published: '
+ 'the manifest serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and '
+ 'this is not one of them, so an authored value was silently dropped. Delete the key; put the '
+ 'hint in `description`, which IS published. `BaseSchema.placeholder`, the node-level prop, is '
+ 'a DIFFERENT key and is unaffected.',
);
}
});
});

/* ── the contrast a deletion would have produced ─────────────────────────── */

describe('a tombstone is not a deletion — the contrast, measured in one run', () => {
it('an UNDECLARED key is silently stripped, which is what deleting these four would have bought', () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, notAKeyAtAll: 'anything' });
expect(result.success).toBe(true);
if (result.success) expect(result.data).not.toHaveProperty('notAKeyAtAll');
});

it('the four stay in the mirror\'s shape — a tombstone is DECLARED, just unwritable', () => {
for (const key of Object.keys(RETIRED)) {
expect(shapeOf(ComponentInputSchema)).toHaveProperty(key);
expect(describeOf(ComponentInputSchema, key)).toContain('RETIRED (objectui#5905)');
}
});
});
94 changes: 76 additions & 18 deletions packages/types/src/base.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -544,28 +544,86 @@ export interface ComponentInput {

/**
* Specific input type (e.g., 'email', 'password' for string)
*
* ⚠️ NOT retired alongside the four tombstones below (objectui#5905), and the
* difference is measured rather than stylistic. `plugin-markdown`'s
* registration AUTHORS this key — `inputs: [{ name: 'content', …, inputType:
* 'textarea' }]` in `packages/plugin-markdown/src/index.tsx`, pinned by that
* package's own test — while the manifest serializer still drops it. That is
* declared-and-DROPPED, a different defect from the declared-and-unread four
* below: retiring it would convert one registration's silent no-op into a
* build failure without first deciding what that registration should say
* instead (delete the line, or teach the publication path to carry it). The
* fork is recorded on objectui#5905 for a ruling; until then this stays a
* live, writable key that nothing publishes.
*/
inputType?: string;

/**
* Minimum value (for number/date)
*/
min?: number;

/**
* Maximum value (for number/date)
*/
max?: number;

/**
* Step value (for number)
*/
step?: number;

/**
* Placeholder text
*/
placeholder?: string;
* ADR-0049 RETIREMENT TOMBSTONES — `min` / `max` / `step` / `placeholder`
* (objectui#5905).
*
* `?: never` is this package's tombstone convention (see `crud.ts` `confirm`
* and {@link StaticTableColumn} in `data-display.ts`): the key stays
* DECLARED and becomes UNWRITABLE, so authoring one is a `tsc` error here and
* a named parse refusal in the Zod twin (`zod/base.zod.ts`
* `ComponentInputSchema`, via `retirementTombstone()`). Deleting the members
* outright would have been the quiet option — an undeclared key is silently
* stripped by the non-strict mirror, which trades one silent no-op for
* another.
*
* What was measured (objectui#5905, re-measured on the merge-base of the
* retiring PR): no consumer reads any of the four, and the manifest
* serializer (`packages/sdui-parser/src/index.ts`) forwards exactly six keys
* per input — `name`, `type`, `required`, `enum`, `binding`, `description` —
* so a value authored here could not reach the published
* `sdui.manifest.json` even in principle. A structural census over every
* `inputs:` array in the repository found ZERO authoring sites for the four
* (the same pass counted 926 `name`, 926 `type` and 161 `description` sites,
* so the instrument was not blind). Authorship from OUTSIDE the repository is
* not measurable from here — the limit objectui#5674 recorded for
* `PluginComponentInput` — and converting such a write from a silent drop
* into a NAMED REFUSAL is exactly what these tombstones buy.
*
* ⚠️ Why a future reader must NOT read this as "these keys were a mistake":
* the neighbouring `type` field carries a maintainer ruling of 2026-08-17
* (quoted in full above) recording that giving `ComponentInput` real
* constraint slots was **DEFERRED, NOT REJECTED** — two sources of truth,
* free to drift, was the stated cost. `min` / `max` / `step` read exactly
* like the slots that ruling declined to add. What is retired is this inert
* spelling of them, not the idea; the ruling's own reopen condition (a
* measured case of an author shipping a spec-rejected value objectui's
* silence let through) is still the route back.
*
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
min?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
max?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
step?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Put the
* hint in `description`, which IS published. `BaseSchema.placeholder` — the
* node-level prop a renderer does read — is a DIFFERENT key and is
* unaffected.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
placeholder?: never;
}

/**
Expand Down
10 changes: 10 additions & 0 deletions packages/types/src/widget.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,6 +219,16 @@ export interface WidgetSourceRegistry {
* these. Copying them here would mirror surface that nothing reads on the
* face it already lives on.
*
* ⚠️ FOUR of those five are now ADR-0049 RETIREMENT TOMBSTONES on
* `ComponentInput` (`min` / `max` / `step` / `placeholder` — `?: never` plus
* a named Zod refusal, objectui#5905), so what this clause records is no
* longer "five keys this face declines to copy" but ONE live key
* (`inputType`) plus four unwritable ones. Copying any of them here is now
* doubly wrong: the four are REFUSED on the face they already live on, and
* `inputType` is the open fork objectui#5905 reported — `plugin-markdown`
* authors it and the serializer still drops it, which is a ruling to make,
* not a surface to mirror.
*
* Pin: `__tests__/widget-input-control-vocabulary.test.ts`.
*/
export interface WidgetInput {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/5905-componentinput-retire-constraint-keys.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
'@object-ui/types': minor
---

Retire `ComponentInput`'s four inert constraint keys — `min`, `max`, `step` and
`placeholder` (objectui#5905, ADR-0049 enforce-or-remove).

All four were declared on `ComponentInput` and read by nothing, on either path. No consumer
reads them off a `ComponentInput` value, and the manifest serializer
(`packages/sdui-parser/src/index.ts`) forwards exactly six keys per input — `name`, `type`,
`required`, `enum`, `binding`, `description` — so a value authored here could not reach the
published `sdui.manifest.json` even in principle. Re-measured on this branch's merge-base
rather than inherited from the card: a structural census over every `inputs:` array in the
repository (219 regions, all tracked files) scores `min` **0**, `max` **0**, `step` **0**
and `placeholder` **0**, against `name` 926, `type` 926, `description` 161, `enum` 114 and
`required` 87 in the same pass over the same regions — the instrument was not blind.

FROM → TO, per key:

- `min: number` → **removed**. Spell the numeric domain out in `description`, which IS
published (`'A positive integer — the contract rejects 0 and fractional values'`).
- `max: number` → **removed**. Same remedy.
- `step: number` → **removed**. Same remedy.
- `placeholder: string` → **removed**. Put the hint in `description`. ⚠️
`BaseSchema.placeholder` — the node-level prop a renderer does read — is a DIFFERENT key
and is unaffected.

The retirement kit: `?: never` on the interface (`packages/types/src/base.ts`), so authoring
one is a `tsc` error at the registration site; `retirementTombstone()` on the Zod mirror
(`packages/types/src/zod/base.zod.ts`), so an authored value is REFUSED at parse time with
`code: 'invalid_type'`, the key named in the issue `path`, and the migration note as the
message. Deleting the members outright was the option NOT taken: `ComponentInputSchema` is
a non-strict `z.object`, which strips an undeclared key silently — one silent no-op traded
for another. Pinned in
`packages/types/src/__tests__/component-input-retired-constraint-keys.test.ts`.

Two limits worth stating rather than papering over:

- The in-repo zero is what was measured. Whether anything OUTSIDE this repository writes
these keys is **not measurable from here** (the same limit objectui#5674 recorded for
`PluginComponentInput`). Converting such a write from a silent drop into a named refusal
is exactly what the tombstone buys.
- The fifth key objectui#5905 named, `inputType`, is **NOT retired here**.
`packages/plugin-markdown` authors it (`inputType: 'textarea'`), so it is
declared-and-DROPPED — a different defect that needs a ruling, not a removal.

This is not a verdict that constraint slots on `ComponentInput` were a mistake. The
neighbouring `type` field carries a maintainer ruling of 2026-08-17 recording that giving
`ComponentInput` real constraint slots was **deferred, not rejected** — `min`/`max`/`step`
read exactly like the slots that ruling declined to add. What is retired is this inert
spelling; the ruling's own reopen condition still stands.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
/**
* 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.
*/

/**
* `ComponentInput`'s four inert constraint keys are ADR-0049 RETIREMENT
* TOMBSTONES, and the refusal is LOUD (objectui#5905).
*
* ## What was measured
*
* `min` / `max` / `step` / `placeholder` were declared on `ComponentInput` and
* read by nothing, on either path:
*
* - no consumer reads them off a `ComponentInput` value; and
* - the manifest serializer (`packages/sdui-parser/src/index.ts`) forwards
* exactly six keys per input — `name`, `type`, `required`, `enum`,
* `binding`, `description` — so an authored value could not reach the
* published `sdui.manifest.json` even in principle.
*
* A structural census over EVERY `inputs:` array in the repository found zero
* authoring sites for the four; the same pass, over the same regions, counted
* 926 `name`, 926 `type` and 161 `description` sites, so the instrument was
* demonstrably not blind. Authorship from OUTSIDE this repository is not
* measurable from here (the limit objectui#5674 recorded for
* `PluginComponentInput`) — and that unmeasurable half is precisely what the
* tombstone serves: an outside write becomes a NAMED REFUSAL carrying its own
* remedy instead of a silent drop.
*
* ## Why tombstones and not deletions
*
* `ComponentInputSchema` is a NON-STRICT `z.object`, so a deleted key would be
* silently STRIPPED — one silent no-op traded for another. The tombstone keeps
* the key declared and unwritable: `?: never` on the interface (a `tsc` error
* at the authoring site) and `retirementTombstone()` on the mirror (a parse
* refusal whose message IS the migration note). Both halves are pinned below,
* plus the CONTRAST against a genuinely undeclared key, so nobody can "simplify"
* the tombstones into deletions without this file going red.
*
* ## `inputType` is NOT here, deliberately
*
* The fifth key objectui#5905 named is still live and still writable, because
* the repository AUTHORS it: `packages/plugin-markdown/src/index.tsx` declares
* `inputType: 'textarea'` on its `content` input (pinned by that package's own
* test). That is declared-and-DROPPED — a different defect from the
* declared-and-unread four — and it needs a ruling, not a removal. Its liveness
* is pinned below so the fork stays visible and closing it stays a deliberate
* edit to this file.
*
* The `@ts-expect-error` directives are REAL enforcement: this package
* type-checks its tests through `tsconfig.test.json`, so re-widening the
* declaration fails the build on the unused directive.
*/

import { describe, it, expect } from 'vitest';
import type { ComponentInput } from '../base';
import { ComponentInputSchema } from '../zod/base.zod';

/** The four retired keys, with a value an author would plausibly have written. */
const RETIRED = {
min: 0,
max: 100,
step: 1,
placeholder: 'Type here…',
} as const;

type RetiredKey = keyof typeof RETIRED;

/** A fully live input — every key here is declared AND forwarded by the serializer. */
const LIVE_INPUT = {
name: 'content',
type: 'string',
label: 'Markdown Content',
required: true,
description: 'A positive integer — the contract rejects 0 and fractional values',
} as const;

const shapeOf = (schema: unknown): Record<string, unknown> =>
(schema as { shape: Record<string, unknown> }).shape;

const describeOf = (schema: unknown, key: string): string | undefined =>
(shapeOf(schema)[key] as { description?: string } | undefined)?.description;

/* ── type-level pins: the `tsc` channel ──────────────────────────────────── */

describe('the interface tombstones make authoring a `tsc` error', () => {
it('refuses each retired key at the authoring site', () => {
const input: ComponentInput = {
name: 'content',
type: 'string',
// @ts-expect-error `min` is a retirement tombstone (objectui#5905)
min: 0,
// @ts-expect-error `max` is a retirement tombstone (objectui#5905)
max: 100,
// @ts-expect-error `step` is a retirement tombstone (objectui#5905)
step: 1,
// @ts-expect-error `placeholder` is a retirement tombstone (objectui#5905)
placeholder: 'Type here…',
};
expect(input.name).toBe('content');
});

it('keeps `inputType` WRITABLE — the fork objectui#5905 reported, not an oversight', () => {
// No `@ts-expect-error`: `plugin-markdown` authors this key today, so
// retiring it is a ruling about that registration, not a cleanup. If this
// line ever needs a directive, the fork was closed — say so on the card.
const input: ComponentInput = { name: 'content', type: 'string', inputType: 'textarea' };
expect(input.inputType).toBe('textarea');
});
});

/* ── the mirror refuses, and the refusal carries its remedy ──────────────── */

describe('the zod tombstones REFUSE, loudly (objectui#5905)', () => {
it('a fully live input still parses GREEN — the non-vacuity control, in this test', () => {
// Without this, a mirror that refused everything would satisfy every
// assertion below by accident.
const control = ComponentInputSchema.safeParse(LIVE_INPUT);
expect(control.success).toBe(true);
if (control.success) {
expect(control.data.name).toBe('content');
expect(control.data.description).toBe(LIVE_INPUT.description);
}
});

it('`inputType` still parses green — the fork half of the same control', () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, inputType: 'textarea' });
expect(result.success).toBe(true);
if (result.success) expect(result.data.inputType).toBe('textarea');
});

for (const key of Object.keys(RETIRED) as RetiredKey[]) {
it(`refuses \`${key}\`, names it in the path, and answers with its own guidance`, () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, [key]: RETIRED[key] });
expect(result.success, key).toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => String(i.path[0]) === key);
expect(issue, `no issue addressed to \`${key}\``).toBeDefined();

// The accept-set contract: same address, same code a bare `z.never()`
// reports. A `refine`-based spelling would report `custom` and was
// rejected for exactly that reason (objectui#6105).
expect(issue!.code, key).toBe('invalid_type');
expect(issue!.path, key).toEqual([key]);

// The message is the migration note, not zod's generic string.
expect(issue!.message, key).not.toContain('Invalid input: expected never, received ');
expect(issue!.message, key).toContain('RETIRED (objectui#5905)');
expect(issue!.message, key).toContain(`\`ComponentInput.${key}\``);
expect(issue!.message, key).toContain('`description`');

// ONE string, BOTH channels — the invariant `retirementTombstone()`
// exists to make unbreakable. Asserted derived (nothing hand-copied to
// rot), which is why the literal anchors above sit beside it: two empty
// strings are also equal.
expect(issue!.message, key).toBe(describeOf(ComponentInputSchema, key));
});
}

it('`placeholder` answers with the full string, including the `BaseSchema` disambiguation', () => {
// One member pinned as a LITERAL so the derived assertions above cannot all
// drift together. `BaseSchema.placeholder` is a different, live key — an
// author who trips this one must not read it as that one being retired.
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, placeholder: 'Type here…' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toBe(
'RETIRED (objectui#5905) — `ComponentInput.placeholder` was never read, and never published: '
+ 'the manifest serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and '
+ 'this is not one of them, so an authored value was silently dropped. Delete the key; put the '
+ 'hint in `description`, which IS published. `BaseSchema.placeholder`, the node-level prop, is '
+ 'a DIFFERENT key and is unaffected.',
);
}
});
});

/* ── the contrast a deletion would have produced ─────────────────────────── */

describe('a tombstone is not a deletion — the contrast, measured in one run', () => {
it('an UNDECLARED key is silently stripped, which is what deleting these four would have bought', () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, notAKeyAtAll: 'anything' });
expect(result.success).toBe(true);
if (result.success) expect(result.data).not.toHaveProperty('notAKeyAtAll');
});

it('the four stay in the mirror\'s shape — a tombstone is DECLARED, just unwritable', () => {
for (const key of Object.keys(RETIRED)) {
expect(shapeOf(ComponentInputSchema)).toHaveProperty(key);
expect(describeOf(ComponentInputSchema, key)).toContain('RETIRED (objectui#5905)');
}
});
});
94 changes: 76 additions & 18 deletions packages/types/src/base.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -544,28 +544,86 @@ export interface ComponentInput {

/**
* Specific input type (e.g., 'email', 'password' for string)
*
* ⚠️ NOT retired alongside the four tombstones below (objectui#5905), and the
* difference is measured rather than stylistic. `plugin-markdown`'s
* registration AUTHORS this key — `inputs: [{ name: 'content', …, inputType:
* 'textarea' }]` in `packages/plugin-markdown/src/index.tsx`, pinned by that
* package's own test — while the manifest serializer still drops it. That is
* declared-and-DROPPED, a different defect from the declared-and-unread four
* below: retiring it would convert one registration's silent no-op into a
* build failure without first deciding what that registration should say
* instead (delete the line, or teach the publication path to carry it). The
* fork is recorded on objectui#5905 for a ruling; until then this stays a
* live, writable key that nothing publishes.
*/
inputType?: string;

/**
* Minimum value (for number/date)
*/
min?: number;

/**
* Maximum value (for number/date)
*/
max?: number;

/**
* Step value (for number)
*/
step?: number;

/**
* Placeholder text
*/
placeholder?: string;
* ADR-0049 RETIREMENT TOMBSTONES — `min` / `max` / `step` / `placeholder`
* (objectui#5905).
*
* `?: never` is this package's tombstone convention (see `crud.ts` `confirm`
* and {@link StaticTableColumn} in `data-display.ts`): the key stays
* DECLARED and becomes UNWRITABLE, so authoring one is a `tsc` error here and
* a named parse refusal in the Zod twin (`zod/base.zod.ts`
* `ComponentInputSchema`, via `retirementTombstone()`). Deleting the members
* outright would have been the quiet option — an undeclared key is silently
* stripped by the non-strict mirror, which trades one silent no-op for
* another.
*
* What was measured (objectui#5905, re-measured on the merge-base of the
* retiring PR): no consumer reads any of the four, and the manifest
* serializer (`packages/sdui-parser/src/index.ts`) forwards exactly six keys
* per input — `name`, `type`, `required`, `enum`, `binding`, `description` —
* so a value authored here could not reach the published
* `sdui.manifest.json` even in principle. A structural census over every
* `inputs:` array in the repository found ZERO authoring sites for the four
* (the same pass counted 926 `name`, 926 `type` and 161 `description` sites,
* so the instrument was not blind). Authorship from OUTSIDE the repository is
* not measurable from here — the limit objectui#5674 recorded for
* `PluginComponentInput` — and converting such a write from a silent drop
* into a NAMED REFUSAL is exactly what these tombstones buy.
*
* ⚠️ Why a future reader must NOT read this as "these keys were a mistake":
* the neighbouring `type` field carries a maintainer ruling of 2026-08-17
* (quoted in full above) recording that giving `ComponentInput` real
* constraint slots was **DEFERRED, NOT REJECTED** — two sources of truth,
* free to drift, was the stated cost. `min` / `max` / `step` read exactly
* like the slots that ruling declined to add. What is retired is this inert
* spelling of them, not the idea; the ruling's own reopen condition (a
* measured case of an author shipping a spec-rejected value objectui's
* silence let through) is still the route back.
*
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
min?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
max?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
step?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Put the
* hint in `description`, which IS published. `BaseSchema.placeholder` — the
* node-level prop a renderer does read — is a DIFFERENT key and is
* unaffected.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
placeholder?: never;
}

/**
Expand Down
10 changes: 10 additions & 0 deletions packages/types/src/widget.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,6 +219,16 @@ export interface WidgetSourceRegistry {
* these. Copying them here would mirror surface that nothing reads on the
* face it already lives on.
*
* ⚠️ FOUR of those five are now ADR-0049 RETIREMENT TOMBSTONES on
* `ComponentInput` (`min` / `max` / `step` / `placeholder` — `?: never` plus
* a named Zod refusal, objectui#5905), so what this clause records is no
* longer "five keys this face declines to copy" but ONE live key
* (`inputType`) plus four unwritable ones. Copying any of them here is now
* doubly wrong: the four are REFUSED on the face they already live on, and
* `inputType` is the open fork objectui#5905 reported — `plugin-markdown`
* authors it and the serializer still drops it, which is a ruling to make,
* not a surface to mirror.
*
* Pin: `__tests__/widget-input-control-vocabulary.test.ts`.
*/
export interface WidgetInput {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/5905-componentinput-retire-constraint-keys.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
'@object-ui/types': minor
---

Retire `ComponentInput`'s four inert constraint keys — `min`, `max`, `step` and
`placeholder` (objectui#5905, ADR-0049 enforce-or-remove).

All four were declared on `ComponentInput` and read by nothing, on either path. No consumer
reads them off a `ComponentInput` value, and the manifest serializer
(`packages/sdui-parser/src/index.ts`) forwards exactly six keys per input — `name`, `type`,
`required`, `enum`, `binding`, `description` — so a value authored here could not reach the
published `sdui.manifest.json` even in principle. Re-measured on this branch's merge-base
rather than inherited from the card: a structural census over every `inputs:` array in the
repository (219 regions, all tracked files) scores `min` **0**, `max` **0**, `step` **0**
and `placeholder` **0**, against `name` 926, `type` 926, `description` 161, `enum` 114 and
`required` 87 in the same pass over the same regions — the instrument was not blind.

FROM → TO, per key:

- `min: number` → **removed**. Spell the numeric domain out in `description`, which IS
published (`'A positive integer — the contract rejects 0 and fractional values'`).
- `max: number` → **removed**. Same remedy.
- `step: number` → **removed**. Same remedy.
- `placeholder: string` → **removed**. Put the hint in `description`. ⚠️
`BaseSchema.placeholder` — the node-level prop a renderer does read — is a DIFFERENT key
and is unaffected.

The retirement kit: `?: never` on the interface (`packages/types/src/base.ts`), so authoring
one is a `tsc` error at the registration site; `retirementTombstone()` on the Zod mirror
(`packages/types/src/zod/base.zod.ts`), so an authored value is REFUSED at parse time with
`code: 'invalid_type'`, the key named in the issue `path`, and the migration note as the
message. Deleting the members outright was the option NOT taken: `ComponentInputSchema` is
a non-strict `z.object`, which strips an undeclared key silently — one silent no-op traded
for another. Pinned in
`packages/types/src/__tests__/component-input-retired-constraint-keys.test.ts`.

Two limits worth stating rather than papering over:

- The in-repo zero is what was measured. Whether anything OUTSIDE this repository writes
these keys is **not measurable from here** (the same limit objectui#5674 recorded for
`PluginComponentInput`). Converting such a write from a silent drop into a named refusal
is exactly what the tombstone buys.
- The fifth key objectui#5905 named, `inputType`, is **NOT retired here**.
`packages/plugin-markdown` authors it (`inputType: 'textarea'`), so it is
declared-and-DROPPED — a different defect that needs a ruling, not a removal.

This is not a verdict that constraint slots on `ComponentInput` were a mistake. The
neighbouring `type` field carries a maintainer ruling of 2026-08-17 recording that giving
`ComponentInput` real constraint slots was **deferred, not rejected** — `min`/`max`/`step`
read exactly like the slots that ruling declined to add. What is retired is this inert
spelling; the ruling's own reopen condition still stands.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
/**
* 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.
*/

/**
* `ComponentInput`'s four inert constraint keys are ADR-0049 RETIREMENT
* TOMBSTONES, and the refusal is LOUD (objectui#5905).
*
* ## What was measured
*
* `min` / `max` / `step` / `placeholder` were declared on `ComponentInput` and
* read by nothing, on either path:
*
* - no consumer reads them off a `ComponentInput` value; and
* - the manifest serializer (`packages/sdui-parser/src/index.ts`) forwards
* exactly six keys per input — `name`, `type`, `required`, `enum`,
* `binding`, `description` — so an authored value could not reach the
* published `sdui.manifest.json` even in principle.
*
* A structural census over EVERY `inputs:` array in the repository found zero
* authoring sites for the four; the same pass, over the same regions, counted
* 926 `name`, 926 `type` and 161 `description` sites, so the instrument was
* demonstrably not blind. Authorship from OUTSIDE this repository is not
* measurable from here (the limit objectui#5674 recorded for
* `PluginComponentInput`) — and that unmeasurable half is precisely what the
* tombstone serves: an outside write becomes a NAMED REFUSAL carrying its own
* remedy instead of a silent drop.
*
* ## Why tombstones and not deletions
*
* `ComponentInputSchema` is a NON-STRICT `z.object`, so a deleted key would be
* silently STRIPPED — one silent no-op traded for another. The tombstone keeps
* the key declared and unwritable: `?: never` on the interface (a `tsc` error
* at the authoring site) and `retirementTombstone()` on the mirror (a parse
* refusal whose message IS the migration note). Both halves are pinned below,
* plus the CONTRAST against a genuinely undeclared key, so nobody can "simplify"
* the tombstones into deletions without this file going red.
*
* ## `inputType` is NOT here, deliberately
*
* The fifth key objectui#5905 named is still live and still writable, because
* the repository AUTHORS it: `packages/plugin-markdown/src/index.tsx` declares
* `inputType: 'textarea'` on its `content` input (pinned by that package's own
* test). That is declared-and-DROPPED — a different defect from the
* declared-and-unread four — and it needs a ruling, not a removal. Its liveness
* is pinned below so the fork stays visible and closing it stays a deliberate
* edit to this file.
*
* The `@ts-expect-error` directives are REAL enforcement: this package
* type-checks its tests through `tsconfig.test.json`, so re-widening the
* declaration fails the build on the unused directive.
*/

import { describe, it, expect } from 'vitest';
import type { ComponentInput } from '../base';
import { ComponentInputSchema } from '../zod/base.zod';

/** The four retired keys, with a value an author would plausibly have written. */
const RETIRED = {
min: 0,
max: 100,
step: 1,
placeholder: 'Type here…',
} as const;

type RetiredKey = keyof typeof RETIRED;

/** A fully live input — every key here is declared AND forwarded by the serializer. */
const LIVE_INPUT = {
name: 'content',
type: 'string',
label: 'Markdown Content',
required: true,
description: 'A positive integer — the contract rejects 0 and fractional values',
} as const;

const shapeOf = (schema: unknown): Record<string, unknown> =>
(schema as { shape: Record<string, unknown> }).shape;

const describeOf = (schema: unknown, key: string): string | undefined =>
(shapeOf(schema)[key] as { description?: string } | undefined)?.description;

/* ── type-level pins: the `tsc` channel ──────────────────────────────────── */

describe('the interface tombstones make authoring a `tsc` error', () => {
it('refuses each retired key at the authoring site', () => {
const input: ComponentInput = {
name: 'content',
type: 'string',
// @ts-expect-error `min` is a retirement tombstone (objectui#5905)
min: 0,
// @ts-expect-error `max` is a retirement tombstone (objectui#5905)
max: 100,
// @ts-expect-error `step` is a retirement tombstone (objectui#5905)
step: 1,
// @ts-expect-error `placeholder` is a retirement tombstone (objectui#5905)
placeholder: 'Type here…',
};
expect(input.name).toBe('content');
});

it('keeps `inputType` WRITABLE — the fork objectui#5905 reported, not an oversight', () => {
// No `@ts-expect-error`: `plugin-markdown` authors this key today, so
// retiring it is a ruling about that registration, not a cleanup. If this
// line ever needs a directive, the fork was closed — say so on the card.
const input: ComponentInput = { name: 'content', type: 'string', inputType: 'textarea' };
expect(input.inputType).toBe('textarea');
});
});

/* ── the mirror refuses, and the refusal carries its remedy ──────────────── */

describe('the zod tombstones REFUSE, loudly (objectui#5905)', () => {
it('a fully live input still parses GREEN — the non-vacuity control, in this test', () => {
// Without this, a mirror that refused everything would satisfy every
// assertion below by accident.
const control = ComponentInputSchema.safeParse(LIVE_INPUT);
expect(control.success).toBe(true);
if (control.success) {
expect(control.data.name).toBe('content');
expect(control.data.description).toBe(LIVE_INPUT.description);
}
});

it('`inputType` still parses green — the fork half of the same control', () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, inputType: 'textarea' });
expect(result.success).toBe(true);
if (result.success) expect(result.data.inputType).toBe('textarea');
});

for (const key of Object.keys(RETIRED) as RetiredKey[]) {
it(`refuses \`${key}\`, names it in the path, and answers with its own guidance`, () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, [key]: RETIRED[key] });
expect(result.success, key).toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => String(i.path[0]) === key);
expect(issue, `no issue addressed to \`${key}\``).toBeDefined();

// The accept-set contract: same address, same code a bare `z.never()`
// reports. A `refine`-based spelling would report `custom` and was
// rejected for exactly that reason (objectui#6105).
expect(issue!.code, key).toBe('invalid_type');
expect(issue!.path, key).toEqual([key]);

// The message is the migration note, not zod's generic string.
expect(issue!.message, key).not.toContain('Invalid input: expected never, received ');
expect(issue!.message, key).toContain('RETIRED (objectui#5905)');
expect(issue!.message, key).toContain(`\`ComponentInput.${key}\``);
expect(issue!.message, key).toContain('`description`');

// ONE string, BOTH channels — the invariant `retirementTombstone()`
// exists to make unbreakable. Asserted derived (nothing hand-copied to
// rot), which is why the literal anchors above sit beside it: two empty
// strings are also equal.
expect(issue!.message, key).toBe(describeOf(ComponentInputSchema, key));
});
}

it('`placeholder` answers with the full string, including the `BaseSchema` disambiguation', () => {
// One member pinned as a LITERAL so the derived assertions above cannot all
// drift together. `BaseSchema.placeholder` is a different, live key — an
// author who trips this one must not read it as that one being retired.
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, placeholder: 'Type here…' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toBe(
'RETIRED (objectui#5905) — `ComponentInput.placeholder` was never read, and never published: '
+ 'the manifest serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and '
+ 'this is not one of them, so an authored value was silently dropped. Delete the key; put the '
+ 'hint in `description`, which IS published. `BaseSchema.placeholder`, the node-level prop, is '
+ 'a DIFFERENT key and is unaffected.',
);
}
});
});

/* ── the contrast a deletion would have produced ─────────────────────────── */

describe('a tombstone is not a deletion — the contrast, measured in one run', () => {
it('an UNDECLARED key is silently stripped, which is what deleting these four would have bought', () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, notAKeyAtAll: 'anything' });
expect(result.success).toBe(true);
if (result.success) expect(result.data).not.toHaveProperty('notAKeyAtAll');
});

it('the four stay in the mirror\'s shape — a tombstone is DECLARED, just unwritable', () => {
for (const key of Object.keys(RETIRED)) {
expect(shapeOf(ComponentInputSchema)).toHaveProperty(key);
expect(describeOf(ComponentInputSchema, key)).toContain('RETIRED (objectui#5905)');
}
});
});
94 changes: 76 additions & 18 deletions packages/types/src/base.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -544,28 +544,86 @@ export interface ComponentInput {

/**
* Specific input type (e.g., 'email', 'password' for string)
*
* ⚠️ NOT retired alongside the four tombstones below (objectui#5905), and the
* difference is measured rather than stylistic. `plugin-markdown`'s
* registration AUTHORS this key — `inputs: [{ name: 'content', …, inputType:
* 'textarea' }]` in `packages/plugin-markdown/src/index.tsx`, pinned by that
* package's own test — while the manifest serializer still drops it. That is
* declared-and-DROPPED, a different defect from the declared-and-unread four
* below: retiring it would convert one registration's silent no-op into a
* build failure without first deciding what that registration should say
* instead (delete the line, or teach the publication path to carry it). The
* fork is recorded on objectui#5905 for a ruling; until then this stays a
* live, writable key that nothing publishes.
*/
inputType?: string;

/**
* Minimum value (for number/date)
*/
min?: number;

/**
* Maximum value (for number/date)
*/
max?: number;

/**
* Step value (for number)
*/
step?: number;

/**
* Placeholder text
*/
placeholder?: string;
* ADR-0049 RETIREMENT TOMBSTONES — `min` / `max` / `step` / `placeholder`
* (objectui#5905).
*
* `?: never` is this package's tombstone convention (see `crud.ts` `confirm`
* and {@link StaticTableColumn} in `data-display.ts`): the key stays
* DECLARED and becomes UNWRITABLE, so authoring one is a `tsc` error here and
* a named parse refusal in the Zod twin (`zod/base.zod.ts`
* `ComponentInputSchema`, via `retirementTombstone()`). Deleting the members
* outright would have been the quiet option — an undeclared key is silently
* stripped by the non-strict mirror, which trades one silent no-op for
* another.
*
* What was measured (objectui#5905, re-measured on the merge-base of the
* retiring PR): no consumer reads any of the four, and the manifest
* serializer (`packages/sdui-parser/src/index.ts`) forwards exactly six keys
* per input — `name`, `type`, `required`, `enum`, `binding`, `description` —
* so a value authored here could not reach the published
* `sdui.manifest.json` even in principle. A structural census over every
* `inputs:` array in the repository found ZERO authoring sites for the four
* (the same pass counted 926 `name`, 926 `type` and 161 `description` sites,
* so the instrument was not blind). Authorship from OUTSIDE the repository is
* not measurable from here — the limit objectui#5674 recorded for
* `PluginComponentInput` — and converting such a write from a silent drop
* into a NAMED REFUSAL is exactly what these tombstones buy.
*
* ⚠️ Why a future reader must NOT read this as "these keys were a mistake":
* the neighbouring `type` field carries a maintainer ruling of 2026-08-17
* (quoted in full above) recording that giving `ComponentInput` real
* constraint slots was **DEFERRED, NOT REJECTED** — two sources of truth,
* free to drift, was the stated cost. `min` / `max` / `step` read exactly
* like the slots that ruling declined to add. What is retired is this inert
* spelling of them, not the idea; the ruling's own reopen condition (a
* measured case of an author shipping a spec-rejected value objectui's
* silence let through) is still the route back.
*
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
min?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
max?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
step?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Put the
* hint in `description`, which IS published. `BaseSchema.placeholder` — the
* node-level prop a renderer does read — is a DIFFERENT key and is
* unaffected.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
placeholder?: never;
}

/**
Expand Down
10 changes: 10 additions & 0 deletions packages/types/src/widget.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,6 +219,16 @@ export interface WidgetSourceRegistry {
* these. Copying them here would mirror surface that nothing reads on the
* face it already lives on.
*
* ⚠️ FOUR of those five are now ADR-0049 RETIREMENT TOMBSTONES on
* `ComponentInput` (`min` / `max` / `step` / `placeholder` — `?: never` plus
* a named Zod refusal, objectui#5905), so what this clause records is no
* longer "five keys this face declines to copy" but ONE live key
* (`inputType`) plus four unwritable ones. Copying any of them here is now
* doubly wrong: the four are REFUSED on the face they already live on, and
* `inputType` is the open fork objectui#5905 reported — `plugin-markdown`
* authors it and the serializer still drops it, which is a ruling to make,
* not a surface to mirror.
*
* Pin: `__tests__/widget-input-control-vocabulary.test.ts`.
*/
export interface WidgetInput {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/5905-componentinput-retire-constraint-keys.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
'@object-ui/types': minor
---

Retire `ComponentInput`'s four inert constraint keys — `min`, `max`, `step` and
`placeholder` (objectui#5905, ADR-0049 enforce-or-remove).

All four were declared on `ComponentInput` and read by nothing, on either path. No consumer
reads them off a `ComponentInput` value, and the manifest serializer
(`packages/sdui-parser/src/index.ts`) forwards exactly six keys per input — `name`, `type`,
`required`, `enum`, `binding`, `description` — so a value authored here could not reach the
published `sdui.manifest.json` even in principle. Re-measured on this branch's merge-base
rather than inherited from the card: a structural census over every `inputs:` array in the
repository (219 regions, all tracked files) scores `min` **0**, `max` **0**, `step` **0**
and `placeholder` **0**, against `name` 926, `type` 926, `description` 161, `enum` 114 and
`required` 87 in the same pass over the same regions — the instrument was not blind.

FROM → TO, per key:

- `min: number` → **removed**. Spell the numeric domain out in `description`, which IS
published (`'A positive integer — the contract rejects 0 and fractional values'`).
- `max: number` → **removed**. Same remedy.
- `step: number` → **removed**. Same remedy.
- `placeholder: string` → **removed**. Put the hint in `description`. ⚠️
`BaseSchema.placeholder` — the node-level prop a renderer does read — is a DIFFERENT key
and is unaffected.

The retirement kit: `?: never` on the interface (`packages/types/src/base.ts`), so authoring
one is a `tsc` error at the registration site; `retirementTombstone()` on the Zod mirror
(`packages/types/src/zod/base.zod.ts`), so an authored value is REFUSED at parse time with
`code: 'invalid_type'`, the key named in the issue `path`, and the migration note as the
message. Deleting the members outright was the option NOT taken: `ComponentInputSchema` is
a non-strict `z.object`, which strips an undeclared key silently — one silent no-op traded
for another. Pinned in
`packages/types/src/__tests__/component-input-retired-constraint-keys.test.ts`.

Two limits worth stating rather than papering over:

- The in-repo zero is what was measured. Whether anything OUTSIDE this repository writes
these keys is **not measurable from here** (the same limit objectui#5674 recorded for
`PluginComponentInput`). Converting such a write from a silent drop into a named refusal
is exactly what the tombstone buys.
- The fifth key objectui#5905 named, `inputType`, is **NOT retired here**.
`packages/plugin-markdown` authors it (`inputType: 'textarea'`), so it is
declared-and-DROPPED — a different defect that needs a ruling, not a removal.

This is not a verdict that constraint slots on `ComponentInput` were a mistake. The
neighbouring `type` field carries a maintainer ruling of 2026-08-17 recording that giving
`ComponentInput` real constraint slots was **deferred, not rejected** — `min`/`max`/`step`
read exactly like the slots that ruling declined to add. What is retired is this inert
spelling; the ruling's own reopen condition still stands.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
/**
* 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.
*/

/**
* `ComponentInput`'s four inert constraint keys are ADR-0049 RETIREMENT
* TOMBSTONES, and the refusal is LOUD (objectui#5905).
*
* ## What was measured
*
* `min` / `max` / `step` / `placeholder` were declared on `ComponentInput` and
* read by nothing, on either path:
*
* - no consumer reads them off a `ComponentInput` value; and
* - the manifest serializer (`packages/sdui-parser/src/index.ts`) forwards
* exactly six keys per input — `name`, `type`, `required`, `enum`,
* `binding`, `description` — so an authored value could not reach the
* published `sdui.manifest.json` even in principle.
*
* A structural census over EVERY `inputs:` array in the repository found zero
* authoring sites for the four; the same pass, over the same regions, counted
* 926 `name`, 926 `type` and 161 `description` sites, so the instrument was
* demonstrably not blind. Authorship from OUTSIDE this repository is not
* measurable from here (the limit objectui#5674 recorded for
* `PluginComponentInput`) — and that unmeasurable half is precisely what the
* tombstone serves: an outside write becomes a NAMED REFUSAL carrying its own
* remedy instead of a silent drop.
*
* ## Why tombstones and not deletions
*
* `ComponentInputSchema` is a NON-STRICT `z.object`, so a deleted key would be
* silently STRIPPED — one silent no-op traded for another. The tombstone keeps
* the key declared and unwritable: `?: never` on the interface (a `tsc` error
* at the authoring site) and `retirementTombstone()` on the mirror (a parse
* refusal whose message IS the migration note). Both halves are pinned below,
* plus the CONTRAST against a genuinely undeclared key, so nobody can "simplify"
* the tombstones into deletions without this file going red.
*
* ## `inputType` is NOT here, deliberately
*
* The fifth key objectui#5905 named is still live and still writable, because
* the repository AUTHORS it: `packages/plugin-markdown/src/index.tsx` declares
* `inputType: 'textarea'` on its `content` input (pinned by that package's own
* test). That is declared-and-DROPPED — a different defect from the
* declared-and-unread four — and it needs a ruling, not a removal. Its liveness
* is pinned below so the fork stays visible and closing it stays a deliberate
* edit to this file.
*
* The `@ts-expect-error` directives are REAL enforcement: this package
* type-checks its tests through `tsconfig.test.json`, so re-widening the
* declaration fails the build on the unused directive.
*/

import { describe, it, expect } from 'vitest';
import type { ComponentInput } from '../base';
import { ComponentInputSchema } from '../zod/base.zod';

/** The four retired keys, with a value an author would plausibly have written. */
const RETIRED = {
min: 0,
max: 100,
step: 1,
placeholder: 'Type here…',
} as const;

type RetiredKey = keyof typeof RETIRED;

/** A fully live input — every key here is declared AND forwarded by the serializer. */
const LIVE_INPUT = {
name: 'content',
type: 'string',
label: 'Markdown Content',
required: true,
description: 'A positive integer — the contract rejects 0 and fractional values',
} as const;

const shapeOf = (schema: unknown): Record<string, unknown> =>
(schema as { shape: Record<string, unknown> }).shape;

const describeOf = (schema: unknown, key: string): string | undefined =>
(shapeOf(schema)[key] as { description?: string } | undefined)?.description;

/* ── type-level pins: the `tsc` channel ──────────────────────────────────── */

describe('the interface tombstones make authoring a `tsc` error', () => {
it('refuses each retired key at the authoring site', () => {
const input: ComponentInput = {
name: 'content',
type: 'string',
// @ts-expect-error `min` is a retirement tombstone (objectui#5905)
min: 0,
// @ts-expect-error `max` is a retirement tombstone (objectui#5905)
max: 100,
// @ts-expect-error `step` is a retirement tombstone (objectui#5905)
step: 1,
// @ts-expect-error `placeholder` is a retirement tombstone (objectui#5905)
placeholder: 'Type here…',
};
expect(input.name).toBe('content');
});

it('keeps `inputType` WRITABLE — the fork objectui#5905 reported, not an oversight', () => {
// No `@ts-expect-error`: `plugin-markdown` authors this key today, so
// retiring it is a ruling about that registration, not a cleanup. If this
// line ever needs a directive, the fork was closed — say so on the card.
const input: ComponentInput = { name: 'content', type: 'string', inputType: 'textarea' };
expect(input.inputType).toBe('textarea');
});
});

/* ── the mirror refuses, and the refusal carries its remedy ──────────────── */

describe('the zod tombstones REFUSE, loudly (objectui#5905)', () => {
it('a fully live input still parses GREEN — the non-vacuity control, in this test', () => {
// Without this, a mirror that refused everything would satisfy every
// assertion below by accident.
const control = ComponentInputSchema.safeParse(LIVE_INPUT);
expect(control.success).toBe(true);
if (control.success) {
expect(control.data.name).toBe('content');
expect(control.data.description).toBe(LIVE_INPUT.description);
}
});

it('`inputType` still parses green — the fork half of the same control', () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, inputType: 'textarea' });
expect(result.success).toBe(true);
if (result.success) expect(result.data.inputType).toBe('textarea');
});

for (const key of Object.keys(RETIRED) as RetiredKey[]) {
it(`refuses \`${key}\`, names it in the path, and answers with its own guidance`, () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, [key]: RETIRED[key] });
expect(result.success, key).toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => String(i.path[0]) === key);
expect(issue, `no issue addressed to \`${key}\``).toBeDefined();

// The accept-set contract: same address, same code a bare `z.never()`
// reports. A `refine`-based spelling would report `custom` and was
// rejected for exactly that reason (objectui#6105).
expect(issue!.code, key).toBe('invalid_type');
expect(issue!.path, key).toEqual([key]);

// The message is the migration note, not zod's generic string.
expect(issue!.message, key).not.toContain('Invalid input: expected never, received ');
expect(issue!.message, key).toContain('RETIRED (objectui#5905)');
expect(issue!.message, key).toContain(`\`ComponentInput.${key}\``);
expect(issue!.message, key).toContain('`description`');

// ONE string, BOTH channels — the invariant `retirementTombstone()`
// exists to make unbreakable. Asserted derived (nothing hand-copied to
// rot), which is why the literal anchors above sit beside it: two empty
// strings are also equal.
expect(issue!.message, key).toBe(describeOf(ComponentInputSchema, key));
});
}

it('`placeholder` answers with the full string, including the `BaseSchema` disambiguation', () => {
// One member pinned as a LITERAL so the derived assertions above cannot all
// drift together. `BaseSchema.placeholder` is a different, live key — an
// author who trips this one must not read it as that one being retired.
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, placeholder: 'Type here…' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toBe(
'RETIRED (objectui#5905) — `ComponentInput.placeholder` was never read, and never published: '
+ 'the manifest serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and '
+ 'this is not one of them, so an authored value was silently dropped. Delete the key; put the '
+ 'hint in `description`, which IS published. `BaseSchema.placeholder`, the node-level prop, is '
+ 'a DIFFERENT key and is unaffected.',
);
}
});
});

/* ── the contrast a deletion would have produced ─────────────────────────── */

describe('a tombstone is not a deletion — the contrast, measured in one run', () => {
it('an UNDECLARED key is silently stripped, which is what deleting these four would have bought', () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, notAKeyAtAll: 'anything' });
expect(result.success).toBe(true);
if (result.success) expect(result.data).not.toHaveProperty('notAKeyAtAll');
});

it('the four stay in the mirror\'s shape — a tombstone is DECLARED, just unwritable', () => {
for (const key of Object.keys(RETIRED)) {
expect(shapeOf(ComponentInputSchema)).toHaveProperty(key);
expect(describeOf(ComponentInputSchema, key)).toContain('RETIRED (objectui#5905)');
}
});
});
94 changes: 76 additions & 18 deletions packages/types/src/base.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -544,28 +544,86 @@ export interface ComponentInput {

/**
* Specific input type (e.g., 'email', 'password' for string)
*
* ⚠️ NOT retired alongside the four tombstones below (objectui#5905), and the
* difference is measured rather than stylistic. `plugin-markdown`'s
* registration AUTHORS this key — `inputs: [{ name: 'content', …, inputType:
* 'textarea' }]` in `packages/plugin-markdown/src/index.tsx`, pinned by that
* package's own test — while the manifest serializer still drops it. That is
* declared-and-DROPPED, a different defect from the declared-and-unread four
* below: retiring it would convert one registration's silent no-op into a
* build failure without first deciding what that registration should say
* instead (delete the line, or teach the publication path to carry it). The
* fork is recorded on objectui#5905 for a ruling; until then this stays a
* live, writable key that nothing publishes.
*/
inputType?: string;

/**
* Minimum value (for number/date)
*/
min?: number;

/**
* Maximum value (for number/date)
*/
max?: number;

/**
* Step value (for number)
*/
step?: number;

/**
* Placeholder text
*/
placeholder?: string;
* ADR-0049 RETIREMENT TOMBSTONES — `min` / `max` / `step` / `placeholder`
* (objectui#5905).
*
* `?: never` is this package's tombstone convention (see `crud.ts` `confirm`
* and {@link StaticTableColumn} in `data-display.ts`): the key stays
* DECLARED and becomes UNWRITABLE, so authoring one is a `tsc` error here and
* a named parse refusal in the Zod twin (`zod/base.zod.ts`
* `ComponentInputSchema`, via `retirementTombstone()`). Deleting the members
* outright would have been the quiet option — an undeclared key is silently
* stripped by the non-strict mirror, which trades one silent no-op for
* another.
*
* What was measured (objectui#5905, re-measured on the merge-base of the
* retiring PR): no consumer reads any of the four, and the manifest
* serializer (`packages/sdui-parser/src/index.ts`) forwards exactly six keys
* per input — `name`, `type`, `required`, `enum`, `binding`, `description` —
* so a value authored here could not reach the published
* `sdui.manifest.json` even in principle. A structural census over every
* `inputs:` array in the repository found ZERO authoring sites for the four
* (the same pass counted 926 `name`, 926 `type` and 161 `description` sites,
* so the instrument was not blind). Authorship from OUTSIDE the repository is
* not measurable from here — the limit objectui#5674 recorded for
* `PluginComponentInput` — and converting such a write from a silent drop
* into a NAMED REFUSAL is exactly what these tombstones buy.
*
* ⚠️ Why a future reader must NOT read this as "these keys were a mistake":
* the neighbouring `type` field carries a maintainer ruling of 2026-08-17
* (quoted in full above) recording that giving `ComponentInput` real
* constraint slots was **DEFERRED, NOT REJECTED** — two sources of truth,
* free to drift, was the stated cost. `min` / `max` / `step` read exactly
* like the slots that ruling declined to add. What is retired is this inert
* spelling of them, not the idea; the ruling's own reopen condition (a
* measured case of an author shipping a spec-rejected value objectui's
* silence let through) is still the route back.
*
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
min?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
max?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
step?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Put the
* hint in `description`, which IS published. `BaseSchema.placeholder` — the
* node-level prop a renderer does read — is a DIFFERENT key and is
* unaffected.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
placeholder?: never;
}

/**
Expand Down
10 changes: 10 additions & 0 deletions packages/types/src/widget.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,6 +219,16 @@ export interface WidgetSourceRegistry {
* these. Copying them here would mirror surface that nothing reads on the
* face it already lives on.
*
* ⚠️ FOUR of those five are now ADR-0049 RETIREMENT TOMBSTONES on
* `ComponentInput` (`min` / `max` / `step` / `placeholder` — `?: never` plus
* a named Zod refusal, objectui#5905), so what this clause records is no
* longer "five keys this face declines to copy" but ONE live key
* (`inputType`) plus four unwritable ones. Copying any of them here is now
* doubly wrong: the four are REFUSED on the face they already live on, and
* `inputType` is the open fork objectui#5905 reported — `plugin-markdown`
* authors it and the serializer still drops it, which is a ruling to make,
* not a surface to mirror.
*
* Pin: `__tests__/widget-input-control-vocabulary.test.ts`.
*/
export interface WidgetInput {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/5905-componentinput-retire-constraint-keys.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
'@object-ui/types': minor
---

Retire `ComponentInput`'s four inert constraint keys — `min`, `max`, `step` and
`placeholder` (objectui#5905, ADR-0049 enforce-or-remove).

All four were declared on `ComponentInput` and read by nothing, on either path. No consumer
reads them off a `ComponentInput` value, and the manifest serializer
(`packages/sdui-parser/src/index.ts`) forwards exactly six keys per input — `name`, `type`,
`required`, `enum`, `binding`, `description` — so a value authored here could not reach the
published `sdui.manifest.json` even in principle. Re-measured on this branch's merge-base
rather than inherited from the card: a structural census over every `inputs:` array in the
repository (219 regions, all tracked files) scores `min` **0**, `max` **0**, `step` **0**
and `placeholder` **0**, against `name` 926, `type` 926, `description` 161, `enum` 114 and
`required` 87 in the same pass over the same regions — the instrument was not blind.

FROM → TO, per key:

- `min: number` → **removed**. Spell the numeric domain out in `description`, which IS
published (`'A positive integer — the contract rejects 0 and fractional values'`).
- `max: number` → **removed**. Same remedy.
- `step: number` → **removed**. Same remedy.
- `placeholder: string` → **removed**. Put the hint in `description`. ⚠️
`BaseSchema.placeholder` — the node-level prop a renderer does read — is a DIFFERENT key
and is unaffected.

The retirement kit: `?: never` on the interface (`packages/types/src/base.ts`), so authoring
one is a `tsc` error at the registration site; `retirementTombstone()` on the Zod mirror
(`packages/types/src/zod/base.zod.ts`), so an authored value is REFUSED at parse time with
`code: 'invalid_type'`, the key named in the issue `path`, and the migration note as the
message. Deleting the members outright was the option NOT taken: `ComponentInputSchema` is
a non-strict `z.object`, which strips an undeclared key silently — one silent no-op traded
for another. Pinned in
`packages/types/src/__tests__/component-input-retired-constraint-keys.test.ts`.

Two limits worth stating rather than papering over:

- The in-repo zero is what was measured. Whether anything OUTSIDE this repository writes
these keys is **not measurable from here** (the same limit objectui#5674 recorded for
`PluginComponentInput`). Converting such a write from a silent drop into a named refusal
is exactly what the tombstone buys.
- The fifth key objectui#5905 named, `inputType`, is **NOT retired here**.
`packages/plugin-markdown` authors it (`inputType: 'textarea'`), so it is
declared-and-DROPPED — a different defect that needs a ruling, not a removal.

This is not a verdict that constraint slots on `ComponentInput` were a mistake. The
neighbouring `type` field carries a maintainer ruling of 2026-08-17 recording that giving
`ComponentInput` real constraint slots was **deferred, not rejected** — `min`/`max`/`step`
read exactly like the slots that ruling declined to add. What is retired is this inert
spelling; the ruling's own reopen condition still stands.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
/**
* 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.
*/

/**
* `ComponentInput`'s four inert constraint keys are ADR-0049 RETIREMENT
* TOMBSTONES, and the refusal is LOUD (objectui#5905).
*
* ## What was measured
*
* `min` / `max` / `step` / `placeholder` were declared on `ComponentInput` and
* read by nothing, on either path:
*
* - no consumer reads them off a `ComponentInput` value; and
* - the manifest serializer (`packages/sdui-parser/src/index.ts`) forwards
* exactly six keys per input — `name`, `type`, `required`, `enum`,
* `binding`, `description` — so an authored value could not reach the
* published `sdui.manifest.json` even in principle.
*
* A structural census over EVERY `inputs:` array in the repository found zero
* authoring sites for the four; the same pass, over the same regions, counted
* 926 `name`, 926 `type` and 161 `description` sites, so the instrument was
* demonstrably not blind. Authorship from OUTSIDE this repository is not
* measurable from here (the limit objectui#5674 recorded for
* `PluginComponentInput`) — and that unmeasurable half is precisely what the
* tombstone serves: an outside write becomes a NAMED REFUSAL carrying its own
* remedy instead of a silent drop.
*
* ## Why tombstones and not deletions
*
* `ComponentInputSchema` is a NON-STRICT `z.object`, so a deleted key would be
* silently STRIPPED — one silent no-op traded for another. The tombstone keeps
* the key declared and unwritable: `?: never` on the interface (a `tsc` error
* at the authoring site) and `retirementTombstone()` on the mirror (a parse
* refusal whose message IS the migration note). Both halves are pinned below,
* plus the CONTRAST against a genuinely undeclared key, so nobody can "simplify"
* the tombstones into deletions without this file going red.
*
* ## `inputType` is NOT here, deliberately
*
* The fifth key objectui#5905 named is still live and still writable, because
* the repository AUTHORS it: `packages/plugin-markdown/src/index.tsx` declares
* `inputType: 'textarea'` on its `content` input (pinned by that package's own
* test). That is declared-and-DROPPED — a different defect from the
* declared-and-unread four — and it needs a ruling, not a removal. Its liveness
* is pinned below so the fork stays visible and closing it stays a deliberate
* edit to this file.
*
* The `@ts-expect-error` directives are REAL enforcement: this package
* type-checks its tests through `tsconfig.test.json`, so re-widening the
* declaration fails the build on the unused directive.
*/

import { describe, it, expect } from 'vitest';
import type { ComponentInput } from '../base';
import { ComponentInputSchema } from '../zod/base.zod';

/** The four retired keys, with a value an author would plausibly have written. */
const RETIRED = {
min: 0,
max: 100,
step: 1,
placeholder: 'Type here…',
} as const;

type RetiredKey = keyof typeof RETIRED;

/** A fully live input — every key here is declared AND forwarded by the serializer. */
const LIVE_INPUT = {
name: 'content',
type: 'string',
label: 'Markdown Content',
required: true,
description: 'A positive integer — the contract rejects 0 and fractional values',
} as const;

const shapeOf = (schema: unknown): Record<string, unknown> =>
(schema as { shape: Record<string, unknown> }).shape;

const describeOf = (schema: unknown, key: string): string | undefined =>
(shapeOf(schema)[key] as { description?: string } | undefined)?.description;

/* ── type-level pins: the `tsc` channel ──────────────────────────────────── */

describe('the interface tombstones make authoring a `tsc` error', () => {
it('refuses each retired key at the authoring site', () => {
const input: ComponentInput = {
name: 'content',
type: 'string',
// @ts-expect-error `min` is a retirement tombstone (objectui#5905)
min: 0,
// @ts-expect-error `max` is a retirement tombstone (objectui#5905)
max: 100,
// @ts-expect-error `step` is a retirement tombstone (objectui#5905)
step: 1,
// @ts-expect-error `placeholder` is a retirement tombstone (objectui#5905)
placeholder: 'Type here…',
};
expect(input.name).toBe('content');
});

it('keeps `inputType` WRITABLE — the fork objectui#5905 reported, not an oversight', () => {
// No `@ts-expect-error`: `plugin-markdown` authors this key today, so
// retiring it is a ruling about that registration, not a cleanup. If this
// line ever needs a directive, the fork was closed — say so on the card.
const input: ComponentInput = { name: 'content', type: 'string', inputType: 'textarea' };
expect(input.inputType).toBe('textarea');
});
});

/* ── the mirror refuses, and the refusal carries its remedy ──────────────── */

describe('the zod tombstones REFUSE, loudly (objectui#5905)', () => {
it('a fully live input still parses GREEN — the non-vacuity control, in this test', () => {
// Without this, a mirror that refused everything would satisfy every
// assertion below by accident.
const control = ComponentInputSchema.safeParse(LIVE_INPUT);
expect(control.success).toBe(true);
if (control.success) {
expect(control.data.name).toBe('content');
expect(control.data.description).toBe(LIVE_INPUT.description);
}
});

it('`inputType` still parses green — the fork half of the same control', () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, inputType: 'textarea' });
expect(result.success).toBe(true);
if (result.success) expect(result.data.inputType).toBe('textarea');
});

for (const key of Object.keys(RETIRED) as RetiredKey[]) {
it(`refuses \`${key}\`, names it in the path, and answers with its own guidance`, () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, [key]: RETIRED[key] });
expect(result.success, key).toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => String(i.path[0]) === key);
expect(issue, `no issue addressed to \`${key}\``).toBeDefined();

// The accept-set contract: same address, same code a bare `z.never()`
// reports. A `refine`-based spelling would report `custom` and was
// rejected for exactly that reason (objectui#6105).
expect(issue!.code, key).toBe('invalid_type');
expect(issue!.path, key).toEqual([key]);

// The message is the migration note, not zod's generic string.
expect(issue!.message, key).not.toContain('Invalid input: expected never, received ');
expect(issue!.message, key).toContain('RETIRED (objectui#5905)');
expect(issue!.message, key).toContain(`\`ComponentInput.${key}\``);
expect(issue!.message, key).toContain('`description`');

// ONE string, BOTH channels — the invariant `retirementTombstone()`
// exists to make unbreakable. Asserted derived (nothing hand-copied to
// rot), which is why the literal anchors above sit beside it: two empty
// strings are also equal.
expect(issue!.message, key).toBe(describeOf(ComponentInputSchema, key));
});
}

it('`placeholder` answers with the full string, including the `BaseSchema` disambiguation', () => {
// One member pinned as a LITERAL so the derived assertions above cannot all
// drift together. `BaseSchema.placeholder` is a different, live key — an
// author who trips this one must not read it as that one being retired.
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, placeholder: 'Type here…' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toBe(
'RETIRED (objectui#5905) — `ComponentInput.placeholder` was never read, and never published: '
+ 'the manifest serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and '
+ 'this is not one of them, so an authored value was silently dropped. Delete the key; put the '
+ 'hint in `description`, which IS published. `BaseSchema.placeholder`, the node-level prop, is '
+ 'a DIFFERENT key and is unaffected.',
);
}
});
});

/* ── the contrast a deletion would have produced ─────────────────────────── */

describe('a tombstone is not a deletion — the contrast, measured in one run', () => {
it('an UNDECLARED key is silently stripped, which is what deleting these four would have bought', () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, notAKeyAtAll: 'anything' });
expect(result.success).toBe(true);
if (result.success) expect(result.data).not.toHaveProperty('notAKeyAtAll');
});

it('the four stay in the mirror\'s shape — a tombstone is DECLARED, just unwritable', () => {
for (const key of Object.keys(RETIRED)) {
expect(shapeOf(ComponentInputSchema)).toHaveProperty(key);
expect(describeOf(ComponentInputSchema, key)).toContain('RETIRED (objectui#5905)');
}
});
});
94 changes: 76 additions & 18 deletions packages/types/src/base.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -544,28 +544,86 @@ export interface ComponentInput {

/**
* Specific input type (e.g., 'email', 'password' for string)
*
* ⚠️ NOT retired alongside the four tombstones below (objectui#5905), and the
* difference is measured rather than stylistic. `plugin-markdown`'s
* registration AUTHORS this key — `inputs: [{ name: 'content', …, inputType:
* 'textarea' }]` in `packages/plugin-markdown/src/index.tsx`, pinned by that
* package's own test — while the manifest serializer still drops it. That is
* declared-and-DROPPED, a different defect from the declared-and-unread four
* below: retiring it would convert one registration's silent no-op into a
* build failure without first deciding what that registration should say
* instead (delete the line, or teach the publication path to carry it). The
* fork is recorded on objectui#5905 for a ruling; until then this stays a
* live, writable key that nothing publishes.
*/
inputType?: string;

/**
* Minimum value (for number/date)
*/
min?: number;

/**
* Maximum value (for number/date)
*/
max?: number;

/**
* Step value (for number)
*/
step?: number;

/**
* Placeholder text
*/
placeholder?: string;
* ADR-0049 RETIREMENT TOMBSTONES — `min` / `max` / `step` / `placeholder`
* (objectui#5905).
*
* `?: never` is this package's tombstone convention (see `crud.ts` `confirm`
* and {@link StaticTableColumn} in `data-display.ts`): the key stays
* DECLARED and becomes UNWRITABLE, so authoring one is a `tsc` error here and
* a named parse refusal in the Zod twin (`zod/base.zod.ts`
* `ComponentInputSchema`, via `retirementTombstone()`). Deleting the members
* outright would have been the quiet option — an undeclared key is silently
* stripped by the non-strict mirror, which trades one silent no-op for
* another.
*
* What was measured (objectui#5905, re-measured on the merge-base of the
* retiring PR): no consumer reads any of the four, and the manifest
* serializer (`packages/sdui-parser/src/index.ts`) forwards exactly six keys
* per input — `name`, `type`, `required`, `enum`, `binding`, `description` —
* so a value authored here could not reach the published
* `sdui.manifest.json` even in principle. A structural census over every
* `inputs:` array in the repository found ZERO authoring sites for the four
* (the same pass counted 926 `name`, 926 `type` and 161 `description` sites,
* so the instrument was not blind). Authorship from OUTSIDE the repository is
* not measurable from here — the limit objectui#5674 recorded for
* `PluginComponentInput` — and converting such a write from a silent drop
* into a NAMED REFUSAL is exactly what these tombstones buy.
*
* ⚠️ Why a future reader must NOT read this as "these keys were a mistake":
* the neighbouring `type` field carries a maintainer ruling of 2026-08-17
* (quoted in full above) recording that giving `ComponentInput` real
* constraint slots was **DEFERRED, NOT REJECTED** — two sources of truth,
* free to drift, was the stated cost. `min` / `max` / `step` read exactly
* like the slots that ruling declined to add. What is retired is this inert
* spelling of them, not the idea; the ruling's own reopen condition (a
* measured case of an author shipping a spec-rejected value objectui's
* silence let through) is still the route back.
*
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
min?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
max?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
step?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Put the
* hint in `description`, which IS published. `BaseSchema.placeholder` — the
* node-level prop a renderer does read — is a DIFFERENT key and is
* unaffected.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
placeholder?: never;
}

/**
Expand Down
10 changes: 10 additions & 0 deletions packages/types/src/widget.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,6 +219,16 @@ export interface WidgetSourceRegistry {
* these. Copying them here would mirror surface that nothing reads on the
* face it already lives on.
*
* ⚠️ FOUR of those five are now ADR-0049 RETIREMENT TOMBSTONES on
* `ComponentInput` (`min` / `max` / `step` / `placeholder` — `?: never` plus
* a named Zod refusal, objectui#5905), so what this clause records is no
* longer "five keys this face declines to copy" but ONE live key
* (`inputType`) plus four unwritable ones. Copying any of them here is now
* doubly wrong: the four are REFUSED on the face they already live on, and
* `inputType` is the open fork objectui#5905 reported — `plugin-markdown`
* authors it and the serializer still drops it, which is a ruling to make,
* not a surface to mirror.
*
* Pin: `__tests__/widget-input-control-vocabulary.test.ts`.
*/
export interface WidgetInput {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/5905-componentinput-retire-constraint-keys.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
'@object-ui/types': minor
---

Retire `ComponentInput`'s four inert constraint keys — `min`, `max`, `step` and
`placeholder` (objectui#5905, ADR-0049 enforce-or-remove).

All four were declared on `ComponentInput` and read by nothing, on either path. No consumer
reads them off a `ComponentInput` value, and the manifest serializer
(`packages/sdui-parser/src/index.ts`) forwards exactly six keys per input — `name`, `type`,
`required`, `enum`, `binding`, `description` — so a value authored here could not reach the
published `sdui.manifest.json` even in principle. Re-measured on this branch's merge-base
rather than inherited from the card: a structural census over every `inputs:` array in the
repository (219 regions, all tracked files) scores `min` **0**, `max` **0**, `step` **0**
and `placeholder` **0**, against `name` 926, `type` 926, `description` 161, `enum` 114 and
`required` 87 in the same pass over the same regions — the instrument was not blind.

FROM → TO, per key:

- `min: number` → **removed**. Spell the numeric domain out in `description`, which IS
published (`'A positive integer — the contract rejects 0 and fractional values'`).
- `max: number` → **removed**. Same remedy.
- `step: number` → **removed**. Same remedy.
- `placeholder: string` → **removed**. Put the hint in `description`. ⚠️
`BaseSchema.placeholder` — the node-level prop a renderer does read — is a DIFFERENT key
and is unaffected.

The retirement kit: `?: never` on the interface (`packages/types/src/base.ts`), so authoring
one is a `tsc` error at the registration site; `retirementTombstone()` on the Zod mirror
(`packages/types/src/zod/base.zod.ts`), so an authored value is REFUSED at parse time with
`code: 'invalid_type'`, the key named in the issue `path`, and the migration note as the
message. Deleting the members outright was the option NOT taken: `ComponentInputSchema` is
a non-strict `z.object`, which strips an undeclared key silently — one silent no-op traded
for another. Pinned in
`packages/types/src/__tests__/component-input-retired-constraint-keys.test.ts`.

Two limits worth stating rather than papering over:

- The in-repo zero is what was measured. Whether anything OUTSIDE this repository writes
these keys is **not measurable from here** (the same limit objectui#5674 recorded for
`PluginComponentInput`). Converting such a write from a silent drop into a named refusal
is exactly what the tombstone buys.
- The fifth key objectui#5905 named, `inputType`, is **NOT retired here**.
`packages/plugin-markdown` authors it (`inputType: 'textarea'`), so it is
declared-and-DROPPED — a different defect that needs a ruling, not a removal.

This is not a verdict that constraint slots on `ComponentInput` were a mistake. The
neighbouring `type` field carries a maintainer ruling of 2026-08-17 recording that giving
`ComponentInput` real constraint slots was **deferred, not rejected** — `min`/`max`/`step`
read exactly like the slots that ruling declined to add. What is retired is this inert
spelling; the ruling's own reopen condition still stands.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
/**
* 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.
*/

/**
* `ComponentInput`'s four inert constraint keys are ADR-0049 RETIREMENT
* TOMBSTONES, and the refusal is LOUD (objectui#5905).
*
* ## What was measured
*
* `min` / `max` / `step` / `placeholder` were declared on `ComponentInput` and
* read by nothing, on either path:
*
* - no consumer reads them off a `ComponentInput` value; and
* - the manifest serializer (`packages/sdui-parser/src/index.ts`) forwards
* exactly six keys per input — `name`, `type`, `required`, `enum`,
* `binding`, `description` — so an authored value could not reach the
* published `sdui.manifest.json` even in principle.
*
* A structural census over EVERY `inputs:` array in the repository found zero
* authoring sites for the four; the same pass, over the same regions, counted
* 926 `name`, 926 `type` and 161 `description` sites, so the instrument was
* demonstrably not blind. Authorship from OUTSIDE this repository is not
* measurable from here (the limit objectui#5674 recorded for
* `PluginComponentInput`) — and that unmeasurable half is precisely what the
* tombstone serves: an outside write becomes a NAMED REFUSAL carrying its own
* remedy instead of a silent drop.
*
* ## Why tombstones and not deletions
*
* `ComponentInputSchema` is a NON-STRICT `z.object`, so a deleted key would be
* silently STRIPPED — one silent no-op traded for another. The tombstone keeps
* the key declared and unwritable: `?: never` on the interface (a `tsc` error
* at the authoring site) and `retirementTombstone()` on the mirror (a parse
* refusal whose message IS the migration note). Both halves are pinned below,
* plus the CONTRAST against a genuinely undeclared key, so nobody can "simplify"
* the tombstones into deletions without this file going red.
*
* ## `inputType` is NOT here, deliberately
*
* The fifth key objectui#5905 named is still live and still writable, because
* the repository AUTHORS it: `packages/plugin-markdown/src/index.tsx` declares
* `inputType: 'textarea'` on its `content` input (pinned by that package's own
* test). That is declared-and-DROPPED — a different defect from the
* declared-and-unread four — and it needs a ruling, not a removal. Its liveness
* is pinned below so the fork stays visible and closing it stays a deliberate
* edit to this file.
*
* The `@ts-expect-error` directives are REAL enforcement: this package
* type-checks its tests through `tsconfig.test.json`, so re-widening the
* declaration fails the build on the unused directive.
*/

import { describe, it, expect } from 'vitest';
import type { ComponentInput } from '../base';
import { ComponentInputSchema } from '../zod/base.zod';

/** The four retired keys, with a value an author would plausibly have written. */
const RETIRED = {
min: 0,
max: 100,
step: 1,
placeholder: 'Type here…',
} as const;

type RetiredKey = keyof typeof RETIRED;

/** A fully live input — every key here is declared AND forwarded by the serializer. */
const LIVE_INPUT = {
name: 'content',
type: 'string',
label: 'Markdown Content',
required: true,
description: 'A positive integer — the contract rejects 0 and fractional values',
} as const;

const shapeOf = (schema: unknown): Record<string, unknown> =>
(schema as { shape: Record<string, unknown> }).shape;

const describeOf = (schema: unknown, key: string): string | undefined =>
(shapeOf(schema)[key] as { description?: string } | undefined)?.description;

/* ── type-level pins: the `tsc` channel ──────────────────────────────────── */

describe('the interface tombstones make authoring a `tsc` error', () => {
it('refuses each retired key at the authoring site', () => {
const input: ComponentInput = {
name: 'content',
type: 'string',
// @ts-expect-error `min` is a retirement tombstone (objectui#5905)
min: 0,
// @ts-expect-error `max` is a retirement tombstone (objectui#5905)
max: 100,
// @ts-expect-error `step` is a retirement tombstone (objectui#5905)
step: 1,
// @ts-expect-error `placeholder` is a retirement tombstone (objectui#5905)
placeholder: 'Type here…',
};
expect(input.name).toBe('content');
});

it('keeps `inputType` WRITABLE — the fork objectui#5905 reported, not an oversight', () => {
// No `@ts-expect-error`: `plugin-markdown` authors this key today, so
// retiring it is a ruling about that registration, not a cleanup. If this
// line ever needs a directive, the fork was closed — say so on the card.
const input: ComponentInput = { name: 'content', type: 'string', inputType: 'textarea' };
expect(input.inputType).toBe('textarea');
});
});

/* ── the mirror refuses, and the refusal carries its remedy ──────────────── */

describe('the zod tombstones REFUSE, loudly (objectui#5905)', () => {
it('a fully live input still parses GREEN — the non-vacuity control, in this test', () => {
// Without this, a mirror that refused everything would satisfy every
// assertion below by accident.
const control = ComponentInputSchema.safeParse(LIVE_INPUT);
expect(control.success).toBe(true);
if (control.success) {
expect(control.data.name).toBe('content');
expect(control.data.description).toBe(LIVE_INPUT.description);
}
});

it('`inputType` still parses green — the fork half of the same control', () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, inputType: 'textarea' });
expect(result.success).toBe(true);
if (result.success) expect(result.data.inputType).toBe('textarea');
});

for (const key of Object.keys(RETIRED) as RetiredKey[]) {
it(`refuses \`${key}\`, names it in the path, and answers with its own guidance`, () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, [key]: RETIRED[key] });
expect(result.success, key).toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => String(i.path[0]) === key);
expect(issue, `no issue addressed to \`${key}\``).toBeDefined();

// The accept-set contract: same address, same code a bare `z.never()`
// reports. A `refine`-based spelling would report `custom` and was
// rejected for exactly that reason (objectui#6105).
expect(issue!.code, key).toBe('invalid_type');
expect(issue!.path, key).toEqual([key]);

// The message is the migration note, not zod's generic string.
expect(issue!.message, key).not.toContain('Invalid input: expected never, received ');
expect(issue!.message, key).toContain('RETIRED (objectui#5905)');
expect(issue!.message, key).toContain(`\`ComponentInput.${key}\``);
expect(issue!.message, key).toContain('`description`');

// ONE string, BOTH channels — the invariant `retirementTombstone()`
// exists to make unbreakable. Asserted derived (nothing hand-copied to
// rot), which is why the literal anchors above sit beside it: two empty
// strings are also equal.
expect(issue!.message, key).toBe(describeOf(ComponentInputSchema, key));
});
}

it('`placeholder` answers with the full string, including the `BaseSchema` disambiguation', () => {
// One member pinned as a LITERAL so the derived assertions above cannot all
// drift together. `BaseSchema.placeholder` is a different, live key — an
// author who trips this one must not read it as that one being retired.
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, placeholder: 'Type here…' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toBe(
'RETIRED (objectui#5905) — `ComponentInput.placeholder` was never read, and never published: '
+ 'the manifest serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and '
+ 'this is not one of them, so an authored value was silently dropped. Delete the key; put the '
+ 'hint in `description`, which IS published. `BaseSchema.placeholder`, the node-level prop, is '
+ 'a DIFFERENT key and is unaffected.',
);
}
});
});

/* ── the contrast a deletion would have produced ─────────────────────────── */

describe('a tombstone is not a deletion — the contrast, measured in one run', () => {
it('an UNDECLARED key is silently stripped, which is what deleting these four would have bought', () => {
const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, notAKeyAtAll: 'anything' });
expect(result.success).toBe(true);
if (result.success) expect(result.data).not.toHaveProperty('notAKeyAtAll');
});

it('the four stay in the mirror\'s shape — a tombstone is DECLARED, just unwritable', () => {
for (const key of Object.keys(RETIRED)) {
expect(shapeOf(ComponentInputSchema)).toHaveProperty(key);
expect(describeOf(ComponentInputSchema, key)).toContain('RETIRED (objectui#5905)');
}
});
});
94 changes: 76 additions & 18 deletions packages/types/src/base.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -544,28 +544,86 @@ export interface ComponentInput {

/**
* Specific input type (e.g., 'email', 'password' for string)
*
* ⚠️ NOT retired alongside the four tombstones below (objectui#5905), and the
* difference is measured rather than stylistic. `plugin-markdown`'s
* registration AUTHORS this key — `inputs: [{ name: 'content', …, inputType:
* 'textarea' }]` in `packages/plugin-markdown/src/index.tsx`, pinned by that
* package's own test — while the manifest serializer still drops it. That is
* declared-and-DROPPED, a different defect from the declared-and-unread four
* below: retiring it would convert one registration's silent no-op into a
* build failure without first deciding what that registration should say
* instead (delete the line, or teach the publication path to carry it). The
* fork is recorded on objectui#5905 for a ruling; until then this stays a
* live, writable key that nothing publishes.
*/
inputType?: string;

/**
* Minimum value (for number/date)
*/
min?: number;

/**
* Maximum value (for number/date)
*/
max?: number;

/**
* Step value (for number)
*/
step?: number;

/**
* Placeholder text
*/
placeholder?: string;
* ADR-0049 RETIREMENT TOMBSTONES — `min` / `max` / `step` / `placeholder`
* (objectui#5905).
*
* `?: never` is this package's tombstone convention (see `crud.ts` `confirm`
* and {@link StaticTableColumn} in `data-display.ts`): the key stays
* DECLARED and becomes UNWRITABLE, so authoring one is a `tsc` error here and
* a named parse refusal in the Zod twin (`zod/base.zod.ts`
* `ComponentInputSchema`, via `retirementTombstone()`). Deleting the members
* outright would have been the quiet option — an undeclared key is silently
* stripped by the non-strict mirror, which trades one silent no-op for
* another.
*
* What was measured (objectui#5905, re-measured on the merge-base of the
* retiring PR): no consumer reads any of the four, and the manifest
* serializer (`packages/sdui-parser/src/index.ts`) forwards exactly six keys
* per input — `name`, `type`, `required`, `enum`, `binding`, `description` —
* so a value authored here could not reach the published
* `sdui.manifest.json` even in principle. A structural census over every
* `inputs:` array in the repository found ZERO authoring sites for the four
* (the same pass counted 926 `name`, 926 `type` and 161 `description` sites,
* so the instrument was not blind). Authorship from OUTSIDE the repository is
* not measurable from here — the limit objectui#5674 recorded for
* `PluginComponentInput` — and converting such a write from a silent drop
* into a NAMED REFUSAL is exactly what these tombstones buy.
*
* ⚠️ Why a future reader must NOT read this as "these keys were a mistake":
* the neighbouring `type` field carries a maintainer ruling of 2026-08-17
* (quoted in full above) recording that giving `ComponentInput` real
* constraint slots was **DEFERRED, NOT REJECTED** — two sources of truth,
* free to drift, was the stated cost. `min` / `max` / `step` read exactly
* like the slots that ruling declined to add. What is retired is this inert
* spelling of them, not the idea; the ruling's own reopen condition (a
* measured case of an author shipping a spec-rejected value objectui's
* silence let through) is still the route back.
*
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
min?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
max?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Spell
* the numeric domain out in `description`, which IS published.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
step?: never;
/**
* RETIRED (objectui#5905, ADR-0049) — never read, and never published: the
* manifest serializer forwards six keys and this is not one of them. Put the
* hint in `description`, which IS published. `BaseSchema.placeholder` — the
* node-level prop a renderer does read — is a DIFFERENT key and is
* unaffected.
* @deprecated Not part of `ComponentInput`'s contract — the value was inert.
*/
placeholder?: never;
}

/**
Expand Down
10 changes: 10 additions & 0 deletions packages/types/src/widget.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,6 +219,16 @@ export interface WidgetSourceRegistry {
* these. Copying them here would mirror surface that nothing reads on the
* face it already lives on.
*
* ⚠️ FOUR of those five are now ADR-0049 RETIREMENT TOMBSTONES on
* `ComponentInput` (`min` / `max` / `step` / `placeholder` — `?: never` plus
* a named Zod refusal, objectui#5905), so what this clause records is no
* longer "five keys this face declines to copy" but ONE live key
* (`inputType`) plus four unwritable ones. Copying any of them here is now
* doubly wrong: the four are REFUSED on the face they already live on, and
* `inputType` is the open fork objectui#5905 reported — `plugin-markdown`
* authors it and the serializer still drops it, which is a ruling to make,
* not a surface to mirror.
*
* Pin: `__tests__/widget-input-control-vocabulary.test.ts`.
*/
export interface WidgetInput {
Expand Down
Loading
Loading