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
35 changes: 35 additions & 0 deletions .changeset/7087-disabled-twin-symmetry.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@object-ui/types': minor
---

`disabled` accepts a predicate string — `boolean | string`, the `BaseSchema` union — on
the 18 concrete schemas that used to narrow it back to `boolean` (objectui#7087,
maintainer ruling 2026-09-01: option 1, scoped to `disabled`).

`visible` and `disabled` are twins: objectui#4581 widened both on `BaseSchema` on the same
evidence — `SchemaRenderer` evaluates both through `evaluator.evaluateCondition` rather
than reading either as a boolean. After that widening, 0 of the 124 `extends BaseSchema`
interfaces redeclared `visible`, while 18 still carried a pre-widening
`disabled?: boolean` of their own, with matching `z.boolean()` mirrors. So
`disabled: "${data.status === 'locked'}"` — the capability the renderer implements and
the base type advertises — was a type error and a zod refusal on `ButtonSchema`,
`InputSchema`, `TextareaSchema`, `SelectSchema`, `CheckboxSchema`, `RadioGroupSchema`,
`SwitchSchema`, `ToggleSchema`, `SliderSchema`, `FileUploadSchema`, `DatePickerSchema`,
`CalendarSchema`, `InputOTPSchema`, `FormSchema`, `ComboboxSchema`, `ActionSchema`,
`CollapsibleSchema` and `ToggleGroupSchema`.

Those 18 redeclarations are removed, on both faces. The interfaces inherit
`BaseSchema.disabled` the way they always inherited `visible`; the zod mirrors inherit
`base.zod.ts`'s `z.union([z.boolean(), z.string()])` through `.extend()`'s merged
`.shape`, so there is no second spelling of the union to drift from — the route
`ChatbotSchema` took in objectui#6169.

**Additive for authors**: a predicate string is now accepted where it was refused; every
boolean that parsed before parses unchanged, and a number is still refused at path
`disabled`. Runtime behaviour does not change — the renderer already evaluated both twins.

**Out of scope, per the ruling**: `label` (29 narrowings) and `description` (32) carry
`string | I18nLabel` i18n semantics and wait for their own ruling; the independent
`disabled?: boolean` declarations on shapes that do not extend `BaseSchema`
(`SelectOption`, `RadioOption`, `FormField`, `ComboboxOption`, `AccordionItem`,
`ToggleGroupItem`, and the rest of that family) are not narrowings and are untouched.
264 changes: 264 additions & 0 deletions packages/types/src/__tests__/disabled-twin-symmetry-7087.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `disabled` is inherited from `BaseSchema` — `boolean | string` — on every
* concrete schema that used to narrow it back to `boolean` (objectui#7087,
* maintainer ruling 2026-09-01: option 1, scoped to `disabled`).
*
* ## What was narrowed, and why the narrowing was wrong
*
* `visible` and `disabled` are twins. objectui#4581 widened both on `BaseSchema`
* to `boolean | string` on the same evidence: `SchemaRenderer` reads neither key
* as a boolean, it routes both through `evaluator.evaluateCondition`, declared
* `(condition: string | boolean | undefined, context?) => boolean`. After that
* widening, 0 of the 124 `extends BaseSchema` interfaces redeclared `visible`,
* while 18 kept a pre-widening `disabled?: boolean` of their own — 15 in
* `form.ts`, `ActionSchema` in `crud.ts`, `CollapsibleSchema` and
* `ToggleGroupSchema` in `disclosure.ts` — and their zod mirrors carried the
* matching `z.boolean()`. So `disabled: "${data.status === 'locked'}"`, the
* capability the renderer implements and `BaseSchema` advertises, was a type
* error and a zod refusal on exactly the schemas an author reaches for first.
*
* The fix is the one `ChatbotSchema` already took (objectui#6169): the key is
* not redeclared at all. The TS interface inherits the member the way `visible`
* always has, and `.extend()`'s `.shape` merges the parent's fields into the
* child's, so the base union reaches every mirror without a second spelling of
* it that could drift.
*
* ## What this file pins
*
* 1. Type level — for each of the 18, `X['disabled']` is EXACTLY
* `boolean | string | undefined`, invariantly. `Equal`, not `extends`: the
* narrow `boolean` is assignable to the wide union, so a one-way check
* stays green on a narrowing that was never removed; and `BaseSchema`'s
* `[key: string]: any` index signature means an interface that LOST the
* member reads `any`, which a one-way check also accepts. `visible` is
* asserted beside it as the twin control, so a schema that dropped both
* keys cannot pass vacuously.
* 2. Runtime — each of the 18 zod mirrors `safeParse`s a predicate string on
* `disabled` (and on `visible`, the control) and still refuses a number at
* path `disabled`. Every fixture is also parsed WITHOUT `disabled`, so a
* refusal cannot be a broken fixture wearing a green.
* 3. Scope guard — the ruling widens NARROWINGS, not independent
* declarations. The six `disabled?: boolean` shapes in these same three
* files that do not extend `BaseSchema` (`SelectOption`, `RadioOption`,
* `FormField`, `ComboboxOption`, `AccordionItem`, `ToggleGroupItem`) stay
* `boolean` on both faces.
*
* ## Predictions, written before the first run (red-first)
*
* With any one of the three source files (or its mirror) reverted to its
* `origin/main` @ `67dadd602` blob and this file in place:
*
* - TS face reverted: `tsc -p packages/types/tsconfig.test.json` reports
* TS2344 on `assertionDisabledIsBaseUnion`, naming that file's interfaces;
* `assertionVisibleTwinControl` stays clean (nothing narrowed `visible`).
* - zod face reverted: the `accepts a predicate string on disabled` cases for
* that file's mirrors fail (`success: false`), the `visible` control and the
* `refuses a number` cases for the same mirrors stay green, and every other
* file's cases stay green.
*
* The measured counts are in the PR that landed this file.
*/

import { describe, expect, it } from 'vitest';
import type { ZodType } from 'zod';

import type { BaseSchema } from '../base';
import type { ActionSchema } from '../crud';
import type {
AccordionItem,
CollapsibleSchema,
ToggleGroupItem,
ToggleGroupSchema,
} from '../disclosure';
import type {
ButtonSchema,
CalendarSchema,
CheckboxSchema,
ComboboxOption,
ComboboxSchema,
DatePickerSchema,
FileUploadSchema,
FormField,
FormSchema,
InputOTPSchema,
InputSchema,
RadioGroupSchema,
RadioOption,
SelectOption,
SelectSchema,
SliderSchema,
SwitchSchema,
TextareaSchema,
ToggleSchema,
} from '../form';

import { ActionSchema as ActionMirror } from '../zod/crud.zod';
import {
AccordionItemSchema,
CollapsibleSchema as CollapsibleMirror,
ToggleGroupItemSchema,
ToggleGroupSchema as ToggleGroupMirror,
} from '../zod/disclosure.zod';
import * as FormMirrors from '../zod/form.zod';

/* ── Type-level helpers ──────────────────────────────────────────────────── */

/** Invariant equality — `extends` both ways would accept a narrowing. */
type Equal< A, B > =
(< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false;
type Expect< T extends true > = T;

/** The union both twins carry on `BaseSchema`, pinned here so the checks below cannot drift from it. */
type BasePredicate = boolean | string | undefined;
export type assertionBaseDisabled = Expect< Equal< BaseSchema['disabled'], BasePredicate > >;
export type assertionBaseVisible = Expect< Equal< BaseSchema['visible'], BasePredicate > >;

/* ── 1. The 18 formerly-narrowed interfaces inherit the base union ───────── */

type InScope = {
ButtonSchema: ButtonSchema;
InputSchema: InputSchema;
TextareaSchema: TextareaSchema;
SelectSchema: SelectSchema;
CheckboxSchema: CheckboxSchema;
RadioGroupSchema: RadioGroupSchema;
SwitchSchema: SwitchSchema;
ToggleSchema: ToggleSchema;
SliderSchema: SliderSchema;
FileUploadSchema: FileUploadSchema;
DatePickerSchema: DatePickerSchema;
CalendarSchema: CalendarSchema;
InputOTPSchema: InputOTPSchema;
FormSchema: FormSchema;
ComboboxSchema: ComboboxSchema;
ActionSchema: ActionSchema;
CollapsibleSchema: CollapsibleSchema;
ToggleGroupSchema: ToggleGroupSchema;
};

/** The names whose `K` is NOT exactly the base union — `never` when nothing narrows (or loses) it. */
type NotBaseUnion< K extends 'disabled' | 'visible' > = {
[N in keyof InScope]: Equal< InScope[N][K], BasePredicate > extends true ? never : N;
}[keyof InScope];

export type assertionDisabledIsBaseUnion = Expect< Equal< NotBaseUnion<'disabled'>, never > >;
/** Twin control: `visible` was never narrowed, so this stays `never` on every tree. */
export type assertionVisibleTwinControl = Expect< Equal< NotBaseUnion<'visible'>, never > >;

/* ── 3. Scope guard: independent declarations are not narrowings ─────────── */

type OutOfScope = {
SelectOption: SelectOption;
RadioOption: RadioOption;
FormField: FormField;
ComboboxOption: ComboboxOption;
AccordionItem: AccordionItem;
ToggleGroupItem: ToggleGroupItem;
};

type Widened = {
[N in keyof OutOfScope]: Equal< OutOfScope[N]['disabled'], boolean | undefined > extends true ? never : N;
}[keyof OutOfScope];

export type assertionIndependentDeclarationsStayBoolean = Expect< Equal< Widened, never > >;

/* ── 2. Runtime: the zod mirrors accept what the interfaces now declare ──── */

const PREDICATE = '${data.status === "locked"}';

interface MirrorCase {
name: string;
mirror: ZodType;
/** Minimal valid node — every required key, nothing optional. */
fixture: Record<string, unknown>;
}

const IN_SCOPE: readonly MirrorCase[] = [
{ name: 'form.zod.ts#ButtonSchema', mirror: FormMirrors.ButtonSchema, fixture: { type: 'button' } },
{ name: 'form.zod.ts#InputSchema', mirror: FormMirrors.InputSchema, fixture: { type: 'input' } },
{ name: 'form.zod.ts#TextareaSchema', mirror: FormMirrors.TextareaSchema, fixture: { type: 'textarea' } },
{ name: 'form.zod.ts#SelectSchema', mirror: FormMirrors.SelectSchema, fixture: { type: 'select', options: [{ label: 'A', value: 'a' }] } },
{ name: 'form.zod.ts#CheckboxSchema', mirror: FormMirrors.CheckboxSchema, fixture: { type: 'checkbox' } },
{ name: 'form.zod.ts#RadioGroupSchema', mirror: FormMirrors.RadioGroupSchema, fixture: { type: 'radio-group', options: [{ label: 'A', value: 'a' }] } },
{ name: 'form.zod.ts#SwitchSchema', mirror: FormMirrors.SwitchSchema, fixture: { type: 'switch' } },
{ name: 'form.zod.ts#ToggleSchema', mirror: FormMirrors.ToggleSchema, fixture: { type: 'toggle' } },
{ name: 'form.zod.ts#SliderSchema', mirror: FormMirrors.SliderSchema, fixture: { type: 'slider' } },
{ name: 'form.zod.ts#FileUploadSchema', mirror: FormMirrors.FileUploadSchema, fixture: { type: 'file-upload' } },
{ name: 'form.zod.ts#DatePickerSchema', mirror: FormMirrors.DatePickerSchema, fixture: { type: 'date-picker' } },
{ name: 'form.zod.ts#CalendarSchema', mirror: FormMirrors.CalendarSchema, fixture: { type: 'calendar' } },
{ name: 'form.zod.ts#InputOTPSchema', mirror: FormMirrors.InputOTPSchema, fixture: { type: 'input-otp' } },
{ name: 'form.zod.ts#FormSchema', mirror: FormMirrors.FormSchema, fixture: { type: 'form', fields: [{ name: 'title', type: 'text' }] } },
{ name: 'form.zod.ts#ComboboxSchema', mirror: FormMirrors.ComboboxSchema, fixture: { type: 'combobox', options: [{ label: 'A', value: 'a' }] } },
{ name: 'crud.zod.ts#ActionSchema', mirror: ActionMirror, fixture: { type: 'action', label: 'Save' } },
{
name: 'disclosure.zod.ts#CollapsibleSchema',
mirror: CollapsibleMirror,
fixture: { type: 'collapsible', trigger: { type: 'button', label: 'More' }, content: { type: 'text', text: 'Body' } },
},
{ name: 'disclosure.zod.ts#ToggleGroupSchema', mirror: ToggleGroupMirror, fixture: { type: 'toggle-group' } },
];

const OUT_OF_SCOPE: readonly MirrorCase[] = [
{ name: 'form.zod.ts#SelectOptionSchema', mirror: FormMirrors.SelectOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#RadioOptionSchema', mirror: FormMirrors.RadioOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#ComboboxOptionSchema', mirror: FormMirrors.ComboboxOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#FormFieldSchema', mirror: FormMirrors.FormFieldSchema, fixture: { name: 'title' } },
{ name: 'disclosure.zod.ts#AccordionItemSchema', mirror: AccordionItemSchema, fixture: { value: 'a', title: 'A', content: { type: 'text', text: 'Body' } } },
{ name: 'disclosure.zod.ts#ToggleGroupItemSchema', mirror: ToggleGroupItemSchema, fixture: { value: 'a', label: 'A' } },
];

const issuePaths = (r: ReturnType<ZodType['safeParse']>): string[] =>
r.success ? [] : r.error.issues.map((issue) => issue.path.map(String).join('.'));

describe('`disabled` is the BaseSchema union on every formerly-narrowed mirror (objectui#7087)', () => {
it('covers the 18 interfaces the ruling names, and the 6 it excludes', () => {
// The census the ruling was made on. A mirror added to or dropped from
// either list changes the ruling's population and must say so here.
expect(IN_SCOPE).toHaveLength(18);
expect(OUT_OF_SCOPE).toHaveLength(6);
});

it.each(IN_SCOPE)('$name: the fixture is valid on its own', ({ mirror, fixture }) => {
// Control: the refusals below are about `disabled`, not about a fixture
// that never parsed.
expect(issuePaths(mirror.safeParse(fixture))).toEqual([]);
});

it.each(IN_SCOPE)('$name accepts a predicate string on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: PREDICATE });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { disabled?: unknown }).disabled).toBe(PREDICATE);
});

it.each(IN_SCOPE)('$name accepts a predicate string on `visible` — the twin control', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, visible: PREDICATE });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { visible?: unknown }).visible).toBe(PREDICATE);
});

it.each(IN_SCOPE)('$name still refuses a number on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: 1 });
expect(r.success).toBe(false);
expect(issuePaths(r)).toContain('disabled');
});

it.each(IN_SCOPE)('$name still accepts the boolean form — a widening, not a replacement', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: true });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { disabled?: unknown }).disabled).toBe(true);
});
});

describe('scope guard: the independent `disabled?: boolean` declarations are not narrowings (objectui#7087)', () => {
it.each(OUT_OF_SCOPE)('$name: the fixture is valid on its own', ({ mirror, fixture }) => {
expect(issuePaths(mirror.safeParse(fixture))).toEqual([]);
});

it.each(OUT_OF_SCOPE)('$name still refuses a string on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: PREDICATE });
expect(r.success).toBe(false);
expect(issuePaths(r)).toContain('disabled');
});
});
4 changes: 0 additions & 4 deletions packages/types/src/crud.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,10 +83,6 @@ export interface ActionSchema extends BaseSchema {
* Action variant
*/
variant?: 'default' | 'outline' | 'ghost' | 'link';
/**
* Whether action is disabled
*/
disabled?: boolean;
/**
* Action type
* Enhanced in Phase 2 with 'ajax', 'confirm', 'dialog'
Expand Down
8 changes: 0 additions & 8 deletions packages/types/src/disclosure.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,10 +103,6 @@ export interface CollapsibleSchema extends BaseSchema {
* Controlled open state
*/
open?: boolean;
/**
* Whether collapsible is disabled
*/
disabled?: boolean;
/**
* Open state change handler
*/
Expand DownExpand Up@@ -167,10 +163,6 @@ export interface ToggleGroupSchema extends BaseSchema {
* Controlled selected value(s)
*/
value?: string | string[];
/**
* Whether toggle group is disabled
*/
disabled?: boolean;
/**
* Change handler
*/
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
35 changes: 35 additions & 0 deletions .changeset/7087-disabled-twin-symmetry.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@object-ui/types': minor
---

`disabled` accepts a predicate string — `boolean | string`, the `BaseSchema` union — on
the 18 concrete schemas that used to narrow it back to `boolean` (objectui#7087,
maintainer ruling 2026-09-01: option 1, scoped to `disabled`).

`visible` and `disabled` are twins: objectui#4581 widened both on `BaseSchema` on the same
evidence — `SchemaRenderer` evaluates both through `evaluator.evaluateCondition` rather
than reading either as a boolean. After that widening, 0 of the 124 `extends BaseSchema`
interfaces redeclared `visible`, while 18 still carried a pre-widening
`disabled?: boolean` of their own, with matching `z.boolean()` mirrors. So
`disabled: "${data.status === 'locked'}"` — the capability the renderer implements and
the base type advertises — was a type error and a zod refusal on `ButtonSchema`,
`InputSchema`, `TextareaSchema`, `SelectSchema`, `CheckboxSchema`, `RadioGroupSchema`,
`SwitchSchema`, `ToggleSchema`, `SliderSchema`, `FileUploadSchema`, `DatePickerSchema`,
`CalendarSchema`, `InputOTPSchema`, `FormSchema`, `ComboboxSchema`, `ActionSchema`,
`CollapsibleSchema` and `ToggleGroupSchema`.

Those 18 redeclarations are removed, on both faces. The interfaces inherit
`BaseSchema.disabled` the way they always inherited `visible`; the zod mirrors inherit
`base.zod.ts`'s `z.union([z.boolean(), z.string()])` through `.extend()`'s merged
`.shape`, so there is no second spelling of the union to drift from — the route
`ChatbotSchema` took in objectui#6169.

**Additive for authors**: a predicate string is now accepted where it was refused; every
boolean that parsed before parses unchanged, and a number is still refused at path
`disabled`. Runtime behaviour does not change — the renderer already evaluated both twins.

**Out of scope, per the ruling**: `label` (29 narrowings) and `description` (32) carry
`string | I18nLabel` i18n semantics and wait for their own ruling; the independent
`disabled?: boolean` declarations on shapes that do not extend `BaseSchema`
(`SelectOption`, `RadioOption`, `FormField`, `ComboboxOption`, `AccordionItem`,
`ToggleGroupItem`, and the rest of that family) are not narrowings and are untouched.
264 changes: 264 additions & 0 deletions packages/types/src/__tests__/disabled-twin-symmetry-7087.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `disabled` is inherited from `BaseSchema` — `boolean | string` — on every
* concrete schema that used to narrow it back to `boolean` (objectui#7087,
* maintainer ruling 2026-09-01: option 1, scoped to `disabled`).
*
* ## What was narrowed, and why the narrowing was wrong
*
* `visible` and `disabled` are twins. objectui#4581 widened both on `BaseSchema`
* to `boolean | string` on the same evidence: `SchemaRenderer` reads neither key
* as a boolean, it routes both through `evaluator.evaluateCondition`, declared
* `(condition: string | boolean | undefined, context?) => boolean`. After that
* widening, 0 of the 124 `extends BaseSchema` interfaces redeclared `visible`,
* while 18 kept a pre-widening `disabled?: boolean` of their own — 15 in
* `form.ts`, `ActionSchema` in `crud.ts`, `CollapsibleSchema` and
* `ToggleGroupSchema` in `disclosure.ts` — and their zod mirrors carried the
* matching `z.boolean()`. So `disabled: "${data.status === 'locked'}"`, the
* capability the renderer implements and `BaseSchema` advertises, was a type
* error and a zod refusal on exactly the schemas an author reaches for first.
*
* The fix is the one `ChatbotSchema` already took (objectui#6169): the key is
* not redeclared at all. The TS interface inherits the member the way `visible`
* always has, and `.extend()`'s `.shape` merges the parent's fields into the
* child's, so the base union reaches every mirror without a second spelling of
* it that could drift.
*
* ## What this file pins
*
* 1. Type level — for each of the 18, `X['disabled']` is EXACTLY
* `boolean | string | undefined`, invariantly. `Equal`, not `extends`: the
* narrow `boolean` is assignable to the wide union, so a one-way check
* stays green on a narrowing that was never removed; and `BaseSchema`'s
* `[key: string]: any` index signature means an interface that LOST the
* member reads `any`, which a one-way check also accepts. `visible` is
* asserted beside it as the twin control, so a schema that dropped both
* keys cannot pass vacuously.
* 2. Runtime — each of the 18 zod mirrors `safeParse`s a predicate string on
* `disabled` (and on `visible`, the control) and still refuses a number at
* path `disabled`. Every fixture is also parsed WITHOUT `disabled`, so a
* refusal cannot be a broken fixture wearing a green.
* 3. Scope guard — the ruling widens NARROWINGS, not independent
* declarations. The six `disabled?: boolean` shapes in these same three
* files that do not extend `BaseSchema` (`SelectOption`, `RadioOption`,
* `FormField`, `ComboboxOption`, `AccordionItem`, `ToggleGroupItem`) stay
* `boolean` on both faces.
*
* ## Predictions, written before the first run (red-first)
*
* With any one of the three source files (or its mirror) reverted to its
* `origin/main` @ `67dadd602` blob and this file in place:
*
* - TS face reverted: `tsc -p packages/types/tsconfig.test.json` reports
* TS2344 on `assertionDisabledIsBaseUnion`, naming that file's interfaces;
* `assertionVisibleTwinControl` stays clean (nothing narrowed `visible`).
* - zod face reverted: the `accepts a predicate string on disabled` cases for
* that file's mirrors fail (`success: false`), the `visible` control and the
* `refuses a number` cases for the same mirrors stay green, and every other
* file's cases stay green.
*
* The measured counts are in the PR that landed this file.
*/

import { describe, expect, it } from 'vitest';
import type { ZodType } from 'zod';

import type { BaseSchema } from '../base';
import type { ActionSchema } from '../crud';
import type {
AccordionItem,
CollapsibleSchema,
ToggleGroupItem,
ToggleGroupSchema,
} from '../disclosure';
import type {
ButtonSchema,
CalendarSchema,
CheckboxSchema,
ComboboxOption,
ComboboxSchema,
DatePickerSchema,
FileUploadSchema,
FormField,
FormSchema,
InputOTPSchema,
InputSchema,
RadioGroupSchema,
RadioOption,
SelectOption,
SelectSchema,
SliderSchema,
SwitchSchema,
TextareaSchema,
ToggleSchema,
} from '../form';

import { ActionSchema as ActionMirror } from '../zod/crud.zod';
import {
AccordionItemSchema,
CollapsibleSchema as CollapsibleMirror,
ToggleGroupItemSchema,
ToggleGroupSchema as ToggleGroupMirror,
} from '../zod/disclosure.zod';
import * as FormMirrors from '../zod/form.zod';

/* ── Type-level helpers ──────────────────────────────────────────────────── */

/** Invariant equality — `extends` both ways would accept a narrowing. */
type Equal< A, B > =
(< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false;
type Expect< T extends true > = T;

/** The union both twins carry on `BaseSchema`, pinned here so the checks below cannot drift from it. */
type BasePredicate = boolean | string | undefined;
export type assertionBaseDisabled = Expect< Equal< BaseSchema['disabled'], BasePredicate > >;
export type assertionBaseVisible = Expect< Equal< BaseSchema['visible'], BasePredicate > >;

/* ── 1. The 18 formerly-narrowed interfaces inherit the base union ───────── */

type InScope = {
ButtonSchema: ButtonSchema;
InputSchema: InputSchema;
TextareaSchema: TextareaSchema;
SelectSchema: SelectSchema;
CheckboxSchema: CheckboxSchema;
RadioGroupSchema: RadioGroupSchema;
SwitchSchema: SwitchSchema;
ToggleSchema: ToggleSchema;
SliderSchema: SliderSchema;
FileUploadSchema: FileUploadSchema;
DatePickerSchema: DatePickerSchema;
CalendarSchema: CalendarSchema;
InputOTPSchema: InputOTPSchema;
FormSchema: FormSchema;
ComboboxSchema: ComboboxSchema;
ActionSchema: ActionSchema;
CollapsibleSchema: CollapsibleSchema;
ToggleGroupSchema: ToggleGroupSchema;
};

/** The names whose `K` is NOT exactly the base union — `never` when nothing narrows (or loses) it. */
type NotBaseUnion< K extends 'disabled' | 'visible' > = {
[N in keyof InScope]: Equal< InScope[N][K], BasePredicate > extends true ? never : N;
}[keyof InScope];

export type assertionDisabledIsBaseUnion = Expect< Equal< NotBaseUnion<'disabled'>, never > >;
/** Twin control: `visible` was never narrowed, so this stays `never` on every tree. */
export type assertionVisibleTwinControl = Expect< Equal< NotBaseUnion<'visible'>, never > >;

/* ── 3. Scope guard: independent declarations are not narrowings ─────────── */

type OutOfScope = {
SelectOption: SelectOption;
RadioOption: RadioOption;
FormField: FormField;
ComboboxOption: ComboboxOption;
AccordionItem: AccordionItem;
ToggleGroupItem: ToggleGroupItem;
};

type Widened = {
[N in keyof OutOfScope]: Equal< OutOfScope[N]['disabled'], boolean | undefined > extends true ? never : N;
}[keyof OutOfScope];

export type assertionIndependentDeclarationsStayBoolean = Expect< Equal< Widened, never > >;

/* ── 2. Runtime: the zod mirrors accept what the interfaces now declare ──── */

const PREDICATE = '${data.status === "locked"}';

interface MirrorCase {
name: string;
mirror: ZodType;
/** Minimal valid node — every required key, nothing optional. */
fixture: Record<string, unknown>;
}

const IN_SCOPE: readonly MirrorCase[] = [
{ name: 'form.zod.ts#ButtonSchema', mirror: FormMirrors.ButtonSchema, fixture: { type: 'button' } },
{ name: 'form.zod.ts#InputSchema', mirror: FormMirrors.InputSchema, fixture: { type: 'input' } },
{ name: 'form.zod.ts#TextareaSchema', mirror: FormMirrors.TextareaSchema, fixture: { type: 'textarea' } },
{ name: 'form.zod.ts#SelectSchema', mirror: FormMirrors.SelectSchema, fixture: { type: 'select', options: [{ label: 'A', value: 'a' }] } },
{ name: 'form.zod.ts#CheckboxSchema', mirror: FormMirrors.CheckboxSchema, fixture: { type: 'checkbox' } },
{ name: 'form.zod.ts#RadioGroupSchema', mirror: FormMirrors.RadioGroupSchema, fixture: { type: 'radio-group', options: [{ label: 'A', value: 'a' }] } },
{ name: 'form.zod.ts#SwitchSchema', mirror: FormMirrors.SwitchSchema, fixture: { type: 'switch' } },
{ name: 'form.zod.ts#ToggleSchema', mirror: FormMirrors.ToggleSchema, fixture: { type: 'toggle' } },
{ name: 'form.zod.ts#SliderSchema', mirror: FormMirrors.SliderSchema, fixture: { type: 'slider' } },
{ name: 'form.zod.ts#FileUploadSchema', mirror: FormMirrors.FileUploadSchema, fixture: { type: 'file-upload' } },
{ name: 'form.zod.ts#DatePickerSchema', mirror: FormMirrors.DatePickerSchema, fixture: { type: 'date-picker' } },
{ name: 'form.zod.ts#CalendarSchema', mirror: FormMirrors.CalendarSchema, fixture: { type: 'calendar' } },
{ name: 'form.zod.ts#InputOTPSchema', mirror: FormMirrors.InputOTPSchema, fixture: { type: 'input-otp' } },
{ name: 'form.zod.ts#FormSchema', mirror: FormMirrors.FormSchema, fixture: { type: 'form', fields: [{ name: 'title', type: 'text' }] } },
{ name: 'form.zod.ts#ComboboxSchema', mirror: FormMirrors.ComboboxSchema, fixture: { type: 'combobox', options: [{ label: 'A', value: 'a' }] } },
{ name: 'crud.zod.ts#ActionSchema', mirror: ActionMirror, fixture: { type: 'action', label: 'Save' } },
{
name: 'disclosure.zod.ts#CollapsibleSchema',
mirror: CollapsibleMirror,
fixture: { type: 'collapsible', trigger: { type: 'button', label: 'More' }, content: { type: 'text', text: 'Body' } },
},
{ name: 'disclosure.zod.ts#ToggleGroupSchema', mirror: ToggleGroupMirror, fixture: { type: 'toggle-group' } },
];

const OUT_OF_SCOPE: readonly MirrorCase[] = [
{ name: 'form.zod.ts#SelectOptionSchema', mirror: FormMirrors.SelectOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#RadioOptionSchema', mirror: FormMirrors.RadioOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#ComboboxOptionSchema', mirror: FormMirrors.ComboboxOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#FormFieldSchema', mirror: FormMirrors.FormFieldSchema, fixture: { name: 'title' } },
{ name: 'disclosure.zod.ts#AccordionItemSchema', mirror: AccordionItemSchema, fixture: { value: 'a', title: 'A', content: { type: 'text', text: 'Body' } } },
{ name: 'disclosure.zod.ts#ToggleGroupItemSchema', mirror: ToggleGroupItemSchema, fixture: { value: 'a', label: 'A' } },
];

const issuePaths = (r: ReturnType<ZodType['safeParse']>): string[] =>
r.success ? [] : r.error.issues.map((issue) => issue.path.map(String).join('.'));

describe('`disabled` is the BaseSchema union on every formerly-narrowed mirror (objectui#7087)', () => {
it('covers the 18 interfaces the ruling names, and the 6 it excludes', () => {
// The census the ruling was made on. A mirror added to or dropped from
// either list changes the ruling's population and must say so here.
expect(IN_SCOPE).toHaveLength(18);
expect(OUT_OF_SCOPE).toHaveLength(6);
});

it.each(IN_SCOPE)('$name: the fixture is valid on its own', ({ mirror, fixture }) => {
// Control: the refusals below are about `disabled`, not about a fixture
// that never parsed.
expect(issuePaths(mirror.safeParse(fixture))).toEqual([]);
});

it.each(IN_SCOPE)('$name accepts a predicate string on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: PREDICATE });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { disabled?: unknown }).disabled).toBe(PREDICATE);
});

it.each(IN_SCOPE)('$name accepts a predicate string on `visible` — the twin control', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, visible: PREDICATE });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { visible?: unknown }).visible).toBe(PREDICATE);
});

it.each(IN_SCOPE)('$name still refuses a number on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: 1 });
expect(r.success).toBe(false);
expect(issuePaths(r)).toContain('disabled');
});

it.each(IN_SCOPE)('$name still accepts the boolean form — a widening, not a replacement', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: true });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { disabled?: unknown }).disabled).toBe(true);
});
});

describe('scope guard: the independent `disabled?: boolean` declarations are not narrowings (objectui#7087)', () => {
it.each(OUT_OF_SCOPE)('$name: the fixture is valid on its own', ({ mirror, fixture }) => {
expect(issuePaths(mirror.safeParse(fixture))).toEqual([]);
});

it.each(OUT_OF_SCOPE)('$name still refuses a string on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: PREDICATE });
expect(r.success).toBe(false);
expect(issuePaths(r)).toContain('disabled');
});
});
4 changes: 0 additions & 4 deletions packages/types/src/crud.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,10 +83,6 @@ export interface ActionSchema extends BaseSchema {
* Action variant
*/
variant?: 'default' | 'outline' | 'ghost' | 'link';
/**
* Whether action is disabled
*/
disabled?: boolean;
/**
* Action type
* Enhanced in Phase 2 with 'ajax', 'confirm', 'dialog'
Expand Down
8 changes: 0 additions & 8 deletions packages/types/src/disclosure.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,10 +103,6 @@ export interface CollapsibleSchema extends BaseSchema {
* Controlled open state
*/
open?: boolean;
/**
* Whether collapsible is disabled
*/
disabled?: boolean;
/**
* Open state change handler
*/
Expand DownExpand Up@@ -167,10 +163,6 @@ export interface ToggleGroupSchema extends BaseSchema {
* Controlled selected value(s)
*/
value?: string | string[];
/**
* Whether toggle group is disabled
*/
disabled?: boolean;
/**
* Change handler
*/
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
35 changes: 35 additions & 0 deletions .changeset/7087-disabled-twin-symmetry.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@object-ui/types': minor
---

`disabled` accepts a predicate string — `boolean | string`, the `BaseSchema` union — on
the 18 concrete schemas that used to narrow it back to `boolean` (objectui#7087,
maintainer ruling 2026-09-01: option 1, scoped to `disabled`).

`visible` and `disabled` are twins: objectui#4581 widened both on `BaseSchema` on the same
evidence — `SchemaRenderer` evaluates both through `evaluator.evaluateCondition` rather
than reading either as a boolean. After that widening, 0 of the 124 `extends BaseSchema`
interfaces redeclared `visible`, while 18 still carried a pre-widening
`disabled?: boolean` of their own, with matching `z.boolean()` mirrors. So
`disabled: "${data.status === 'locked'}"` — the capability the renderer implements and
the base type advertises — was a type error and a zod refusal on `ButtonSchema`,
`InputSchema`, `TextareaSchema`, `SelectSchema`, `CheckboxSchema`, `RadioGroupSchema`,
`SwitchSchema`, `ToggleSchema`, `SliderSchema`, `FileUploadSchema`, `DatePickerSchema`,
`CalendarSchema`, `InputOTPSchema`, `FormSchema`, `ComboboxSchema`, `ActionSchema`,
`CollapsibleSchema` and `ToggleGroupSchema`.

Those 18 redeclarations are removed, on both faces. The interfaces inherit
`BaseSchema.disabled` the way they always inherited `visible`; the zod mirrors inherit
`base.zod.ts`'s `z.union([z.boolean(), z.string()])` through `.extend()`'s merged
`.shape`, so there is no second spelling of the union to drift from — the route
`ChatbotSchema` took in objectui#6169.

**Additive for authors**: a predicate string is now accepted where it was refused; every
boolean that parsed before parses unchanged, and a number is still refused at path
`disabled`. Runtime behaviour does not change — the renderer already evaluated both twins.

**Out of scope, per the ruling**: `label` (29 narrowings) and `description` (32) carry
`string | I18nLabel` i18n semantics and wait for their own ruling; the independent
`disabled?: boolean` declarations on shapes that do not extend `BaseSchema`
(`SelectOption`, `RadioOption`, `FormField`, `ComboboxOption`, `AccordionItem`,
`ToggleGroupItem`, and the rest of that family) are not narrowings and are untouched.
264 changes: 264 additions & 0 deletions packages/types/src/__tests__/disabled-twin-symmetry-7087.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `disabled` is inherited from `BaseSchema` — `boolean | string` — on every
* concrete schema that used to narrow it back to `boolean` (objectui#7087,
* maintainer ruling 2026-09-01: option 1, scoped to `disabled`).
*
* ## What was narrowed, and why the narrowing was wrong
*
* `visible` and `disabled` are twins. objectui#4581 widened both on `BaseSchema`
* to `boolean | string` on the same evidence: `SchemaRenderer` reads neither key
* as a boolean, it routes both through `evaluator.evaluateCondition`, declared
* `(condition: string | boolean | undefined, context?) => boolean`. After that
* widening, 0 of the 124 `extends BaseSchema` interfaces redeclared `visible`,
* while 18 kept a pre-widening `disabled?: boolean` of their own — 15 in
* `form.ts`, `ActionSchema` in `crud.ts`, `CollapsibleSchema` and
* `ToggleGroupSchema` in `disclosure.ts` — and their zod mirrors carried the
* matching `z.boolean()`. So `disabled: "${data.status === 'locked'}"`, the
* capability the renderer implements and `BaseSchema` advertises, was a type
* error and a zod refusal on exactly the schemas an author reaches for first.
*
* The fix is the one `ChatbotSchema` already took (objectui#6169): the key is
* not redeclared at all. The TS interface inherits the member the way `visible`
* always has, and `.extend()`'s `.shape` merges the parent's fields into the
* child's, so the base union reaches every mirror without a second spelling of
* it that could drift.
*
* ## What this file pins
*
* 1. Type level — for each of the 18, `X['disabled']` is EXACTLY
* `boolean | string | undefined`, invariantly. `Equal`, not `extends`: the
* narrow `boolean` is assignable to the wide union, so a one-way check
* stays green on a narrowing that was never removed; and `BaseSchema`'s
* `[key: string]: any` index signature means an interface that LOST the
* member reads `any`, which a one-way check also accepts. `visible` is
* asserted beside it as the twin control, so a schema that dropped both
* keys cannot pass vacuously.
* 2. Runtime — each of the 18 zod mirrors `safeParse`s a predicate string on
* `disabled` (and on `visible`, the control) and still refuses a number at
* path `disabled`. Every fixture is also parsed WITHOUT `disabled`, so a
* refusal cannot be a broken fixture wearing a green.
* 3. Scope guard — the ruling widens NARROWINGS, not independent
* declarations. The six `disabled?: boolean` shapes in these same three
* files that do not extend `BaseSchema` (`SelectOption`, `RadioOption`,
* `FormField`, `ComboboxOption`, `AccordionItem`, `ToggleGroupItem`) stay
* `boolean` on both faces.
*
* ## Predictions, written before the first run (red-first)
*
* With any one of the three source files (or its mirror) reverted to its
* `origin/main` @ `67dadd602` blob and this file in place:
*
* - TS face reverted: `tsc -p packages/types/tsconfig.test.json` reports
* TS2344 on `assertionDisabledIsBaseUnion`, naming that file's interfaces;
* `assertionVisibleTwinControl` stays clean (nothing narrowed `visible`).
* - zod face reverted: the `accepts a predicate string on disabled` cases for
* that file's mirrors fail (`success: false`), the `visible` control and the
* `refuses a number` cases for the same mirrors stay green, and every other
* file's cases stay green.
*
* The measured counts are in the PR that landed this file.
*/

import { describe, expect, it } from 'vitest';
import type { ZodType } from 'zod';

import type { BaseSchema } from '../base';
import type { ActionSchema } from '../crud';
import type {
AccordionItem,
CollapsibleSchema,
ToggleGroupItem,
ToggleGroupSchema,
} from '../disclosure';
import type {
ButtonSchema,
CalendarSchema,
CheckboxSchema,
ComboboxOption,
ComboboxSchema,
DatePickerSchema,
FileUploadSchema,
FormField,
FormSchema,
InputOTPSchema,
InputSchema,
RadioGroupSchema,
RadioOption,
SelectOption,
SelectSchema,
SliderSchema,
SwitchSchema,
TextareaSchema,
ToggleSchema,
} from '../form';

import { ActionSchema as ActionMirror } from '../zod/crud.zod';
import {
AccordionItemSchema,
CollapsibleSchema as CollapsibleMirror,
ToggleGroupItemSchema,
ToggleGroupSchema as ToggleGroupMirror,
} from '../zod/disclosure.zod';
import * as FormMirrors from '../zod/form.zod';

/* ── Type-level helpers ──────────────────────────────────────────────────── */

/** Invariant equality — `extends` both ways would accept a narrowing. */
type Equal< A, B > =
(< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false;
type Expect< T extends true > = T;

/** The union both twins carry on `BaseSchema`, pinned here so the checks below cannot drift from it. */
type BasePredicate = boolean | string | undefined;
export type assertionBaseDisabled = Expect< Equal< BaseSchema['disabled'], BasePredicate > >;
export type assertionBaseVisible = Expect< Equal< BaseSchema['visible'], BasePredicate > >;

/* ── 1. The 18 formerly-narrowed interfaces inherit the base union ───────── */

type InScope = {
ButtonSchema: ButtonSchema;
InputSchema: InputSchema;
TextareaSchema: TextareaSchema;
SelectSchema: SelectSchema;
CheckboxSchema: CheckboxSchema;
RadioGroupSchema: RadioGroupSchema;
SwitchSchema: SwitchSchema;
ToggleSchema: ToggleSchema;
SliderSchema: SliderSchema;
FileUploadSchema: FileUploadSchema;
DatePickerSchema: DatePickerSchema;
CalendarSchema: CalendarSchema;
InputOTPSchema: InputOTPSchema;
FormSchema: FormSchema;
ComboboxSchema: ComboboxSchema;
ActionSchema: ActionSchema;
CollapsibleSchema: CollapsibleSchema;
ToggleGroupSchema: ToggleGroupSchema;
};

/** The names whose `K` is NOT exactly the base union — `never` when nothing narrows (or loses) it. */
type NotBaseUnion< K extends 'disabled' | 'visible' > = {
[N in keyof InScope]: Equal< InScope[N][K], BasePredicate > extends true ? never : N;
}[keyof InScope];

export type assertionDisabledIsBaseUnion = Expect< Equal< NotBaseUnion<'disabled'>, never > >;
/** Twin control: `visible` was never narrowed, so this stays `never` on every tree. */
export type assertionVisibleTwinControl = Expect< Equal< NotBaseUnion<'visible'>, never > >;

/* ── 3. Scope guard: independent declarations are not narrowings ─────────── */

type OutOfScope = {
SelectOption: SelectOption;
RadioOption: RadioOption;
FormField: FormField;
ComboboxOption: ComboboxOption;
AccordionItem: AccordionItem;
ToggleGroupItem: ToggleGroupItem;
};

type Widened = {
[N in keyof OutOfScope]: Equal< OutOfScope[N]['disabled'], boolean | undefined > extends true ? never : N;
}[keyof OutOfScope];

export type assertionIndependentDeclarationsStayBoolean = Expect< Equal< Widened, never > >;

/* ── 2. Runtime: the zod mirrors accept what the interfaces now declare ──── */

const PREDICATE = '${data.status === "locked"}';

interface MirrorCase {
name: string;
mirror: ZodType;
/** Minimal valid node — every required key, nothing optional. */
fixture: Record<string, unknown>;
}

const IN_SCOPE: readonly MirrorCase[] = [
{ name: 'form.zod.ts#ButtonSchema', mirror: FormMirrors.ButtonSchema, fixture: { type: 'button' } },
{ name: 'form.zod.ts#InputSchema', mirror: FormMirrors.InputSchema, fixture: { type: 'input' } },
{ name: 'form.zod.ts#TextareaSchema', mirror: FormMirrors.TextareaSchema, fixture: { type: 'textarea' } },
{ name: 'form.zod.ts#SelectSchema', mirror: FormMirrors.SelectSchema, fixture: { type: 'select', options: [{ label: 'A', value: 'a' }] } },
{ name: 'form.zod.ts#CheckboxSchema', mirror: FormMirrors.CheckboxSchema, fixture: { type: 'checkbox' } },
{ name: 'form.zod.ts#RadioGroupSchema', mirror: FormMirrors.RadioGroupSchema, fixture: { type: 'radio-group', options: [{ label: 'A', value: 'a' }] } },
{ name: 'form.zod.ts#SwitchSchema', mirror: FormMirrors.SwitchSchema, fixture: { type: 'switch' } },
{ name: 'form.zod.ts#ToggleSchema', mirror: FormMirrors.ToggleSchema, fixture: { type: 'toggle' } },
{ name: 'form.zod.ts#SliderSchema', mirror: FormMirrors.SliderSchema, fixture: { type: 'slider' } },
{ name: 'form.zod.ts#FileUploadSchema', mirror: FormMirrors.FileUploadSchema, fixture: { type: 'file-upload' } },
{ name: 'form.zod.ts#DatePickerSchema', mirror: FormMirrors.DatePickerSchema, fixture: { type: 'date-picker' } },
{ name: 'form.zod.ts#CalendarSchema', mirror: FormMirrors.CalendarSchema, fixture: { type: 'calendar' } },
{ name: 'form.zod.ts#InputOTPSchema', mirror: FormMirrors.InputOTPSchema, fixture: { type: 'input-otp' } },
{ name: 'form.zod.ts#FormSchema', mirror: FormMirrors.FormSchema, fixture: { type: 'form', fields: [{ name: 'title', type: 'text' }] } },
{ name: 'form.zod.ts#ComboboxSchema', mirror: FormMirrors.ComboboxSchema, fixture: { type: 'combobox', options: [{ label: 'A', value: 'a' }] } },
{ name: 'crud.zod.ts#ActionSchema', mirror: ActionMirror, fixture: { type: 'action', label: 'Save' } },
{
name: 'disclosure.zod.ts#CollapsibleSchema',
mirror: CollapsibleMirror,
fixture: { type: 'collapsible', trigger: { type: 'button', label: 'More' }, content: { type: 'text', text: 'Body' } },
},
{ name: 'disclosure.zod.ts#ToggleGroupSchema', mirror: ToggleGroupMirror, fixture: { type: 'toggle-group' } },
];

const OUT_OF_SCOPE: readonly MirrorCase[] = [
{ name: 'form.zod.ts#SelectOptionSchema', mirror: FormMirrors.SelectOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#RadioOptionSchema', mirror: FormMirrors.RadioOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#ComboboxOptionSchema', mirror: FormMirrors.ComboboxOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#FormFieldSchema', mirror: FormMirrors.FormFieldSchema, fixture: { name: 'title' } },
{ name: 'disclosure.zod.ts#AccordionItemSchema', mirror: AccordionItemSchema, fixture: { value: 'a', title: 'A', content: { type: 'text', text: 'Body' } } },
{ name: 'disclosure.zod.ts#ToggleGroupItemSchema', mirror: ToggleGroupItemSchema, fixture: { value: 'a', label: 'A' } },
];

const issuePaths = (r: ReturnType<ZodType['safeParse']>): string[] =>
r.success ? [] : r.error.issues.map((issue) => issue.path.map(String).join('.'));

describe('`disabled` is the BaseSchema union on every formerly-narrowed mirror (objectui#7087)', () => {
it('covers the 18 interfaces the ruling names, and the 6 it excludes', () => {
// The census the ruling was made on. A mirror added to or dropped from
// either list changes the ruling's population and must say so here.
expect(IN_SCOPE).toHaveLength(18);
expect(OUT_OF_SCOPE).toHaveLength(6);
});

it.each(IN_SCOPE)('$name: the fixture is valid on its own', ({ mirror, fixture }) => {
// Control: the refusals below are about `disabled`, not about a fixture
// that never parsed.
expect(issuePaths(mirror.safeParse(fixture))).toEqual([]);
});

it.each(IN_SCOPE)('$name accepts a predicate string on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: PREDICATE });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { disabled?: unknown }).disabled).toBe(PREDICATE);
});

it.each(IN_SCOPE)('$name accepts a predicate string on `visible` — the twin control', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, visible: PREDICATE });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { visible?: unknown }).visible).toBe(PREDICATE);
});

it.each(IN_SCOPE)('$name still refuses a number on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: 1 });
expect(r.success).toBe(false);
expect(issuePaths(r)).toContain('disabled');
});

it.each(IN_SCOPE)('$name still accepts the boolean form — a widening, not a replacement', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: true });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { disabled?: unknown }).disabled).toBe(true);
});
});

describe('scope guard: the independent `disabled?: boolean` declarations are not narrowings (objectui#7087)', () => {
it.each(OUT_OF_SCOPE)('$name: the fixture is valid on its own', ({ mirror, fixture }) => {
expect(issuePaths(mirror.safeParse(fixture))).toEqual([]);
});

it.each(OUT_OF_SCOPE)('$name still refuses a string on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: PREDICATE });
expect(r.success).toBe(false);
expect(issuePaths(r)).toContain('disabled');
});
});
4 changes: 0 additions & 4 deletions packages/types/src/crud.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,10 +83,6 @@ export interface ActionSchema extends BaseSchema {
* Action variant
*/
variant?: 'default' | 'outline' | 'ghost' | 'link';
/**
* Whether action is disabled
*/
disabled?: boolean;
/**
* Action type
* Enhanced in Phase 2 with 'ajax', 'confirm', 'dialog'
Expand Down
8 changes: 0 additions & 8 deletions packages/types/src/disclosure.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,10 +103,6 @@ export interface CollapsibleSchema extends BaseSchema {
* Controlled open state
*/
open?: boolean;
/**
* Whether collapsible is disabled
*/
disabled?: boolean;
/**
* Open state change handler
*/
Expand DownExpand Up@@ -167,10 +163,6 @@ export interface ToggleGroupSchema extends BaseSchema {
* Controlled selected value(s)
*/
value?: string | string[];
/**
* Whether toggle group is disabled
*/
disabled?: boolean;
/**
* Change handler
*/
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
35 changes: 35 additions & 0 deletions .changeset/7087-disabled-twin-symmetry.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@object-ui/types': minor
---

`disabled` accepts a predicate string — `boolean | string`, the `BaseSchema` union — on
the 18 concrete schemas that used to narrow it back to `boolean` (objectui#7087,
maintainer ruling 2026-09-01: option 1, scoped to `disabled`).

`visible` and `disabled` are twins: objectui#4581 widened both on `BaseSchema` on the same
evidence — `SchemaRenderer` evaluates both through `evaluator.evaluateCondition` rather
than reading either as a boolean. After that widening, 0 of the 124 `extends BaseSchema`
interfaces redeclared `visible`, while 18 still carried a pre-widening
`disabled?: boolean` of their own, with matching `z.boolean()` mirrors. So
`disabled: "${data.status === 'locked'}"` — the capability the renderer implements and
the base type advertises — was a type error and a zod refusal on `ButtonSchema`,
`InputSchema`, `TextareaSchema`, `SelectSchema`, `CheckboxSchema`, `RadioGroupSchema`,
`SwitchSchema`, `ToggleSchema`, `SliderSchema`, `FileUploadSchema`, `DatePickerSchema`,
`CalendarSchema`, `InputOTPSchema`, `FormSchema`, `ComboboxSchema`, `ActionSchema`,
`CollapsibleSchema` and `ToggleGroupSchema`.

Those 18 redeclarations are removed, on both faces. The interfaces inherit
`BaseSchema.disabled` the way they always inherited `visible`; the zod mirrors inherit
`base.zod.ts`'s `z.union([z.boolean(), z.string()])` through `.extend()`'s merged
`.shape`, so there is no second spelling of the union to drift from — the route
`ChatbotSchema` took in objectui#6169.

**Additive for authors**: a predicate string is now accepted where it was refused; every
boolean that parsed before parses unchanged, and a number is still refused at path
`disabled`. Runtime behaviour does not change — the renderer already evaluated both twins.

**Out of scope, per the ruling**: `label` (29 narrowings) and `description` (32) carry
`string | I18nLabel` i18n semantics and wait for their own ruling; the independent
`disabled?: boolean` declarations on shapes that do not extend `BaseSchema`
(`SelectOption`, `RadioOption`, `FormField`, `ComboboxOption`, `AccordionItem`,
`ToggleGroupItem`, and the rest of that family) are not narrowings and are untouched.
264 changes: 264 additions & 0 deletions packages/types/src/__tests__/disabled-twin-symmetry-7087.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `disabled` is inherited from `BaseSchema` — `boolean | string` — on every
* concrete schema that used to narrow it back to `boolean` (objectui#7087,
* maintainer ruling 2026-09-01: option 1, scoped to `disabled`).
*
* ## What was narrowed, and why the narrowing was wrong
*
* `visible` and `disabled` are twins. objectui#4581 widened both on `BaseSchema`
* to `boolean | string` on the same evidence: `SchemaRenderer` reads neither key
* as a boolean, it routes both through `evaluator.evaluateCondition`, declared
* `(condition: string | boolean | undefined, context?) => boolean`. After that
* widening, 0 of the 124 `extends BaseSchema` interfaces redeclared `visible`,
* while 18 kept a pre-widening `disabled?: boolean` of their own — 15 in
* `form.ts`, `ActionSchema` in `crud.ts`, `CollapsibleSchema` and
* `ToggleGroupSchema` in `disclosure.ts` — and their zod mirrors carried the
* matching `z.boolean()`. So `disabled: "${data.status === 'locked'}"`, the
* capability the renderer implements and `BaseSchema` advertises, was a type
* error and a zod refusal on exactly the schemas an author reaches for first.
*
* The fix is the one `ChatbotSchema` already took (objectui#6169): the key is
* not redeclared at all. The TS interface inherits the member the way `visible`
* always has, and `.extend()`'s `.shape` merges the parent's fields into the
* child's, so the base union reaches every mirror without a second spelling of
* it that could drift.
*
* ## What this file pins
*
* 1. Type level — for each of the 18, `X['disabled']` is EXACTLY
* `boolean | string | undefined`, invariantly. `Equal`, not `extends`: the
* narrow `boolean` is assignable to the wide union, so a one-way check
* stays green on a narrowing that was never removed; and `BaseSchema`'s
* `[key: string]: any` index signature means an interface that LOST the
* member reads `any`, which a one-way check also accepts. `visible` is
* asserted beside it as the twin control, so a schema that dropped both
* keys cannot pass vacuously.
* 2. Runtime — each of the 18 zod mirrors `safeParse`s a predicate string on
* `disabled` (and on `visible`, the control) and still refuses a number at
* path `disabled`. Every fixture is also parsed WITHOUT `disabled`, so a
* refusal cannot be a broken fixture wearing a green.
* 3. Scope guard — the ruling widens NARROWINGS, not independent
* declarations. The six `disabled?: boolean` shapes in these same three
* files that do not extend `BaseSchema` (`SelectOption`, `RadioOption`,
* `FormField`, `ComboboxOption`, `AccordionItem`, `ToggleGroupItem`) stay
* `boolean` on both faces.
*
* ## Predictions, written before the first run (red-first)
*
* With any one of the three source files (or its mirror) reverted to its
* `origin/main` @ `67dadd602` blob and this file in place:
*
* - TS face reverted: `tsc -p packages/types/tsconfig.test.json` reports
* TS2344 on `assertionDisabledIsBaseUnion`, naming that file's interfaces;
* `assertionVisibleTwinControl` stays clean (nothing narrowed `visible`).
* - zod face reverted: the `accepts a predicate string on disabled` cases for
* that file's mirrors fail (`success: false`), the `visible` control and the
* `refuses a number` cases for the same mirrors stay green, and every other
* file's cases stay green.
*
* The measured counts are in the PR that landed this file.
*/

import { describe, expect, it } from 'vitest';
import type { ZodType } from 'zod';

import type { BaseSchema } from '../base';
import type { ActionSchema } from '../crud';
import type {
AccordionItem,
CollapsibleSchema,
ToggleGroupItem,
ToggleGroupSchema,
} from '../disclosure';
import type {
ButtonSchema,
CalendarSchema,
CheckboxSchema,
ComboboxOption,
ComboboxSchema,
DatePickerSchema,
FileUploadSchema,
FormField,
FormSchema,
InputOTPSchema,
InputSchema,
RadioGroupSchema,
RadioOption,
SelectOption,
SelectSchema,
SliderSchema,
SwitchSchema,
TextareaSchema,
ToggleSchema,
} from '../form';

import { ActionSchema as ActionMirror } from '../zod/crud.zod';
import {
AccordionItemSchema,
CollapsibleSchema as CollapsibleMirror,
ToggleGroupItemSchema,
ToggleGroupSchema as ToggleGroupMirror,
} from '../zod/disclosure.zod';
import * as FormMirrors from '../zod/form.zod';

/* ── Type-level helpers ──────────────────────────────────────────────────── */

/** Invariant equality — `extends` both ways would accept a narrowing. */
type Equal< A, B > =
(< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false;
type Expect< T extends true > = T;

/** The union both twins carry on `BaseSchema`, pinned here so the checks below cannot drift from it. */
type BasePredicate = boolean | string | undefined;
export type assertionBaseDisabled = Expect< Equal< BaseSchema['disabled'], BasePredicate > >;
export type assertionBaseVisible = Expect< Equal< BaseSchema['visible'], BasePredicate > >;

/* ── 1. The 18 formerly-narrowed interfaces inherit the base union ───────── */

type InScope = {
ButtonSchema: ButtonSchema;
InputSchema: InputSchema;
TextareaSchema: TextareaSchema;
SelectSchema: SelectSchema;
CheckboxSchema: CheckboxSchema;
RadioGroupSchema: RadioGroupSchema;
SwitchSchema: SwitchSchema;
ToggleSchema: ToggleSchema;
SliderSchema: SliderSchema;
FileUploadSchema: FileUploadSchema;
DatePickerSchema: DatePickerSchema;
CalendarSchema: CalendarSchema;
InputOTPSchema: InputOTPSchema;
FormSchema: FormSchema;
ComboboxSchema: ComboboxSchema;
ActionSchema: ActionSchema;
CollapsibleSchema: CollapsibleSchema;
ToggleGroupSchema: ToggleGroupSchema;
};

/** The names whose `K` is NOT exactly the base union — `never` when nothing narrows (or loses) it. */
type NotBaseUnion< K extends 'disabled' | 'visible' > = {
[N in keyof InScope]: Equal< InScope[N][K], BasePredicate > extends true ? never : N;
}[keyof InScope];

export type assertionDisabledIsBaseUnion = Expect< Equal< NotBaseUnion<'disabled'>, never > >;
/** Twin control: `visible` was never narrowed, so this stays `never` on every tree. */
export type assertionVisibleTwinControl = Expect< Equal< NotBaseUnion<'visible'>, never > >;

/* ── 3. Scope guard: independent declarations are not narrowings ─────────── */

type OutOfScope = {
SelectOption: SelectOption;
RadioOption: RadioOption;
FormField: FormField;
ComboboxOption: ComboboxOption;
AccordionItem: AccordionItem;
ToggleGroupItem: ToggleGroupItem;
};

type Widened = {
[N in keyof OutOfScope]: Equal< OutOfScope[N]['disabled'], boolean | undefined > extends true ? never : N;
}[keyof OutOfScope];

export type assertionIndependentDeclarationsStayBoolean = Expect< Equal< Widened, never > >;

/* ── 2. Runtime: the zod mirrors accept what the interfaces now declare ──── */

const PREDICATE = '${data.status === "locked"}';

interface MirrorCase {
name: string;
mirror: ZodType;
/** Minimal valid node — every required key, nothing optional. */
fixture: Record<string, unknown>;
}

const IN_SCOPE: readonly MirrorCase[] = [
{ name: 'form.zod.ts#ButtonSchema', mirror: FormMirrors.ButtonSchema, fixture: { type: 'button' } },
{ name: 'form.zod.ts#InputSchema', mirror: FormMirrors.InputSchema, fixture: { type: 'input' } },
{ name: 'form.zod.ts#TextareaSchema', mirror: FormMirrors.TextareaSchema, fixture: { type: 'textarea' } },
{ name: 'form.zod.ts#SelectSchema', mirror: FormMirrors.SelectSchema, fixture: { type: 'select', options: [{ label: 'A', value: 'a' }] } },
{ name: 'form.zod.ts#CheckboxSchema', mirror: FormMirrors.CheckboxSchema, fixture: { type: 'checkbox' } },
{ name: 'form.zod.ts#RadioGroupSchema', mirror: FormMirrors.RadioGroupSchema, fixture: { type: 'radio-group', options: [{ label: 'A', value: 'a' }] } },
{ name: 'form.zod.ts#SwitchSchema', mirror: FormMirrors.SwitchSchema, fixture: { type: 'switch' } },
{ name: 'form.zod.ts#ToggleSchema', mirror: FormMirrors.ToggleSchema, fixture: { type: 'toggle' } },
{ name: 'form.zod.ts#SliderSchema', mirror: FormMirrors.SliderSchema, fixture: { type: 'slider' } },
{ name: 'form.zod.ts#FileUploadSchema', mirror: FormMirrors.FileUploadSchema, fixture: { type: 'file-upload' } },
{ name: 'form.zod.ts#DatePickerSchema', mirror: FormMirrors.DatePickerSchema, fixture: { type: 'date-picker' } },
{ name: 'form.zod.ts#CalendarSchema', mirror: FormMirrors.CalendarSchema, fixture: { type: 'calendar' } },
{ name: 'form.zod.ts#InputOTPSchema', mirror: FormMirrors.InputOTPSchema, fixture: { type: 'input-otp' } },
{ name: 'form.zod.ts#FormSchema', mirror: FormMirrors.FormSchema, fixture: { type: 'form', fields: [{ name: 'title', type: 'text' }] } },
{ name: 'form.zod.ts#ComboboxSchema', mirror: FormMirrors.ComboboxSchema, fixture: { type: 'combobox', options: [{ label: 'A', value: 'a' }] } },
{ name: 'crud.zod.ts#ActionSchema', mirror: ActionMirror, fixture: { type: 'action', label: 'Save' } },
{
name: 'disclosure.zod.ts#CollapsibleSchema',
mirror: CollapsibleMirror,
fixture: { type: 'collapsible', trigger: { type: 'button', label: 'More' }, content: { type: 'text', text: 'Body' } },
},
{ name: 'disclosure.zod.ts#ToggleGroupSchema', mirror: ToggleGroupMirror, fixture: { type: 'toggle-group' } },
];

const OUT_OF_SCOPE: readonly MirrorCase[] = [
{ name: 'form.zod.ts#SelectOptionSchema', mirror: FormMirrors.SelectOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#RadioOptionSchema', mirror: FormMirrors.RadioOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#ComboboxOptionSchema', mirror: FormMirrors.ComboboxOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#FormFieldSchema', mirror: FormMirrors.FormFieldSchema, fixture: { name: 'title' } },
{ name: 'disclosure.zod.ts#AccordionItemSchema', mirror: AccordionItemSchema, fixture: { value: 'a', title: 'A', content: { type: 'text', text: 'Body' } } },
{ name: 'disclosure.zod.ts#ToggleGroupItemSchema', mirror: ToggleGroupItemSchema, fixture: { value: 'a', label: 'A' } },
];

const issuePaths = (r: ReturnType<ZodType['safeParse']>): string[] =>
r.success ? [] : r.error.issues.map((issue) => issue.path.map(String).join('.'));

describe('`disabled` is the BaseSchema union on every formerly-narrowed mirror (objectui#7087)', () => {
it('covers the 18 interfaces the ruling names, and the 6 it excludes', () => {
// The census the ruling was made on. A mirror added to or dropped from
// either list changes the ruling's population and must say so here.
expect(IN_SCOPE).toHaveLength(18);
expect(OUT_OF_SCOPE).toHaveLength(6);
});

it.each(IN_SCOPE)('$name: the fixture is valid on its own', ({ mirror, fixture }) => {
// Control: the refusals below are about `disabled`, not about a fixture
// that never parsed.
expect(issuePaths(mirror.safeParse(fixture))).toEqual([]);
});

it.each(IN_SCOPE)('$name accepts a predicate string on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: PREDICATE });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { disabled?: unknown }).disabled).toBe(PREDICATE);
});

it.each(IN_SCOPE)('$name accepts a predicate string on `visible` — the twin control', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, visible: PREDICATE });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { visible?: unknown }).visible).toBe(PREDICATE);
});

it.each(IN_SCOPE)('$name still refuses a number on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: 1 });
expect(r.success).toBe(false);
expect(issuePaths(r)).toContain('disabled');
});

it.each(IN_SCOPE)('$name still accepts the boolean form — a widening, not a replacement', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: true });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { disabled?: unknown }).disabled).toBe(true);
});
});

describe('scope guard: the independent `disabled?: boolean` declarations are not narrowings (objectui#7087)', () => {
it.each(OUT_OF_SCOPE)('$name: the fixture is valid on its own', ({ mirror, fixture }) => {
expect(issuePaths(mirror.safeParse(fixture))).toEqual([]);
});

it.each(OUT_OF_SCOPE)('$name still refuses a string on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: PREDICATE });
expect(r.success).toBe(false);
expect(issuePaths(r)).toContain('disabled');
});
});
4 changes: 0 additions & 4 deletions packages/types/src/crud.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,10 +83,6 @@ export interface ActionSchema extends BaseSchema {
* Action variant
*/
variant?: 'default' | 'outline' | 'ghost' | 'link';
/**
* Whether action is disabled
*/
disabled?: boolean;
/**
* Action type
* Enhanced in Phase 2 with 'ajax', 'confirm', 'dialog'
Expand Down
8 changes: 0 additions & 8 deletions packages/types/src/disclosure.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,10 +103,6 @@ export interface CollapsibleSchema extends BaseSchema {
* Controlled open state
*/
open?: boolean;
/**
* Whether collapsible is disabled
*/
disabled?: boolean;
/**
* Open state change handler
*/
Expand DownExpand Up@@ -167,10 +163,6 @@ export interface ToggleGroupSchema extends BaseSchema {
* Controlled selected value(s)
*/
value?: string | string[];
/**
* Whether toggle group is disabled
*/
disabled?: boolean;
/**
* Change handler
*/
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
35 changes: 35 additions & 0 deletions .changeset/7087-disabled-twin-symmetry.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@object-ui/types': minor
---

`disabled` accepts a predicate string — `boolean | string`, the `BaseSchema` union — on
the 18 concrete schemas that used to narrow it back to `boolean` (objectui#7087,
maintainer ruling 2026-09-01: option 1, scoped to `disabled`).

`visible` and `disabled` are twins: objectui#4581 widened both on `BaseSchema` on the same
evidence — `SchemaRenderer` evaluates both through `evaluator.evaluateCondition` rather
than reading either as a boolean. After that widening, 0 of the 124 `extends BaseSchema`
interfaces redeclared `visible`, while 18 still carried a pre-widening
`disabled?: boolean` of their own, with matching `z.boolean()` mirrors. So
`disabled: "${data.status === 'locked'}"` — the capability the renderer implements and
the base type advertises — was a type error and a zod refusal on `ButtonSchema`,
`InputSchema`, `TextareaSchema`, `SelectSchema`, `CheckboxSchema`, `RadioGroupSchema`,
`SwitchSchema`, `ToggleSchema`, `SliderSchema`, `FileUploadSchema`, `DatePickerSchema`,
`CalendarSchema`, `InputOTPSchema`, `FormSchema`, `ComboboxSchema`, `ActionSchema`,
`CollapsibleSchema` and `ToggleGroupSchema`.

Those 18 redeclarations are removed, on both faces. The interfaces inherit
`BaseSchema.disabled` the way they always inherited `visible`; the zod mirrors inherit
`base.zod.ts`'s `z.union([z.boolean(), z.string()])` through `.extend()`'s merged
`.shape`, so there is no second spelling of the union to drift from — the route
`ChatbotSchema` took in objectui#6169.

**Additive for authors**: a predicate string is now accepted where it was refused; every
boolean that parsed before parses unchanged, and a number is still refused at path
`disabled`. Runtime behaviour does not change — the renderer already evaluated both twins.

**Out of scope, per the ruling**: `label` (29 narrowings) and `description` (32) carry
`string | I18nLabel` i18n semantics and wait for their own ruling; the independent
`disabled?: boolean` declarations on shapes that do not extend `BaseSchema`
(`SelectOption`, `RadioOption`, `FormField`, `ComboboxOption`, `AccordionItem`,
`ToggleGroupItem`, and the rest of that family) are not narrowings and are untouched.
264 changes: 264 additions & 0 deletions packages/types/src/__tests__/disabled-twin-symmetry-7087.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `disabled` is inherited from `BaseSchema` — `boolean | string` — on every
* concrete schema that used to narrow it back to `boolean` (objectui#7087,
* maintainer ruling 2026-09-01: option 1, scoped to `disabled`).
*
* ## What was narrowed, and why the narrowing was wrong
*
* `visible` and `disabled` are twins. objectui#4581 widened both on `BaseSchema`
* to `boolean | string` on the same evidence: `SchemaRenderer` reads neither key
* as a boolean, it routes both through `evaluator.evaluateCondition`, declared
* `(condition: string | boolean | undefined, context?) => boolean`. After that
* widening, 0 of the 124 `extends BaseSchema` interfaces redeclared `visible`,
* while 18 kept a pre-widening `disabled?: boolean` of their own — 15 in
* `form.ts`, `ActionSchema` in `crud.ts`, `CollapsibleSchema` and
* `ToggleGroupSchema` in `disclosure.ts` — and their zod mirrors carried the
* matching `z.boolean()`. So `disabled: "${data.status === 'locked'}"`, the
* capability the renderer implements and `BaseSchema` advertises, was a type
* error and a zod refusal on exactly the schemas an author reaches for first.
*
* The fix is the one `ChatbotSchema` already took (objectui#6169): the key is
* not redeclared at all. The TS interface inherits the member the way `visible`
* always has, and `.extend()`'s `.shape` merges the parent's fields into the
* child's, so the base union reaches every mirror without a second spelling of
* it that could drift.
*
* ## What this file pins
*
* 1. Type level — for each of the 18, `X['disabled']` is EXACTLY
* `boolean | string | undefined`, invariantly. `Equal`, not `extends`: the
* narrow `boolean` is assignable to the wide union, so a one-way check
* stays green on a narrowing that was never removed; and `BaseSchema`'s
* `[key: string]: any` index signature means an interface that LOST the
* member reads `any`, which a one-way check also accepts. `visible` is
* asserted beside it as the twin control, so a schema that dropped both
* keys cannot pass vacuously.
* 2. Runtime — each of the 18 zod mirrors `safeParse`s a predicate string on
* `disabled` (and on `visible`, the control) and still refuses a number at
* path `disabled`. Every fixture is also parsed WITHOUT `disabled`, so a
* refusal cannot be a broken fixture wearing a green.
* 3. Scope guard — the ruling widens NARROWINGS, not independent
* declarations. The six `disabled?: boolean` shapes in these same three
* files that do not extend `BaseSchema` (`SelectOption`, `RadioOption`,
* `FormField`, `ComboboxOption`, `AccordionItem`, `ToggleGroupItem`) stay
* `boolean` on both faces.
*
* ## Predictions, written before the first run (red-first)
*
* With any one of the three source files (or its mirror) reverted to its
* `origin/main` @ `67dadd602` blob and this file in place:
*
* - TS face reverted: `tsc -p packages/types/tsconfig.test.json` reports
* TS2344 on `assertionDisabledIsBaseUnion`, naming that file's interfaces;
* `assertionVisibleTwinControl` stays clean (nothing narrowed `visible`).
* - zod face reverted: the `accepts a predicate string on disabled` cases for
* that file's mirrors fail (`success: false`), the `visible` control and the
* `refuses a number` cases for the same mirrors stay green, and every other
* file's cases stay green.
*
* The measured counts are in the PR that landed this file.
*/

import { describe, expect, it } from 'vitest';
import type { ZodType } from 'zod';

import type { BaseSchema } from '../base';
import type { ActionSchema } from '../crud';
import type {
AccordionItem,
CollapsibleSchema,
ToggleGroupItem,
ToggleGroupSchema,
} from '../disclosure';
import type {
ButtonSchema,
CalendarSchema,
CheckboxSchema,
ComboboxOption,
ComboboxSchema,
DatePickerSchema,
FileUploadSchema,
FormField,
FormSchema,
InputOTPSchema,
InputSchema,
RadioGroupSchema,
RadioOption,
SelectOption,
SelectSchema,
SliderSchema,
SwitchSchema,
TextareaSchema,
ToggleSchema,
} from '../form';

import { ActionSchema as ActionMirror } from '../zod/crud.zod';
import {
AccordionItemSchema,
CollapsibleSchema as CollapsibleMirror,
ToggleGroupItemSchema,
ToggleGroupSchema as ToggleGroupMirror,
} from '../zod/disclosure.zod';
import * as FormMirrors from '../zod/form.zod';

/* ── Type-level helpers ──────────────────────────────────────────────────── */

/** Invariant equality — `extends` both ways would accept a narrowing. */
type Equal< A, B > =
(< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false;
type Expect< T extends true > = T;

/** The union both twins carry on `BaseSchema`, pinned here so the checks below cannot drift from it. */
type BasePredicate = boolean | string | undefined;
export type assertionBaseDisabled = Expect< Equal< BaseSchema['disabled'], BasePredicate > >;
export type assertionBaseVisible = Expect< Equal< BaseSchema['visible'], BasePredicate > >;

/* ── 1. The 18 formerly-narrowed interfaces inherit the base union ───────── */

type InScope = {
ButtonSchema: ButtonSchema;
InputSchema: InputSchema;
TextareaSchema: TextareaSchema;
SelectSchema: SelectSchema;
CheckboxSchema: CheckboxSchema;
RadioGroupSchema: RadioGroupSchema;
SwitchSchema: SwitchSchema;
ToggleSchema: ToggleSchema;
SliderSchema: SliderSchema;
FileUploadSchema: FileUploadSchema;
DatePickerSchema: DatePickerSchema;
CalendarSchema: CalendarSchema;
InputOTPSchema: InputOTPSchema;
FormSchema: FormSchema;
ComboboxSchema: ComboboxSchema;
ActionSchema: ActionSchema;
CollapsibleSchema: CollapsibleSchema;
ToggleGroupSchema: ToggleGroupSchema;
};

/** The names whose `K` is NOT exactly the base union — `never` when nothing narrows (or loses) it. */
type NotBaseUnion< K extends 'disabled' | 'visible' > = {
[N in keyof InScope]: Equal< InScope[N][K], BasePredicate > extends true ? never : N;
}[keyof InScope];

export type assertionDisabledIsBaseUnion = Expect< Equal< NotBaseUnion<'disabled'>, never > >;
/** Twin control: `visible` was never narrowed, so this stays `never` on every tree. */
export type assertionVisibleTwinControl = Expect< Equal< NotBaseUnion<'visible'>, never > >;

/* ── 3. Scope guard: independent declarations are not narrowings ─────────── */

type OutOfScope = {
SelectOption: SelectOption;
RadioOption: RadioOption;
FormField: FormField;
ComboboxOption: ComboboxOption;
AccordionItem: AccordionItem;
ToggleGroupItem: ToggleGroupItem;
};

type Widened = {
[N in keyof OutOfScope]: Equal< OutOfScope[N]['disabled'], boolean | undefined > extends true ? never : N;
}[keyof OutOfScope];

export type assertionIndependentDeclarationsStayBoolean = Expect< Equal< Widened, never > >;

/* ── 2. Runtime: the zod mirrors accept what the interfaces now declare ──── */

const PREDICATE = '${data.status === "locked"}';

interface MirrorCase {
name: string;
mirror: ZodType;
/** Minimal valid node — every required key, nothing optional. */
fixture: Record<string, unknown>;
}

const IN_SCOPE: readonly MirrorCase[] = [
{ name: 'form.zod.ts#ButtonSchema', mirror: FormMirrors.ButtonSchema, fixture: { type: 'button' } },
{ name: 'form.zod.ts#InputSchema', mirror: FormMirrors.InputSchema, fixture: { type: 'input' } },
{ name: 'form.zod.ts#TextareaSchema', mirror: FormMirrors.TextareaSchema, fixture: { type: 'textarea' } },
{ name: 'form.zod.ts#SelectSchema', mirror: FormMirrors.SelectSchema, fixture: { type: 'select', options: [{ label: 'A', value: 'a' }] } },
{ name: 'form.zod.ts#CheckboxSchema', mirror: FormMirrors.CheckboxSchema, fixture: { type: 'checkbox' } },
{ name: 'form.zod.ts#RadioGroupSchema', mirror: FormMirrors.RadioGroupSchema, fixture: { type: 'radio-group', options: [{ label: 'A', value: 'a' }] } },
{ name: 'form.zod.ts#SwitchSchema', mirror: FormMirrors.SwitchSchema, fixture: { type: 'switch' } },
{ name: 'form.zod.ts#ToggleSchema', mirror: FormMirrors.ToggleSchema, fixture: { type: 'toggle' } },
{ name: 'form.zod.ts#SliderSchema', mirror: FormMirrors.SliderSchema, fixture: { type: 'slider' } },
{ name: 'form.zod.ts#FileUploadSchema', mirror: FormMirrors.FileUploadSchema, fixture: { type: 'file-upload' } },
{ name: 'form.zod.ts#DatePickerSchema', mirror: FormMirrors.DatePickerSchema, fixture: { type: 'date-picker' } },
{ name: 'form.zod.ts#CalendarSchema', mirror: FormMirrors.CalendarSchema, fixture: { type: 'calendar' } },
{ name: 'form.zod.ts#InputOTPSchema', mirror: FormMirrors.InputOTPSchema, fixture: { type: 'input-otp' } },
{ name: 'form.zod.ts#FormSchema', mirror: FormMirrors.FormSchema, fixture: { type: 'form', fields: [{ name: 'title', type: 'text' }] } },
{ name: 'form.zod.ts#ComboboxSchema', mirror: FormMirrors.ComboboxSchema, fixture: { type: 'combobox', options: [{ label: 'A', value: 'a' }] } },
{ name: 'crud.zod.ts#ActionSchema', mirror: ActionMirror, fixture: { type: 'action', label: 'Save' } },
{
name: 'disclosure.zod.ts#CollapsibleSchema',
mirror: CollapsibleMirror,
fixture: { type: 'collapsible', trigger: { type: 'button', label: 'More' }, content: { type: 'text', text: 'Body' } },
},
{ name: 'disclosure.zod.ts#ToggleGroupSchema', mirror: ToggleGroupMirror, fixture: { type: 'toggle-group' } },
];

const OUT_OF_SCOPE: readonly MirrorCase[] = [
{ name: 'form.zod.ts#SelectOptionSchema', mirror: FormMirrors.SelectOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#RadioOptionSchema', mirror: FormMirrors.RadioOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#ComboboxOptionSchema', mirror: FormMirrors.ComboboxOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#FormFieldSchema', mirror: FormMirrors.FormFieldSchema, fixture: { name: 'title' } },
{ name: 'disclosure.zod.ts#AccordionItemSchema', mirror: AccordionItemSchema, fixture: { value: 'a', title: 'A', content: { type: 'text', text: 'Body' } } },
{ name: 'disclosure.zod.ts#ToggleGroupItemSchema', mirror: ToggleGroupItemSchema, fixture: { value: 'a', label: 'A' } },
];

const issuePaths = (r: ReturnType<ZodType['safeParse']>): string[] =>
r.success ? [] : r.error.issues.map((issue) => issue.path.map(String).join('.'));

describe('`disabled` is the BaseSchema union on every formerly-narrowed mirror (objectui#7087)', () => {
it('covers the 18 interfaces the ruling names, and the 6 it excludes', () => {
// The census the ruling was made on. A mirror added to or dropped from
// either list changes the ruling's population and must say so here.
expect(IN_SCOPE).toHaveLength(18);
expect(OUT_OF_SCOPE).toHaveLength(6);
});

it.each(IN_SCOPE)('$name: the fixture is valid on its own', ({ mirror, fixture }) => {
// Control: the refusals below are about `disabled`, not about a fixture
// that never parsed.
expect(issuePaths(mirror.safeParse(fixture))).toEqual([]);
});

it.each(IN_SCOPE)('$name accepts a predicate string on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: PREDICATE });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { disabled?: unknown }).disabled).toBe(PREDICATE);
});

it.each(IN_SCOPE)('$name accepts a predicate string on `visible` — the twin control', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, visible: PREDICATE });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { visible?: unknown }).visible).toBe(PREDICATE);
});

it.each(IN_SCOPE)('$name still refuses a number on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: 1 });
expect(r.success).toBe(false);
expect(issuePaths(r)).toContain('disabled');
});

it.each(IN_SCOPE)('$name still accepts the boolean form — a widening, not a replacement', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: true });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { disabled?: unknown }).disabled).toBe(true);
});
});

describe('scope guard: the independent `disabled?: boolean` declarations are not narrowings (objectui#7087)', () => {
it.each(OUT_OF_SCOPE)('$name: the fixture is valid on its own', ({ mirror, fixture }) => {
expect(issuePaths(mirror.safeParse(fixture))).toEqual([]);
});

it.each(OUT_OF_SCOPE)('$name still refuses a string on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: PREDICATE });
expect(r.success).toBe(false);
expect(issuePaths(r)).toContain('disabled');
});
});
4 changes: 0 additions & 4 deletions packages/types/src/crud.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,10 +83,6 @@ export interface ActionSchema extends BaseSchema {
* Action variant
*/
variant?: 'default' | 'outline' | 'ghost' | 'link';
/**
* Whether action is disabled
*/
disabled?: boolean;
/**
* Action type
* Enhanced in Phase 2 with 'ajax', 'confirm', 'dialog'
Expand Down
8 changes: 0 additions & 8 deletions packages/types/src/disclosure.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,10 +103,6 @@ export interface CollapsibleSchema extends BaseSchema {
* Controlled open state
*/
open?: boolean;
/**
* Whether collapsible is disabled
*/
disabled?: boolean;
/**
* Open state change handler
*/
Expand DownExpand Up@@ -167,10 +163,6 @@ export interface ToggleGroupSchema extends BaseSchema {
* Controlled selected value(s)
*/
value?: string | string[];
/**
* Whether toggle group is disabled
*/
disabled?: boolean;
/**
* Change handler
*/
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
35 changes: 35 additions & 0 deletions .changeset/7087-disabled-twin-symmetry.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@object-ui/types': minor
---

`disabled` accepts a predicate string — `boolean | string`, the `BaseSchema` union — on
the 18 concrete schemas that used to narrow it back to `boolean` (objectui#7087,
maintainer ruling 2026-09-01: option 1, scoped to `disabled`).

`visible` and `disabled` are twins: objectui#4581 widened both on `BaseSchema` on the same
evidence — `SchemaRenderer` evaluates both through `evaluator.evaluateCondition` rather
than reading either as a boolean. After that widening, 0 of the 124 `extends BaseSchema`
interfaces redeclared `visible`, while 18 still carried a pre-widening
`disabled?: boolean` of their own, with matching `z.boolean()` mirrors. So
`disabled: "${data.status === 'locked'}"` — the capability the renderer implements and
the base type advertises — was a type error and a zod refusal on `ButtonSchema`,
`InputSchema`, `TextareaSchema`, `SelectSchema`, `CheckboxSchema`, `RadioGroupSchema`,
`SwitchSchema`, `ToggleSchema`, `SliderSchema`, `FileUploadSchema`, `DatePickerSchema`,
`CalendarSchema`, `InputOTPSchema`, `FormSchema`, `ComboboxSchema`, `ActionSchema`,
`CollapsibleSchema` and `ToggleGroupSchema`.

Those 18 redeclarations are removed, on both faces. The interfaces inherit
`BaseSchema.disabled` the way they always inherited `visible`; the zod mirrors inherit
`base.zod.ts`'s `z.union([z.boolean(), z.string()])` through `.extend()`'s merged
`.shape`, so there is no second spelling of the union to drift from — the route
`ChatbotSchema` took in objectui#6169.

**Additive for authors**: a predicate string is now accepted where it was refused; every
boolean that parsed before parses unchanged, and a number is still refused at path
`disabled`. Runtime behaviour does not change — the renderer already evaluated both twins.

**Out of scope, per the ruling**: `label` (29 narrowings) and `description` (32) carry
`string | I18nLabel` i18n semantics and wait for their own ruling; the independent
`disabled?: boolean` declarations on shapes that do not extend `BaseSchema`
(`SelectOption`, `RadioOption`, `FormField`, `ComboboxOption`, `AccordionItem`,
`ToggleGroupItem`, and the rest of that family) are not narrowings and are untouched.
264 changes: 264 additions & 0 deletions packages/types/src/__tests__/disabled-twin-symmetry-7087.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `disabled` is inherited from `BaseSchema` — `boolean | string` — on every
* concrete schema that used to narrow it back to `boolean` (objectui#7087,
* maintainer ruling 2026-09-01: option 1, scoped to `disabled`).
*
* ## What was narrowed, and why the narrowing was wrong
*
* `visible` and `disabled` are twins. objectui#4581 widened both on `BaseSchema`
* to `boolean | string` on the same evidence: `SchemaRenderer` reads neither key
* as a boolean, it routes both through `evaluator.evaluateCondition`, declared
* `(condition: string | boolean | undefined, context?) => boolean`. After that
* widening, 0 of the 124 `extends BaseSchema` interfaces redeclared `visible`,
* while 18 kept a pre-widening `disabled?: boolean` of their own — 15 in
* `form.ts`, `ActionSchema` in `crud.ts`, `CollapsibleSchema` and
* `ToggleGroupSchema` in `disclosure.ts` — and their zod mirrors carried the
* matching `z.boolean()`. So `disabled: "${data.status === 'locked'}"`, the
* capability the renderer implements and `BaseSchema` advertises, was a type
* error and a zod refusal on exactly the schemas an author reaches for first.
*
* The fix is the one `ChatbotSchema` already took (objectui#6169): the key is
* not redeclared at all. The TS interface inherits the member the way `visible`
* always has, and `.extend()`'s `.shape` merges the parent's fields into the
* child's, so the base union reaches every mirror without a second spelling of
* it that could drift.
*
* ## What this file pins
*
* 1. Type level — for each of the 18, `X['disabled']` is EXACTLY
* `boolean | string | undefined`, invariantly. `Equal`, not `extends`: the
* narrow `boolean` is assignable to the wide union, so a one-way check
* stays green on a narrowing that was never removed; and `BaseSchema`'s
* `[key: string]: any` index signature means an interface that LOST the
* member reads `any`, which a one-way check also accepts. `visible` is
* asserted beside it as the twin control, so a schema that dropped both
* keys cannot pass vacuously.
* 2. Runtime — each of the 18 zod mirrors `safeParse`s a predicate string on
* `disabled` (and on `visible`, the control) and still refuses a number at
* path `disabled`. Every fixture is also parsed WITHOUT `disabled`, so a
* refusal cannot be a broken fixture wearing a green.
* 3. Scope guard — the ruling widens NARROWINGS, not independent
* declarations. The six `disabled?: boolean` shapes in these same three
* files that do not extend `BaseSchema` (`SelectOption`, `RadioOption`,
* `FormField`, `ComboboxOption`, `AccordionItem`, `ToggleGroupItem`) stay
* `boolean` on both faces.
*
* ## Predictions, written before the first run (red-first)
*
* With any one of the three source files (or its mirror) reverted to its
* `origin/main` @ `67dadd602` blob and this file in place:
*
* - TS face reverted: `tsc -p packages/types/tsconfig.test.json` reports
* TS2344 on `assertionDisabledIsBaseUnion`, naming that file's interfaces;
* `assertionVisibleTwinControl` stays clean (nothing narrowed `visible`).
* - zod face reverted: the `accepts a predicate string on disabled` cases for
* that file's mirrors fail (`success: false`), the `visible` control and the
* `refuses a number` cases for the same mirrors stay green, and every other
* file's cases stay green.
*
* The measured counts are in the PR that landed this file.
*/

import { describe, expect, it } from 'vitest';
import type { ZodType } from 'zod';

import type { BaseSchema } from '../base';
import type { ActionSchema } from '../crud';
import type {
AccordionItem,
CollapsibleSchema,
ToggleGroupItem,
ToggleGroupSchema,
} from '../disclosure';
import type {
ButtonSchema,
CalendarSchema,
CheckboxSchema,
ComboboxOption,
ComboboxSchema,
DatePickerSchema,
FileUploadSchema,
FormField,
FormSchema,
InputOTPSchema,
InputSchema,
RadioGroupSchema,
RadioOption,
SelectOption,
SelectSchema,
SliderSchema,
SwitchSchema,
TextareaSchema,
ToggleSchema,
} from '../form';

import { ActionSchema as ActionMirror } from '../zod/crud.zod';
import {
AccordionItemSchema,
CollapsibleSchema as CollapsibleMirror,
ToggleGroupItemSchema,
ToggleGroupSchema as ToggleGroupMirror,
} from '../zod/disclosure.zod';
import * as FormMirrors from '../zod/form.zod';

/* ── Type-level helpers ──────────────────────────────────────────────────── */

/** Invariant equality — `extends` both ways would accept a narrowing. */
type Equal< A, B > =
(< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false;
type Expect< T extends true > = T;

/** The union both twins carry on `BaseSchema`, pinned here so the checks below cannot drift from it. */
type BasePredicate = boolean | string | undefined;
export type assertionBaseDisabled = Expect< Equal< BaseSchema['disabled'], BasePredicate > >;
export type assertionBaseVisible = Expect< Equal< BaseSchema['visible'], BasePredicate > >;

/* ── 1. The 18 formerly-narrowed interfaces inherit the base union ───────── */

type InScope = {
ButtonSchema: ButtonSchema;
InputSchema: InputSchema;
TextareaSchema: TextareaSchema;
SelectSchema: SelectSchema;
CheckboxSchema: CheckboxSchema;
RadioGroupSchema: RadioGroupSchema;
SwitchSchema: SwitchSchema;
ToggleSchema: ToggleSchema;
SliderSchema: SliderSchema;
FileUploadSchema: FileUploadSchema;
DatePickerSchema: DatePickerSchema;
CalendarSchema: CalendarSchema;
InputOTPSchema: InputOTPSchema;
FormSchema: FormSchema;
ComboboxSchema: ComboboxSchema;
ActionSchema: ActionSchema;
CollapsibleSchema: CollapsibleSchema;
ToggleGroupSchema: ToggleGroupSchema;
};

/** The names whose `K` is NOT exactly the base union — `never` when nothing narrows (or loses) it. */
type NotBaseUnion< K extends 'disabled' | 'visible' > = {
[N in keyof InScope]: Equal< InScope[N][K], BasePredicate > extends true ? never : N;
}[keyof InScope];

export type assertionDisabledIsBaseUnion = Expect< Equal< NotBaseUnion<'disabled'>, never > >;
/** Twin control: `visible` was never narrowed, so this stays `never` on every tree. */
export type assertionVisibleTwinControl = Expect< Equal< NotBaseUnion<'visible'>, never > >;

/* ── 3. Scope guard: independent declarations are not narrowings ─────────── */

type OutOfScope = {
SelectOption: SelectOption;
RadioOption: RadioOption;
FormField: FormField;
ComboboxOption: ComboboxOption;
AccordionItem: AccordionItem;
ToggleGroupItem: ToggleGroupItem;
};

type Widened = {
[N in keyof OutOfScope]: Equal< OutOfScope[N]['disabled'], boolean | undefined > extends true ? never : N;
}[keyof OutOfScope];

export type assertionIndependentDeclarationsStayBoolean = Expect< Equal< Widened, never > >;

/* ── 2. Runtime: the zod mirrors accept what the interfaces now declare ──── */

const PREDICATE = '${data.status === "locked"}';

interface MirrorCase {
name: string;
mirror: ZodType;
/** Minimal valid node — every required key, nothing optional. */
fixture: Record<string, unknown>;
}

const IN_SCOPE: readonly MirrorCase[] = [
{ name: 'form.zod.ts#ButtonSchema', mirror: FormMirrors.ButtonSchema, fixture: { type: 'button' } },
{ name: 'form.zod.ts#InputSchema', mirror: FormMirrors.InputSchema, fixture: { type: 'input' } },
{ name: 'form.zod.ts#TextareaSchema', mirror: FormMirrors.TextareaSchema, fixture: { type: 'textarea' } },
{ name: 'form.zod.ts#SelectSchema', mirror: FormMirrors.SelectSchema, fixture: { type: 'select', options: [{ label: 'A', value: 'a' }] } },
{ name: 'form.zod.ts#CheckboxSchema', mirror: FormMirrors.CheckboxSchema, fixture: { type: 'checkbox' } },
{ name: 'form.zod.ts#RadioGroupSchema', mirror: FormMirrors.RadioGroupSchema, fixture: { type: 'radio-group', options: [{ label: 'A', value: 'a' }] } },
{ name: 'form.zod.ts#SwitchSchema', mirror: FormMirrors.SwitchSchema, fixture: { type: 'switch' } },
{ name: 'form.zod.ts#ToggleSchema', mirror: FormMirrors.ToggleSchema, fixture: { type: 'toggle' } },
{ name: 'form.zod.ts#SliderSchema', mirror: FormMirrors.SliderSchema, fixture: { type: 'slider' } },
{ name: 'form.zod.ts#FileUploadSchema', mirror: FormMirrors.FileUploadSchema, fixture: { type: 'file-upload' } },
{ name: 'form.zod.ts#DatePickerSchema', mirror: FormMirrors.DatePickerSchema, fixture: { type: 'date-picker' } },
{ name: 'form.zod.ts#CalendarSchema', mirror: FormMirrors.CalendarSchema, fixture: { type: 'calendar' } },
{ name: 'form.zod.ts#InputOTPSchema', mirror: FormMirrors.InputOTPSchema, fixture: { type: 'input-otp' } },
{ name: 'form.zod.ts#FormSchema', mirror: FormMirrors.FormSchema, fixture: { type: 'form', fields: [{ name: 'title', type: 'text' }] } },
{ name: 'form.zod.ts#ComboboxSchema', mirror: FormMirrors.ComboboxSchema, fixture: { type: 'combobox', options: [{ label: 'A', value: 'a' }] } },
{ name: 'crud.zod.ts#ActionSchema', mirror: ActionMirror, fixture: { type: 'action', label: 'Save' } },
{
name: 'disclosure.zod.ts#CollapsibleSchema',
mirror: CollapsibleMirror,
fixture: { type: 'collapsible', trigger: { type: 'button', label: 'More' }, content: { type: 'text', text: 'Body' } },
},
{ name: 'disclosure.zod.ts#ToggleGroupSchema', mirror: ToggleGroupMirror, fixture: { type: 'toggle-group' } },
];

const OUT_OF_SCOPE: readonly MirrorCase[] = [
{ name: 'form.zod.ts#SelectOptionSchema', mirror: FormMirrors.SelectOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#RadioOptionSchema', mirror: FormMirrors.RadioOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#ComboboxOptionSchema', mirror: FormMirrors.ComboboxOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#FormFieldSchema', mirror: FormMirrors.FormFieldSchema, fixture: { name: 'title' } },
{ name: 'disclosure.zod.ts#AccordionItemSchema', mirror: AccordionItemSchema, fixture: { value: 'a', title: 'A', content: { type: 'text', text: 'Body' } } },
{ name: 'disclosure.zod.ts#ToggleGroupItemSchema', mirror: ToggleGroupItemSchema, fixture: { value: 'a', label: 'A' } },
];

const issuePaths = (r: ReturnType<ZodType['safeParse']>): string[] =>
r.success ? [] : r.error.issues.map((issue) => issue.path.map(String).join('.'));

describe('`disabled` is the BaseSchema union on every formerly-narrowed mirror (objectui#7087)', () => {
it('covers the 18 interfaces the ruling names, and the 6 it excludes', () => {
// The census the ruling was made on. A mirror added to or dropped from
// either list changes the ruling's population and must say so here.
expect(IN_SCOPE).toHaveLength(18);
expect(OUT_OF_SCOPE).toHaveLength(6);
});

it.each(IN_SCOPE)('$name: the fixture is valid on its own', ({ mirror, fixture }) => {
// Control: the refusals below are about `disabled`, not about a fixture
// that never parsed.
expect(issuePaths(mirror.safeParse(fixture))).toEqual([]);
});

it.each(IN_SCOPE)('$name accepts a predicate string on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: PREDICATE });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { disabled?: unknown }).disabled).toBe(PREDICATE);
});

it.each(IN_SCOPE)('$name accepts a predicate string on `visible` — the twin control', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, visible: PREDICATE });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { visible?: unknown }).visible).toBe(PREDICATE);
});

it.each(IN_SCOPE)('$name still refuses a number on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: 1 });
expect(r.success).toBe(false);
expect(issuePaths(r)).toContain('disabled');
});

it.each(IN_SCOPE)('$name still accepts the boolean form — a widening, not a replacement', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: true });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { disabled?: unknown }).disabled).toBe(true);
});
});

describe('scope guard: the independent `disabled?: boolean` declarations are not narrowings (objectui#7087)', () => {
it.each(OUT_OF_SCOPE)('$name: the fixture is valid on its own', ({ mirror, fixture }) => {
expect(issuePaths(mirror.safeParse(fixture))).toEqual([]);
});

it.each(OUT_OF_SCOPE)('$name still refuses a string on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: PREDICATE });
expect(r.success).toBe(false);
expect(issuePaths(r)).toContain('disabled');
});
});
4 changes: 0 additions & 4 deletions packages/types/src/crud.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,10 +83,6 @@ export interface ActionSchema extends BaseSchema {
* Action variant
*/
variant?: 'default' | 'outline' | 'ghost' | 'link';
/**
* Whether action is disabled
*/
disabled?: boolean;
/**
* Action type
* Enhanced in Phase 2 with 'ajax', 'confirm', 'dialog'
Expand Down
8 changes: 0 additions & 8 deletions packages/types/src/disclosure.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,10 +103,6 @@ export interface CollapsibleSchema extends BaseSchema {
* Controlled open state
*/
open?: boolean;
/**
* Whether collapsible is disabled
*/
disabled?: boolean;
/**
* Open state change handler
*/
Expand DownExpand Up@@ -167,10 +163,6 @@ export interface ToggleGroupSchema extends BaseSchema {
* Controlled selected value(s)
*/
value?: string | string[];
/**
* Whether toggle group is disabled
*/
disabled?: boolean;
/**
* Change handler
*/
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
35 changes: 35 additions & 0 deletions .changeset/7087-disabled-twin-symmetry.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@object-ui/types': minor
---

`disabled` accepts a predicate string — `boolean | string`, the `BaseSchema` union — on
the 18 concrete schemas that used to narrow it back to `boolean` (objectui#7087,
maintainer ruling 2026-09-01: option 1, scoped to `disabled`).

`visible` and `disabled` are twins: objectui#4581 widened both on `BaseSchema` on the same
evidence — `SchemaRenderer` evaluates both through `evaluator.evaluateCondition` rather
than reading either as a boolean. After that widening, 0 of the 124 `extends BaseSchema`
interfaces redeclared `visible`, while 18 still carried a pre-widening
`disabled?: boolean` of their own, with matching `z.boolean()` mirrors. So
`disabled: "${data.status === 'locked'}"` — the capability the renderer implements and
the base type advertises — was a type error and a zod refusal on `ButtonSchema`,
`InputSchema`, `TextareaSchema`, `SelectSchema`, `CheckboxSchema`, `RadioGroupSchema`,
`SwitchSchema`, `ToggleSchema`, `SliderSchema`, `FileUploadSchema`, `DatePickerSchema`,
`CalendarSchema`, `InputOTPSchema`, `FormSchema`, `ComboboxSchema`, `ActionSchema`,
`CollapsibleSchema` and `ToggleGroupSchema`.

Those 18 redeclarations are removed, on both faces. The interfaces inherit
`BaseSchema.disabled` the way they always inherited `visible`; the zod mirrors inherit
`base.zod.ts`'s `z.union([z.boolean(), z.string()])` through `.extend()`'s merged
`.shape`, so there is no second spelling of the union to drift from — the route
`ChatbotSchema` took in objectui#6169.

**Additive for authors**: a predicate string is now accepted where it was refused; every
boolean that parsed before parses unchanged, and a number is still refused at path
`disabled`. Runtime behaviour does not change — the renderer already evaluated both twins.

**Out of scope, per the ruling**: `label` (29 narrowings) and `description` (32) carry
`string | I18nLabel` i18n semantics and wait for their own ruling; the independent
`disabled?: boolean` declarations on shapes that do not extend `BaseSchema`
(`SelectOption`, `RadioOption`, `FormField`, `ComboboxOption`, `AccordionItem`,
`ToggleGroupItem`, and the rest of that family) are not narrowings and are untouched.
264 changes: 264 additions & 0 deletions packages/types/src/__tests__/disabled-twin-symmetry-7087.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `disabled` is inherited from `BaseSchema` — `boolean | string` — on every
* concrete schema that used to narrow it back to `boolean` (objectui#7087,
* maintainer ruling 2026-09-01: option 1, scoped to `disabled`).
*
* ## What was narrowed, and why the narrowing was wrong
*
* `visible` and `disabled` are twins. objectui#4581 widened both on `BaseSchema`
* to `boolean | string` on the same evidence: `SchemaRenderer` reads neither key
* as a boolean, it routes both through `evaluator.evaluateCondition`, declared
* `(condition: string | boolean | undefined, context?) => boolean`. After that
* widening, 0 of the 124 `extends BaseSchema` interfaces redeclared `visible`,
* while 18 kept a pre-widening `disabled?: boolean` of their own — 15 in
* `form.ts`, `ActionSchema` in `crud.ts`, `CollapsibleSchema` and
* `ToggleGroupSchema` in `disclosure.ts` — and their zod mirrors carried the
* matching `z.boolean()`. So `disabled: "${data.status === 'locked'}"`, the
* capability the renderer implements and `BaseSchema` advertises, was a type
* error and a zod refusal on exactly the schemas an author reaches for first.
*
* The fix is the one `ChatbotSchema` already took (objectui#6169): the key is
* not redeclared at all. The TS interface inherits the member the way `visible`
* always has, and `.extend()`'s `.shape` merges the parent's fields into the
* child's, so the base union reaches every mirror without a second spelling of
* it that could drift.
*
* ## What this file pins
*
* 1. Type level — for each of the 18, `X['disabled']` is EXACTLY
* `boolean | string | undefined`, invariantly. `Equal`, not `extends`: the
* narrow `boolean` is assignable to the wide union, so a one-way check
* stays green on a narrowing that was never removed; and `BaseSchema`'s
* `[key: string]: any` index signature means an interface that LOST the
* member reads `any`, which a one-way check also accepts. `visible` is
* asserted beside it as the twin control, so a schema that dropped both
* keys cannot pass vacuously.
* 2. Runtime — each of the 18 zod mirrors `safeParse`s a predicate string on
* `disabled` (and on `visible`, the control) and still refuses a number at
* path `disabled`. Every fixture is also parsed WITHOUT `disabled`, so a
* refusal cannot be a broken fixture wearing a green.
* 3. Scope guard — the ruling widens NARROWINGS, not independent
* declarations. The six `disabled?: boolean` shapes in these same three
* files that do not extend `BaseSchema` (`SelectOption`, `RadioOption`,
* `FormField`, `ComboboxOption`, `AccordionItem`, `ToggleGroupItem`) stay
* `boolean` on both faces.
*
* ## Predictions, written before the first run (red-first)
*
* With any one of the three source files (or its mirror) reverted to its
* `origin/main` @ `67dadd602` blob and this file in place:
*
* - TS face reverted: `tsc -p packages/types/tsconfig.test.json` reports
* TS2344 on `assertionDisabledIsBaseUnion`, naming that file's interfaces;
* `assertionVisibleTwinControl` stays clean (nothing narrowed `visible`).
* - zod face reverted: the `accepts a predicate string on disabled` cases for
* that file's mirrors fail (`success: false`), the `visible` control and the
* `refuses a number` cases for the same mirrors stay green, and every other
* file's cases stay green.
*
* The measured counts are in the PR that landed this file.
*/

import { describe, expect, it } from 'vitest';
import type { ZodType } from 'zod';

import type { BaseSchema } from '../base';
import type { ActionSchema } from '../crud';
import type {
AccordionItem,
CollapsibleSchema,
ToggleGroupItem,
ToggleGroupSchema,
} from '../disclosure';
import type {
ButtonSchema,
CalendarSchema,
CheckboxSchema,
ComboboxOption,
ComboboxSchema,
DatePickerSchema,
FileUploadSchema,
FormField,
FormSchema,
InputOTPSchema,
InputSchema,
RadioGroupSchema,
RadioOption,
SelectOption,
SelectSchema,
SliderSchema,
SwitchSchema,
TextareaSchema,
ToggleSchema,
} from '../form';

import { ActionSchema as ActionMirror } from '../zod/crud.zod';
import {
AccordionItemSchema,
CollapsibleSchema as CollapsibleMirror,
ToggleGroupItemSchema,
ToggleGroupSchema as ToggleGroupMirror,
} from '../zod/disclosure.zod';
import * as FormMirrors from '../zod/form.zod';

/* ── Type-level helpers ──────────────────────────────────────────────────── */

/** Invariant equality — `extends` both ways would accept a narrowing. */
type Equal< A, B > =
(< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false;
type Expect< T extends true > = T;

/** The union both twins carry on `BaseSchema`, pinned here so the checks below cannot drift from it. */
type BasePredicate = boolean | string | undefined;
export type assertionBaseDisabled = Expect< Equal< BaseSchema['disabled'], BasePredicate > >;
export type assertionBaseVisible = Expect< Equal< BaseSchema['visible'], BasePredicate > >;

/* ── 1. The 18 formerly-narrowed interfaces inherit the base union ───────── */

type InScope = {
ButtonSchema: ButtonSchema;
InputSchema: InputSchema;
TextareaSchema: TextareaSchema;
SelectSchema: SelectSchema;
CheckboxSchema: CheckboxSchema;
RadioGroupSchema: RadioGroupSchema;
SwitchSchema: SwitchSchema;
ToggleSchema: ToggleSchema;
SliderSchema: SliderSchema;
FileUploadSchema: FileUploadSchema;
DatePickerSchema: DatePickerSchema;
CalendarSchema: CalendarSchema;
InputOTPSchema: InputOTPSchema;
FormSchema: FormSchema;
ComboboxSchema: ComboboxSchema;
ActionSchema: ActionSchema;
CollapsibleSchema: CollapsibleSchema;
ToggleGroupSchema: ToggleGroupSchema;
};

/** The names whose `K` is NOT exactly the base union — `never` when nothing narrows (or loses) it. */
type NotBaseUnion< K extends 'disabled' | 'visible' > = {
[N in keyof InScope]: Equal< InScope[N][K], BasePredicate > extends true ? never : N;
}[keyof InScope];

export type assertionDisabledIsBaseUnion = Expect< Equal< NotBaseUnion<'disabled'>, never > >;
/** Twin control: `visible` was never narrowed, so this stays `never` on every tree. */
export type assertionVisibleTwinControl = Expect< Equal< NotBaseUnion<'visible'>, never > >;

/* ── 3. Scope guard: independent declarations are not narrowings ─────────── */

type OutOfScope = {
SelectOption: SelectOption;
RadioOption: RadioOption;
FormField: FormField;
ComboboxOption: ComboboxOption;
AccordionItem: AccordionItem;
ToggleGroupItem: ToggleGroupItem;
};

type Widened = {
[N in keyof OutOfScope]: Equal< OutOfScope[N]['disabled'], boolean | undefined > extends true ? never : N;
}[keyof OutOfScope];

export type assertionIndependentDeclarationsStayBoolean = Expect< Equal< Widened, never > >;

/* ── 2. Runtime: the zod mirrors accept what the interfaces now declare ──── */

const PREDICATE = '${data.status === "locked"}';

interface MirrorCase {
name: string;
mirror: ZodType;
/** Minimal valid node — every required key, nothing optional. */
fixture: Record<string, unknown>;
}

const IN_SCOPE: readonly MirrorCase[] = [
{ name: 'form.zod.ts#ButtonSchema', mirror: FormMirrors.ButtonSchema, fixture: { type: 'button' } },
{ name: 'form.zod.ts#InputSchema', mirror: FormMirrors.InputSchema, fixture: { type: 'input' } },
{ name: 'form.zod.ts#TextareaSchema', mirror: FormMirrors.TextareaSchema, fixture: { type: 'textarea' } },
{ name: 'form.zod.ts#SelectSchema', mirror: FormMirrors.SelectSchema, fixture: { type: 'select', options: [{ label: 'A', value: 'a' }] } },
{ name: 'form.zod.ts#CheckboxSchema', mirror: FormMirrors.CheckboxSchema, fixture: { type: 'checkbox' } },
{ name: 'form.zod.ts#RadioGroupSchema', mirror: FormMirrors.RadioGroupSchema, fixture: { type: 'radio-group', options: [{ label: 'A', value: 'a' }] } },
{ name: 'form.zod.ts#SwitchSchema', mirror: FormMirrors.SwitchSchema, fixture: { type: 'switch' } },
{ name: 'form.zod.ts#ToggleSchema', mirror: FormMirrors.ToggleSchema, fixture: { type: 'toggle' } },
{ name: 'form.zod.ts#SliderSchema', mirror: FormMirrors.SliderSchema, fixture: { type: 'slider' } },
{ name: 'form.zod.ts#FileUploadSchema', mirror: FormMirrors.FileUploadSchema, fixture: { type: 'file-upload' } },
{ name: 'form.zod.ts#DatePickerSchema', mirror: FormMirrors.DatePickerSchema, fixture: { type: 'date-picker' } },
{ name: 'form.zod.ts#CalendarSchema', mirror: FormMirrors.CalendarSchema, fixture: { type: 'calendar' } },
{ name: 'form.zod.ts#InputOTPSchema', mirror: FormMirrors.InputOTPSchema, fixture: { type: 'input-otp' } },
{ name: 'form.zod.ts#FormSchema', mirror: FormMirrors.FormSchema, fixture: { type: 'form', fields: [{ name: 'title', type: 'text' }] } },
{ name: 'form.zod.ts#ComboboxSchema', mirror: FormMirrors.ComboboxSchema, fixture: { type: 'combobox', options: [{ label: 'A', value: 'a' }] } },
{ name: 'crud.zod.ts#ActionSchema', mirror: ActionMirror, fixture: { type: 'action', label: 'Save' } },
{
name: 'disclosure.zod.ts#CollapsibleSchema',
mirror: CollapsibleMirror,
fixture: { type: 'collapsible', trigger: { type: 'button', label: 'More' }, content: { type: 'text', text: 'Body' } },
},
{ name: 'disclosure.zod.ts#ToggleGroupSchema', mirror: ToggleGroupMirror, fixture: { type: 'toggle-group' } },
];

const OUT_OF_SCOPE: readonly MirrorCase[] = [
{ name: 'form.zod.ts#SelectOptionSchema', mirror: FormMirrors.SelectOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#RadioOptionSchema', mirror: FormMirrors.RadioOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#ComboboxOptionSchema', mirror: FormMirrors.ComboboxOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#FormFieldSchema', mirror: FormMirrors.FormFieldSchema, fixture: { name: 'title' } },
{ name: 'disclosure.zod.ts#AccordionItemSchema', mirror: AccordionItemSchema, fixture: { value: 'a', title: 'A', content: { type: 'text', text: 'Body' } } },
{ name: 'disclosure.zod.ts#ToggleGroupItemSchema', mirror: ToggleGroupItemSchema, fixture: { value: 'a', label: 'A' } },
];

const issuePaths = (r: ReturnType<ZodType['safeParse']>): string[] =>
r.success ? [] : r.error.issues.map((issue) => issue.path.map(String).join('.'));

describe('`disabled` is the BaseSchema union on every formerly-narrowed mirror (objectui#7087)', () => {
it('covers the 18 interfaces the ruling names, and the 6 it excludes', () => {
// The census the ruling was made on. A mirror added to or dropped from
// either list changes the ruling's population and must say so here.
expect(IN_SCOPE).toHaveLength(18);
expect(OUT_OF_SCOPE).toHaveLength(6);
});

it.each(IN_SCOPE)('$name: the fixture is valid on its own', ({ mirror, fixture }) => {
// Control: the refusals below are about `disabled`, not about a fixture
// that never parsed.
expect(issuePaths(mirror.safeParse(fixture))).toEqual([]);
});

it.each(IN_SCOPE)('$name accepts a predicate string on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: PREDICATE });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { disabled?: unknown }).disabled).toBe(PREDICATE);
});

it.each(IN_SCOPE)('$name accepts a predicate string on `visible` — the twin control', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, visible: PREDICATE });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { visible?: unknown }).visible).toBe(PREDICATE);
});

it.each(IN_SCOPE)('$name still refuses a number on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: 1 });
expect(r.success).toBe(false);
expect(issuePaths(r)).toContain('disabled');
});

it.each(IN_SCOPE)('$name still accepts the boolean form — a widening, not a replacement', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: true });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { disabled?: unknown }).disabled).toBe(true);
});
});

describe('scope guard: the independent `disabled?: boolean` declarations are not narrowings (objectui#7087)', () => {
it.each(OUT_OF_SCOPE)('$name: the fixture is valid on its own', ({ mirror, fixture }) => {
expect(issuePaths(mirror.safeParse(fixture))).toEqual([]);
});

it.each(OUT_OF_SCOPE)('$name still refuses a string on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: PREDICATE });
expect(r.success).toBe(false);
expect(issuePaths(r)).toContain('disabled');
});
});
4 changes: 0 additions & 4 deletions packages/types/src/crud.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,10 +83,6 @@ export interface ActionSchema extends BaseSchema {
* Action variant
*/
variant?: 'default' | 'outline' | 'ghost' | 'link';
/**
* Whether action is disabled
*/
disabled?: boolean;
/**
* Action type
* Enhanced in Phase 2 with 'ajax', 'confirm', 'dialog'
Expand Down
8 changes: 0 additions & 8 deletions packages/types/src/disclosure.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,10 +103,6 @@ export interface CollapsibleSchema extends BaseSchema {
* Controlled open state
*/
open?: boolean;
/**
* Whether collapsible is disabled
*/
disabled?: boolean;
/**
* Open state change handler
*/
Expand DownExpand Up@@ -167,10 +163,6 @@ export interface ToggleGroupSchema extends BaseSchema {
* Controlled selected value(s)
*/
value?: string | string[];
/**
* Whether toggle group is disabled
*/
disabled?: boolean;
/**
* Change handler
*/
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
35 changes: 35 additions & 0 deletions .changeset/7087-disabled-twin-symmetry.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@object-ui/types': minor
---

`disabled` accepts a predicate string — `boolean | string`, the `BaseSchema` union — on
the 18 concrete schemas that used to narrow it back to `boolean` (objectui#7087,
maintainer ruling 2026-09-01: option 1, scoped to `disabled`).

`visible` and `disabled` are twins: objectui#4581 widened both on `BaseSchema` on the same
evidence — `SchemaRenderer` evaluates both through `evaluator.evaluateCondition` rather
than reading either as a boolean. After that widening, 0 of the 124 `extends BaseSchema`
interfaces redeclared `visible`, while 18 still carried a pre-widening
`disabled?: boolean` of their own, with matching `z.boolean()` mirrors. So
`disabled: "${data.status === 'locked'}"` — the capability the renderer implements and
the base type advertises — was a type error and a zod refusal on `ButtonSchema`,
`InputSchema`, `TextareaSchema`, `SelectSchema`, `CheckboxSchema`, `RadioGroupSchema`,
`SwitchSchema`, `ToggleSchema`, `SliderSchema`, `FileUploadSchema`, `DatePickerSchema`,
`CalendarSchema`, `InputOTPSchema`, `FormSchema`, `ComboboxSchema`, `ActionSchema`,
`CollapsibleSchema` and `ToggleGroupSchema`.

Those 18 redeclarations are removed, on both faces. The interfaces inherit
`BaseSchema.disabled` the way they always inherited `visible`; the zod mirrors inherit
`base.zod.ts`'s `z.union([z.boolean(), z.string()])` through `.extend()`'s merged
`.shape`, so there is no second spelling of the union to drift from — the route
`ChatbotSchema` took in objectui#6169.

**Additive for authors**: a predicate string is now accepted where it was refused; every
boolean that parsed before parses unchanged, and a number is still refused at path
`disabled`. Runtime behaviour does not change — the renderer already evaluated both twins.

**Out of scope, per the ruling**: `label` (29 narrowings) and `description` (32) carry
`string | I18nLabel` i18n semantics and wait for their own ruling; the independent
`disabled?: boolean` declarations on shapes that do not extend `BaseSchema`
(`SelectOption`, `RadioOption`, `FormField`, `ComboboxOption`, `AccordionItem`,
`ToggleGroupItem`, and the rest of that family) are not narrowings and are untouched.
264 changes: 264 additions & 0 deletions packages/types/src/__tests__/disabled-twin-symmetry-7087.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `disabled` is inherited from `BaseSchema` — `boolean | string` — on every
* concrete schema that used to narrow it back to `boolean` (objectui#7087,
* maintainer ruling 2026-09-01: option 1, scoped to `disabled`).
*
* ## What was narrowed, and why the narrowing was wrong
*
* `visible` and `disabled` are twins. objectui#4581 widened both on `BaseSchema`
* to `boolean | string` on the same evidence: `SchemaRenderer` reads neither key
* as a boolean, it routes both through `evaluator.evaluateCondition`, declared
* `(condition: string | boolean | undefined, context?) => boolean`. After that
* widening, 0 of the 124 `extends BaseSchema` interfaces redeclared `visible`,
* while 18 kept a pre-widening `disabled?: boolean` of their own — 15 in
* `form.ts`, `ActionSchema` in `crud.ts`, `CollapsibleSchema` and
* `ToggleGroupSchema` in `disclosure.ts` — and their zod mirrors carried the
* matching `z.boolean()`. So `disabled: "${data.status === 'locked'}"`, the
* capability the renderer implements and `BaseSchema` advertises, was a type
* error and a zod refusal on exactly the schemas an author reaches for first.
*
* The fix is the one `ChatbotSchema` already took (objectui#6169): the key is
* not redeclared at all. The TS interface inherits the member the way `visible`
* always has, and `.extend()`'s `.shape` merges the parent's fields into the
* child's, so the base union reaches every mirror without a second spelling of
* it that could drift.
*
* ## What this file pins
*
* 1. Type level — for each of the 18, `X['disabled']` is EXACTLY
* `boolean | string | undefined`, invariantly. `Equal`, not `extends`: the
* narrow `boolean` is assignable to the wide union, so a one-way check
* stays green on a narrowing that was never removed; and `BaseSchema`'s
* `[key: string]: any` index signature means an interface that LOST the
* member reads `any`, which a one-way check also accepts. `visible` is
* asserted beside it as the twin control, so a schema that dropped both
* keys cannot pass vacuously.
* 2. Runtime — each of the 18 zod mirrors `safeParse`s a predicate string on
* `disabled` (and on `visible`, the control) and still refuses a number at
* path `disabled`. Every fixture is also parsed WITHOUT `disabled`, so a
* refusal cannot be a broken fixture wearing a green.
* 3. Scope guard — the ruling widens NARROWINGS, not independent
* declarations. The six `disabled?: boolean` shapes in these same three
* files that do not extend `BaseSchema` (`SelectOption`, `RadioOption`,
* `FormField`, `ComboboxOption`, `AccordionItem`, `ToggleGroupItem`) stay
* `boolean` on both faces.
*
* ## Predictions, written before the first run (red-first)
*
* With any one of the three source files (or its mirror) reverted to its
* `origin/main` @ `67dadd602` blob and this file in place:
*
* - TS face reverted: `tsc -p packages/types/tsconfig.test.json` reports
* TS2344 on `assertionDisabledIsBaseUnion`, naming that file's interfaces;
* `assertionVisibleTwinControl` stays clean (nothing narrowed `visible`).
* - zod face reverted: the `accepts a predicate string on disabled` cases for
* that file's mirrors fail (`success: false`), the `visible` control and the
* `refuses a number` cases for the same mirrors stay green, and every other
* file's cases stay green.
*
* The measured counts are in the PR that landed this file.
*/

import { describe, expect, it } from 'vitest';
import type { ZodType } from 'zod';

import type { BaseSchema } from '../base';
import type { ActionSchema } from '../crud';
import type {
AccordionItem,
CollapsibleSchema,
ToggleGroupItem,
ToggleGroupSchema,
} from '../disclosure';
import type {
ButtonSchema,
CalendarSchema,
CheckboxSchema,
ComboboxOption,
ComboboxSchema,
DatePickerSchema,
FileUploadSchema,
FormField,
FormSchema,
InputOTPSchema,
InputSchema,
RadioGroupSchema,
RadioOption,
SelectOption,
SelectSchema,
SliderSchema,
SwitchSchema,
TextareaSchema,
ToggleSchema,
} from '../form';

import { ActionSchema as ActionMirror } from '../zod/crud.zod';
import {
AccordionItemSchema,
CollapsibleSchema as CollapsibleMirror,
ToggleGroupItemSchema,
ToggleGroupSchema as ToggleGroupMirror,
} from '../zod/disclosure.zod';
import * as FormMirrors from '../zod/form.zod';

/* ── Type-level helpers ──────────────────────────────────────────────────── */

/** Invariant equality — `extends` both ways would accept a narrowing. */
type Equal< A, B > =
(< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false;
type Expect< T extends true > = T;

/** The union both twins carry on `BaseSchema`, pinned here so the checks below cannot drift from it. */
type BasePredicate = boolean | string | undefined;
export type assertionBaseDisabled = Expect< Equal< BaseSchema['disabled'], BasePredicate > >;
export type assertionBaseVisible = Expect< Equal< BaseSchema['visible'], BasePredicate > >;

/* ── 1. The 18 formerly-narrowed interfaces inherit the base union ───────── */

type InScope = {
ButtonSchema: ButtonSchema;
InputSchema: InputSchema;
TextareaSchema: TextareaSchema;
SelectSchema: SelectSchema;
CheckboxSchema: CheckboxSchema;
RadioGroupSchema: RadioGroupSchema;
SwitchSchema: SwitchSchema;
ToggleSchema: ToggleSchema;
SliderSchema: SliderSchema;
FileUploadSchema: FileUploadSchema;
DatePickerSchema: DatePickerSchema;
CalendarSchema: CalendarSchema;
InputOTPSchema: InputOTPSchema;
FormSchema: FormSchema;
ComboboxSchema: ComboboxSchema;
ActionSchema: ActionSchema;
CollapsibleSchema: CollapsibleSchema;
ToggleGroupSchema: ToggleGroupSchema;
};

/** The names whose `K` is NOT exactly the base union — `never` when nothing narrows (or loses) it. */
type NotBaseUnion< K extends 'disabled' | 'visible' > = {
[N in keyof InScope]: Equal< InScope[N][K], BasePredicate > extends true ? never : N;
}[keyof InScope];

export type assertionDisabledIsBaseUnion = Expect< Equal< NotBaseUnion<'disabled'>, never > >;
/** Twin control: `visible` was never narrowed, so this stays `never` on every tree. */
export type assertionVisibleTwinControl = Expect< Equal< NotBaseUnion<'visible'>, never > >;

/* ── 3. Scope guard: independent declarations are not narrowings ─────────── */

type OutOfScope = {
SelectOption: SelectOption;
RadioOption: RadioOption;
FormField: FormField;
ComboboxOption: ComboboxOption;
AccordionItem: AccordionItem;
ToggleGroupItem: ToggleGroupItem;
};

type Widened = {
[N in keyof OutOfScope]: Equal< OutOfScope[N]['disabled'], boolean | undefined > extends true ? never : N;
}[keyof OutOfScope];

export type assertionIndependentDeclarationsStayBoolean = Expect< Equal< Widened, never > >;

/* ── 2. Runtime: the zod mirrors accept what the interfaces now declare ──── */

const PREDICATE = '${data.status === "locked"}';

interface MirrorCase {
name: string;
mirror: ZodType;
/** Minimal valid node — every required key, nothing optional. */
fixture: Record<string, unknown>;
}

const IN_SCOPE: readonly MirrorCase[] = [
{ name: 'form.zod.ts#ButtonSchema', mirror: FormMirrors.ButtonSchema, fixture: { type: 'button' } },
{ name: 'form.zod.ts#InputSchema', mirror: FormMirrors.InputSchema, fixture: { type: 'input' } },
{ name: 'form.zod.ts#TextareaSchema', mirror: FormMirrors.TextareaSchema, fixture: { type: 'textarea' } },
{ name: 'form.zod.ts#SelectSchema', mirror: FormMirrors.SelectSchema, fixture: { type: 'select', options: [{ label: 'A', value: 'a' }] } },
{ name: 'form.zod.ts#CheckboxSchema', mirror: FormMirrors.CheckboxSchema, fixture: { type: 'checkbox' } },
{ name: 'form.zod.ts#RadioGroupSchema', mirror: FormMirrors.RadioGroupSchema, fixture: { type: 'radio-group', options: [{ label: 'A', value: 'a' }] } },
{ name: 'form.zod.ts#SwitchSchema', mirror: FormMirrors.SwitchSchema, fixture: { type: 'switch' } },
{ name: 'form.zod.ts#ToggleSchema', mirror: FormMirrors.ToggleSchema, fixture: { type: 'toggle' } },
{ name: 'form.zod.ts#SliderSchema', mirror: FormMirrors.SliderSchema, fixture: { type: 'slider' } },
{ name: 'form.zod.ts#FileUploadSchema', mirror: FormMirrors.FileUploadSchema, fixture: { type: 'file-upload' } },
{ name: 'form.zod.ts#DatePickerSchema', mirror: FormMirrors.DatePickerSchema, fixture: { type: 'date-picker' } },
{ name: 'form.zod.ts#CalendarSchema', mirror: FormMirrors.CalendarSchema, fixture: { type: 'calendar' } },
{ name: 'form.zod.ts#InputOTPSchema', mirror: FormMirrors.InputOTPSchema, fixture: { type: 'input-otp' } },
{ name: 'form.zod.ts#FormSchema', mirror: FormMirrors.FormSchema, fixture: { type: 'form', fields: [{ name: 'title', type: 'text' }] } },
{ name: 'form.zod.ts#ComboboxSchema', mirror: FormMirrors.ComboboxSchema, fixture: { type: 'combobox', options: [{ label: 'A', value: 'a' }] } },
{ name: 'crud.zod.ts#ActionSchema', mirror: ActionMirror, fixture: { type: 'action', label: 'Save' } },
{
name: 'disclosure.zod.ts#CollapsibleSchema',
mirror: CollapsibleMirror,
fixture: { type: 'collapsible', trigger: { type: 'button', label: 'More' }, content: { type: 'text', text: 'Body' } },
},
{ name: 'disclosure.zod.ts#ToggleGroupSchema', mirror: ToggleGroupMirror, fixture: { type: 'toggle-group' } },
];

const OUT_OF_SCOPE: readonly MirrorCase[] = [
{ name: 'form.zod.ts#SelectOptionSchema', mirror: FormMirrors.SelectOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#RadioOptionSchema', mirror: FormMirrors.RadioOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#ComboboxOptionSchema', mirror: FormMirrors.ComboboxOptionSchema, fixture: { label: 'A', value: 'a' } },
{ name: 'form.zod.ts#FormFieldSchema', mirror: FormMirrors.FormFieldSchema, fixture: { name: 'title' } },
{ name: 'disclosure.zod.ts#AccordionItemSchema', mirror: AccordionItemSchema, fixture: { value: 'a', title: 'A', content: { type: 'text', text: 'Body' } } },
{ name: 'disclosure.zod.ts#ToggleGroupItemSchema', mirror: ToggleGroupItemSchema, fixture: { value: 'a', label: 'A' } },
];

const issuePaths = (r: ReturnType<ZodType['safeParse']>): string[] =>
r.success ? [] : r.error.issues.map((issue) => issue.path.map(String).join('.'));

describe('`disabled` is the BaseSchema union on every formerly-narrowed mirror (objectui#7087)', () => {
it('covers the 18 interfaces the ruling names, and the 6 it excludes', () => {
// The census the ruling was made on. A mirror added to or dropped from
// either list changes the ruling's population and must say so here.
expect(IN_SCOPE).toHaveLength(18);
expect(OUT_OF_SCOPE).toHaveLength(6);
});

it.each(IN_SCOPE)('$name: the fixture is valid on its own', ({ mirror, fixture }) => {
// Control: the refusals below are about `disabled`, not about a fixture
// that never parsed.
expect(issuePaths(mirror.safeParse(fixture))).toEqual([]);
});

it.each(IN_SCOPE)('$name accepts a predicate string on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: PREDICATE });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { disabled?: unknown }).disabled).toBe(PREDICATE);
});

it.each(IN_SCOPE)('$name accepts a predicate string on `visible` — the twin control', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, visible: PREDICATE });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { visible?: unknown }).visible).toBe(PREDICATE);
});

it.each(IN_SCOPE)('$name still refuses a number on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: 1 });
expect(r.success).toBe(false);
expect(issuePaths(r)).toContain('disabled');
});

it.each(IN_SCOPE)('$name still accepts the boolean form — a widening, not a replacement', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: true });
expect(issuePaths(r)).toEqual([]);
expect(r.success && (r.data as { disabled?: unknown }).disabled).toBe(true);
});
});

describe('scope guard: the independent `disabled?: boolean` declarations are not narrowings (objectui#7087)', () => {
it.each(OUT_OF_SCOPE)('$name: the fixture is valid on its own', ({ mirror, fixture }) => {
expect(issuePaths(mirror.safeParse(fixture))).toEqual([]);
});

it.each(OUT_OF_SCOPE)('$name still refuses a string on `disabled`', ({ mirror, fixture }) => {
const r = mirror.safeParse({ ...fixture, disabled: PREDICATE });
expect(r.success).toBe(false);
expect(issuePaths(r)).toContain('disabled');
});
});
4 changes: 0 additions & 4 deletions packages/types/src/crud.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,10 +83,6 @@ export interface ActionSchema extends BaseSchema {
* Action variant
*/
variant?: 'default' | 'outline' | 'ghost' | 'link';
/**
* Whether action is disabled
*/
disabled?: boolean;
/**
* Action type
* Enhanced in Phase 2 with 'ajax', 'confirm', 'dialog'
Expand Down
8 changes: 0 additions & 8 deletions packages/types/src/disclosure.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,10 +103,6 @@ export interface CollapsibleSchema extends BaseSchema {
* Controlled open state
*/
open?: boolean;
/**
* Whether collapsible is disabled
*/
disabled?: boolean;
/**
* Open state change handler
*/
Expand DownExpand Up@@ -167,10 +163,6 @@ export interface ToggleGroupSchema extends BaseSchema {
* Controlled selected value(s)
*/
value?: string | string[];
/**
* Whether toggle group is disabled
*/
disabled?: boolean;
/**
* Change handler
*/
Expand Down
Loading
Loading