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
13 changes: 13 additions & 0 deletions .changeset/hidden-predicate-widen-7455.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
'@object-ui/types': minor
---

**`BaseSchema.hidden` now declares the predicate string the renderer already evaluates** (objectui#7455, maintainer ruling 2026-09-03).

`hidden?: boolean` becomes `hidden?: boolean | string`, and the Zod mirror's `z.boolean()` becomes `z.union([z.boolean(), z.string()])` — matching `visible` (#4581) and `disabled` (#4580 ruling Q3-A) on both faces. `hidden` was the third key on the same evaluated path and the only one still declared boolean-only.

This is a **widening**, not a replacement: every boolean `hidden` keeps parsing and keeps type-checking unchanged, and the renderer's behaviour is untouched by this change — `SchemaRenderer`'s `shouldHide` chain already routed this key through `hasDeclaredPredicate` and evaluated it, which is the evidence the widening rests on. What changes is that authors and their tooling can now write `hidden: "${data.status === 'draft'}"` without casting past the declaration, and the Zod mirror stops refusing it (before this, that value failed `safeParse` with `invalid_type` at path `hidden` while the identical string on `visible` parsed).

`hiddenOn` is unchanged and remains the sibling expression spelling. The CEL envelope object form is still declared on none of `visible` / `hidden` / `disabled`; objectui#7530 rules on all three together.

Per this repository's version-alignment convention, a widening of a published type surface ships as `minor` with the semantics spelled out here rather than as `major` (see AGENTS.md, "版本号策略").
2 changes: 1 addition & 1 deletion content/docs/api/schema-reference.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,7 +69,7 @@ One row per declared member, in declaration order, so the list can be checked ag
| `visible` | `boolean \| string` | Visibility control. Accepts a boolean **or** a predicate expression string — the renderer evaluates this key rather than reading it as a boolean. |
| `visibleWhen` | `string` | Canonical conditional-visibility predicate (ADR-0089); the element is shown when it evaluates truthy. Evaluated **before** `visible` and `visibleOn`, and outranks both. |
| `visibleOn` | `string` | Expression for conditional visibility. **Deprecated** (ADR-0089) — use `visibleWhen`. |
| `hidden` | `boolean` | Inverse of `visible`. Boolean only — unlike `visible`, this key takes no expression. |
| `hidden` | `boolean \| string` | Inverse of `visible` — the node is not rendered. Accepts a boolean **or** a predicate expression string, which the renderer evaluates rather than reading as a boolean; `hiddenOn` remains the sibling spelling. |
| `hiddenOn` | `string` | Expression for conditional hiding. |
| `disabled` | `boolean \| string` | Disabled state. Accepts a boolean **or** a predicate expression string, on the same evaluated path as `visible`. |
| `disabledOn` | `string` | Expression for conditional disabling. |
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,6 +89,7 @@ import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import type { BaseSchema } from '@object-ui/types';
import { SchemaRenderer } from '../SchemaRenderer';
import { SchemaRendererContext } from '../context/SchemaRendererContext';

Expand All@@ -113,6 +114,34 @@ function renderNode(schema: Record<string, unknown>) {
);
}

/**
* The DECLARED path -- no cast at all.
*
* `renderNode` above spreads a `Record<string, unknown>` through `as never`
* because most of this file exercises shapes `BaseSchema` does not declare and
* should not: `null`, `0`, `[]`, `{}`, and the CEL envelope object. Those keep
* the cast.
*
* The STRING form is different since objectui#7455 (ruled 2026-09-03):
* `hidden` is declared `boolean | string`, so an expression-valued `hidden` is
* authorable and the compiler is the right checker for it. Narrowing `hidden`
* back to `boolean` makes the call sites below TS2322 -- and `tsc -p
* tsconfig.test.json` (chained from this package's `type-check` script) is the
* only thing that can see that; vitest cannot, because the annotation is erased
* before a single case runs.
*
* The envelope pin below deliberately stays on `renderNode`: the envelope form
* is declared on NONE of `visible` / `hidden` / `disabled`, and objectui#7530
* rules on all three together.
*/
function renderDeclaredNode(schema: BaseSchema) {
return render(
<SchemaRendererContext.Provider value={{ dataSource: DATA }}>
<SchemaRenderer schema={schema} />
</SchemaRendererContext.Provider>,
);
}

/** Did the node render at all? */
function rendered(): boolean {
return screen.queryByTestId('probe') !== null;
Expand DownExpand Up@@ -177,11 +206,11 @@ describe('SchemaRenderer `hidden` — an empty predicate is not a declared gate
expect(rendered()).toBe(true);
});

it('an expression-valued `hidden` keeps its verdict, both ways', () => {
const { unmount } = renderNode({ hidden: '${data.status === "draft"}' });
it('an expression-valued `hidden` keeps its verdict, both ways -- through the DECLARED path, no cast (objectui#7455)', () => {
const { unmount } = renderDeclaredNode({ type: 'probe-3955', hidden: '${data.status === "draft"}' });
expect(rendered()).toBe(false);
unmount();
renderNode({ hidden: '${data.published}' });
renderDeclaredNode({ type: 'probe-3955', hidden: '${data.published}' });
expect(rendered()).toBe(true);
});

Expand Down
164 changes: 164 additions & 0 deletions packages/types/src/__tests__/base-schema-hidden-predicate.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `BaseSchema.hidden` admits the predicate string the renderer evaluates
* (objectui#7455, maintainer ruling 2026-09-03: option A, widen).
*
* The twin of `base-schema-visible-predicate.test.ts` (#4581) and of
* `disabled-twin-symmetry-7087.test.ts` (#4580 Q3-A). `hidden` was the third
* key on the same evaluated path and the only one still declared boolean-only
* on both faces.
*
* ## The evidence
*
* `SchemaRenderer`'s `shouldHide` chain does not read this key as a boolean:
*
* ```ts
* if (hasDeclaredPredicate(newSchema.hidden)) {
* return evaluateVisibilityPredicate(newSchema.hidden, 'hidden');
* }
* ```
*
* `hasDeclaredPredicate` (`packages/core/src/evaluator/declaredPredicate.ts`)
* is the repo's single shared definition of "declared", asked by all three
* keys, all three `*On` siblings, `ActionRunner` and `ActionEngine`; the
* evaluator underneath is declared
* `(condition: string | boolean | undefined, ...) => boolean`. Predicate
* strings on `hidden` were already SHIPPED and PINNED — see
* `packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx`,
* which drove them through a `Record<string, unknown>` helper because the
* declaration refused them.
*
* ## Measured before the change (red-first, on `origin/main` d04e79a80)
*
* • TS — `base.ts:328` was `hidden?: boolean`.
* • zod — `base.zod.ts:175` was `z.boolean()`, and
* `BaseSchema.safeParse({ type: 'probe', hidden: '${data.status === "draft"}' })`
* returned `success: false`,
* `{ code: 'invalid_type', expected: 'boolean', path: ['hidden'] }`,
* while the identical string on `visible` parsed. So the zod mirror was NOT
* already ahead of TS — both faces refused it.
*
* ## What this file pins, and why in this shape
*
* 1. Type level — `BaseSchema['hidden']` 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 widening that never happened, and `BaseSchema`'s
* `[key: string]: any` index signature means a DELETED member reads `any`,
* which a one-way check also accepts. The overshoot is the live risk here,
* not a hypothetical.
* 2. The three keys are asserted to carry the SAME declared type. The ruling's
* words are "matching `visible` and `disabled` on both faces"; asserting
* `hidden` alone would stay green if a later change narrowed one of the
* other two, which is the asymmetry this card exists to remove.
* 3. Runtime (zod face) — the string form and the boolean form both
* `safeParse` GREEN in full, and a NUMBER is still refused at path
* `hidden`. The refusal is the anti-overshoot guard: `z.any()` would
* satisfy every positive case on its own.
*
* ## Deliberately NOT pinned here: the CEL envelope object
*
* `hasDeclaredPredicate` accepts `{ dialect, source }` on this key, and NO key
* declares it — `visible` and `disabled` are `boolean | string` and under-report
* it too. objectui#7530 rules on all three together (declare on all three, or
* refuse on all three). This file therefore asserts nothing about that shape in
* either direction; pinning the current refusal on `hidden` alone would
* pre-empt that ruling and re-introduce, in the pins, exactly the three-way
* asymmetry the widening just removed.
*
* ADR-0089's carve-out ("the boolean `visible` ... is explicitly out of scope")
* governs `packages/spec`'s keys, not this surface — `BaseSchema` is objectui's
* own declaration. It is evidence of intent about the same concept, which is
* why this was ruled rather than applied mechanically.
*/

import { describe, it, expect } from 'vitest';
import type { BaseSchema } from '../base';
import { BaseSchema as Mirror } from '../zod/base.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 declared type is exactly what the evaluator accepts ─────────────── */

export type assertionHidden = Expect<
Equal< BaseSchema['hidden'], boolean | string | undefined >
>;

/** The two siblings, asserted beside it: all three keys carry one type. */
export type assertionHiddenMatchesVisible = Expect<
Equal< BaseSchema['hidden'], BaseSchema['visible'] >
>;
export type assertionHiddenMatchesDisabled = Expect<
Equal< BaseSchema['hidden'], BaseSchema['disabled'] >
>;

/* ── Authorable fixtures ─────────────────────────────────────────────────── */

/** The capability the renderer implements, now declared. */
export const hiddenPredicateStringIsAuthorable: BaseSchema = {
type: 'test-component',
hidden: 'record.status == "draft"',
};

/** The template-expression spelling the shipped react pins use. */
export const hiddenTemplateExpressionIsAuthorable: BaseSchema = {
type: 'test-component',
hidden: '${data.status === "draft"}',
};

/** The boolean form is untouched — this is a widening, not a replacement. */
export const hiddenBooleanIsStillAuthorable: BaseSchema = {
type: 'test-component',
hidden: true,
};

/* ── Runtime companion (the zod mirror) ──────────────────────────────────── */

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

describe('BaseSchema.hidden (objectui#7455)', () => {
it('type-level: hidden is boolean | string, pinned invariantly against both siblings', () => {
// Erased at runtime; `tsc -p tsconfig.test.json` is the checker, chained
// from this package's `type-check` script. The runtime case exists so a
// green vitest run is not mistaken for the proof.
expect(hiddenPredicateStringIsAuthorable.hidden).toBe('record.status == "draft"');
expect(hiddenBooleanIsStillAuthorable.hidden).toBe(true);
});

it('zod mirror: a predicate string on `hidden` parses in full', () => {
const result = Mirror.safeParse({ type: 'test-component', hidden: PREDICATE });
// Full parse, not just "no unrecognized_keys": this is a judgement about
// the VALUE, so nothing short of a green `safeParse` measures it.
expect(result.success).toBe(true);
});

it('zod mirror: `visible` and `disabled` take the same string — the control', () => {
// If these ever go red, the failure is NOT about `hidden`, and the
// assertion above would have been passing for the wrong reason.
expect(Mirror.safeParse({ type: 'test-component', visible: PREDICATE }).success).toBe(true);
expect(Mirror.safeParse({ type: 'test-component', disabled: PREDICATE }).success).toBe(true);
});

it('zod mirror: the boolean form still parses — a widening, not a replacement', () => {
expect(Mirror.safeParse({ type: 'test-component', hidden: true }).success).toBe(true);
expect(Mirror.safeParse({ type: 'test-component', hidden: false }).success).toBe(true);
});

it('zod mirror: a number is still refused at path `hidden` — the anti-overshoot guard', () => {
// `BaseSchema` is `.passthrough()`, but `hidden` is a DECLARED key, so a
// wrong-typed value is an `invalid_type` error rather than a passthrough.
// Without this case, widening the key to `z.any()` would satisfy every
// positive assertion above.
const result = Mirror.safeParse({ type: 'test-component', hidden: 123 });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.join('.') === 'hidden')).toBe(true);
}
});
});
41 changes: 37 additions & 4 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,12 @@
* already pins equal to `keyof Declared`. Nothing asserts it against a written
* number, so this line is prose and can rot; the pin that cannot is the one
* comparing the two halves to each other.
* - **39 entries** in `KnownDrift`, **55 keys** across them — 56 until objectui#6940
* - **40 entries** in `KnownDrift`, **56 keys** across them — 39 / 55 until
* objectui#7455 SEEDED `app.zod.ts#AppComponentSchema` with its one
* spec-derived key `hidden` (a pair born ledgered, not growth on an existing
* entry: both faces read `boolean` until the base was widened, and only the
* DECLARED face moved — see that entry). It stood at 39 / 55 rather than
* 39 / 56 because objectui#6940
* REPAIRED `DataTableSchema.rowActions` (the entry kept its other four keys, so
* the entry count did not move). It was 12 / 17 until
* objectui#6124 added the RUNTIME-SLOT class (28 pairs touched, 35 keys) — see
Expand DownExpand Up@@ -85,7 +90,7 @@
* "no entry in either" population dropped by one to 141 — went to 142 when
* objectui#6576 added two pairs, one of them ledgered, and stands at **143**
* since objectui#7129 retired `DetailViewSectionSchema`'s only ledgered key.
* - 160 − 39 = **121**, the "pairs with no entry" `LedgerMismatch` speaks of.
* - 160 − 40 = **120**, the "pairs with no entry" `LedgerMismatch` speaks of.
*
* ## Two ratchets, because the forward comparison has two halves
*
Expand All@@ -106,7 +111,7 @@
*
* ## KNOWN_DRIFT is a ratchet, not a waiver
*
* 39 of the 160 pairs carry TYPE drift TODAY (measured, not assumed). Each is
* 40 of the 160 pairs carry TYPE drift TODAY (measured, not assumed). Each is
* pinned to its EXACT drifted key set, so the entry fails when new drift appears on
* that mirror AND when the recorded drift is fixed — a stale entry cannot rot
* quietly. Correcting them is not one change: the pairs below split into DISJOINT
Expand DownExpand Up@@ -685,6 +690,34 @@ export type UnmirroredOf< K extends MirrorKey > = UnmirroredDeclaredKeys< (typeo
* new drift on a listed mirror fails, and so does a listed key that has been fixed.
*/
interface KnownDrift {
/**
* SPEC-DERIVED, not a mirroring debt, and NOT closable by editing this entry.
*
* Measured on `@objectstack/spec@17.2.0` by resolving `AppSchema.shape`: the
* spec's `AppSchema` declares `hidden` (`z.boolean().optional()` -- accepts a
* boolean, refuses a string) and declares NEITHER `visible` NOR `disabled`.
* `AppComponentSchema` is `BaseSchema.extend(SpecAppFields.shape).extend(...)`
* and `SpecAppFields` excludes six keys -- `name`, `label`, `description`,
* `navigation`, `areas`, `contextSelectors` -- with `hidden` not among them,
* so on the MIRROR face the spec's boolean lands after the base's and
* overrides it. On the DECLARED face `interface AppComponentSchema extends
* BaseSchema` does not restate the key at all, so it inherits the base.
*
* That is why widening `BaseSchema.hidden` to `boolean | string`
* (objectui#7455, ruled 2026-09-03) moved only the TS side of THIS pair and
* seeded this entry, while the same widening on `visible` (objectui#4581) and
* `disabled` (objectui#4580 ruling Q3-A) moved both sides and seeded nothing.
* The asymmetry is the spec's, one layer under the one #7455 removed.
*
* The two keys collide in NAME and differ in MEANING -- the spec's is an
* app-catalogue flag (does the app show in the switcher), the base's is the
* renderer's hide predicate -- so this is a contract ruling, not a repair.
* objectui#7542 carries it, with the directions measured and none chosen.
* The one direction that reads easy and is probably wrong: dropping `hidden`
* from `SpecAppFields` would make a spec-DERIVED schema accept, by local
* divergence, a value the spec refuses.
*/
'app.zod.ts#AppComponentSchema': 'hidden';
/**
* RUNTIME SLOT (objectui#6124): `calendar-view`'s `pickHostCallbacks` reads
* `onViewChange` off the spread props (function values only) and hands it to
Expand DownExpand Up@@ -1318,7 +1351,7 @@ export type assertionLedgerHalvesAreDisjoint = Expect< Equal< DoubleFiledKey, ne

/**
* Every pair's TYPE drift equals what `KnownDrift` records for it — `never` for the
* 121 pairs with no entry (160 − 39).
* 120 pairs with no entry (160 − 40).
*
* Routed through `ReconcileAgainstLedger` rather than spelling the conditional
* inline. That is a semantics-preserving refactor and nothing else — the type is
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
13 changes: 13 additions & 0 deletions .changeset/hidden-predicate-widen-7455.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
'@object-ui/types': minor
---

**`BaseSchema.hidden` now declares the predicate string the renderer already evaluates** (objectui#7455, maintainer ruling 2026-09-03).

`hidden?: boolean` becomes `hidden?: boolean | string`, and the Zod mirror's `z.boolean()` becomes `z.union([z.boolean(), z.string()])` — matching `visible` (#4581) and `disabled` (#4580 ruling Q3-A) on both faces. `hidden` was the third key on the same evaluated path and the only one still declared boolean-only.

This is a **widening**, not a replacement: every boolean `hidden` keeps parsing and keeps type-checking unchanged, and the renderer's behaviour is untouched by this change — `SchemaRenderer`'s `shouldHide` chain already routed this key through `hasDeclaredPredicate` and evaluated it, which is the evidence the widening rests on. What changes is that authors and their tooling can now write `hidden: "${data.status === 'draft'}"` without casting past the declaration, and the Zod mirror stops refusing it (before this, that value failed `safeParse` with `invalid_type` at path `hidden` while the identical string on `visible` parsed).

`hiddenOn` is unchanged and remains the sibling expression spelling. The CEL envelope object form is still declared on none of `visible` / `hidden` / `disabled`; objectui#7530 rules on all three together.

Per this repository's version-alignment convention, a widening of a published type surface ships as `minor` with the semantics spelled out here rather than as `major` (see AGENTS.md, "版本号策略").
2 changes: 1 addition & 1 deletion content/docs/api/schema-reference.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,7 +69,7 @@ One row per declared member, in declaration order, so the list can be checked ag
| `visible` | `boolean \| string` | Visibility control. Accepts a boolean **or** a predicate expression string — the renderer evaluates this key rather than reading it as a boolean. |
| `visibleWhen` | `string` | Canonical conditional-visibility predicate (ADR-0089); the element is shown when it evaluates truthy. Evaluated **before** `visible` and `visibleOn`, and outranks both. |
| `visibleOn` | `string` | Expression for conditional visibility. **Deprecated** (ADR-0089) — use `visibleWhen`. |
| `hidden` | `boolean` | Inverse of `visible`. Boolean only — unlike `visible`, this key takes no expression. |
| `hidden` | `boolean \| string` | Inverse of `visible` — the node is not rendered. Accepts a boolean **or** a predicate expression string, which the renderer evaluates rather than reading as a boolean; `hiddenOn` remains the sibling spelling. |
| `hiddenOn` | `string` | Expression for conditional hiding. |
| `disabled` | `boolean \| string` | Disabled state. Accepts a boolean **or** a predicate expression string, on the same evaluated path as `visible`. |
| `disabledOn` | `string` | Expression for conditional disabling. |
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,6 +89,7 @@ import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import type { BaseSchema } from '@object-ui/types';
import { SchemaRenderer } from '../SchemaRenderer';
import { SchemaRendererContext } from '../context/SchemaRendererContext';

Expand All@@ -113,6 +114,34 @@ function renderNode(schema: Record<string, unknown>) {
);
}

/**
* The DECLARED path -- no cast at all.
*
* `renderNode` above spreads a `Record<string, unknown>` through `as never`
* because most of this file exercises shapes `BaseSchema` does not declare and
* should not: `null`, `0`, `[]`, `{}`, and the CEL envelope object. Those keep
* the cast.
*
* The STRING form is different since objectui#7455 (ruled 2026-09-03):
* `hidden` is declared `boolean | string`, so an expression-valued `hidden` is
* authorable and the compiler is the right checker for it. Narrowing `hidden`
* back to `boolean` makes the call sites below TS2322 -- and `tsc -p
* tsconfig.test.json` (chained from this package's `type-check` script) is the
* only thing that can see that; vitest cannot, because the annotation is erased
* before a single case runs.
*
* The envelope pin below deliberately stays on `renderNode`: the envelope form
* is declared on NONE of `visible` / `hidden` / `disabled`, and objectui#7530
* rules on all three together.
*/
function renderDeclaredNode(schema: BaseSchema) {
return render(
<SchemaRendererContext.Provider value={{ dataSource: DATA }}>
<SchemaRenderer schema={schema} />
</SchemaRendererContext.Provider>,
);
}

/** Did the node render at all? */
function rendered(): boolean {
return screen.queryByTestId('probe') !== null;
Expand DownExpand Up@@ -177,11 +206,11 @@ describe('SchemaRenderer `hidden` — an empty predicate is not a declared gate
expect(rendered()).toBe(true);
});

it('an expression-valued `hidden` keeps its verdict, both ways', () => {
const { unmount } = renderNode({ hidden: '${data.status === "draft"}' });
it('an expression-valued `hidden` keeps its verdict, both ways -- through the DECLARED path, no cast (objectui#7455)', () => {
const { unmount } = renderDeclaredNode({ type: 'probe-3955', hidden: '${data.status === "draft"}' });
expect(rendered()).toBe(false);
unmount();
renderNode({ hidden: '${data.published}' });
renderDeclaredNode({ type: 'probe-3955', hidden: '${data.published}' });
expect(rendered()).toBe(true);
});

Expand Down
164 changes: 164 additions & 0 deletions packages/types/src/__tests__/base-schema-hidden-predicate.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `BaseSchema.hidden` admits the predicate string the renderer evaluates
* (objectui#7455, maintainer ruling 2026-09-03: option A, widen).
*
* The twin of `base-schema-visible-predicate.test.ts` (#4581) and of
* `disabled-twin-symmetry-7087.test.ts` (#4580 Q3-A). `hidden` was the third
* key on the same evaluated path and the only one still declared boolean-only
* on both faces.
*
* ## The evidence
*
* `SchemaRenderer`'s `shouldHide` chain does not read this key as a boolean:
*
* ```ts
* if (hasDeclaredPredicate(newSchema.hidden)) {
* return evaluateVisibilityPredicate(newSchema.hidden, 'hidden');
* }
* ```
*
* `hasDeclaredPredicate` (`packages/core/src/evaluator/declaredPredicate.ts`)
* is the repo's single shared definition of "declared", asked by all three
* keys, all three `*On` siblings, `ActionRunner` and `ActionEngine`; the
* evaluator underneath is declared
* `(condition: string | boolean | undefined, ...) => boolean`. Predicate
* strings on `hidden` were already SHIPPED and PINNED — see
* `packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx`,
* which drove them through a `Record<string, unknown>` helper because the
* declaration refused them.
*
* ## Measured before the change (red-first, on `origin/main` d04e79a80)
*
* • TS — `base.ts:328` was `hidden?: boolean`.
* • zod — `base.zod.ts:175` was `z.boolean()`, and
* `BaseSchema.safeParse({ type: 'probe', hidden: '${data.status === "draft"}' })`
* returned `success: false`,
* `{ code: 'invalid_type', expected: 'boolean', path: ['hidden'] }`,
* while the identical string on `visible` parsed. So the zod mirror was NOT
* already ahead of TS — both faces refused it.
*
* ## What this file pins, and why in this shape
*
* 1. Type level — `BaseSchema['hidden']` 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 widening that never happened, and `BaseSchema`'s
* `[key: string]: any` index signature means a DELETED member reads `any`,
* which a one-way check also accepts. The overshoot is the live risk here,
* not a hypothetical.
* 2. The three keys are asserted to carry the SAME declared type. The ruling's
* words are "matching `visible` and `disabled` on both faces"; asserting
* `hidden` alone would stay green if a later change narrowed one of the
* other two, which is the asymmetry this card exists to remove.
* 3. Runtime (zod face) — the string form and the boolean form both
* `safeParse` GREEN in full, and a NUMBER is still refused at path
* `hidden`. The refusal is the anti-overshoot guard: `z.any()` would
* satisfy every positive case on its own.
*
* ## Deliberately NOT pinned here: the CEL envelope object
*
* `hasDeclaredPredicate` accepts `{ dialect, source }` on this key, and NO key
* declares it — `visible` and `disabled` are `boolean | string` and under-report
* it too. objectui#7530 rules on all three together (declare on all three, or
* refuse on all three). This file therefore asserts nothing about that shape in
* either direction; pinning the current refusal on `hidden` alone would
* pre-empt that ruling and re-introduce, in the pins, exactly the three-way
* asymmetry the widening just removed.
*
* ADR-0089's carve-out ("the boolean `visible` ... is explicitly out of scope")
* governs `packages/spec`'s keys, not this surface — `BaseSchema` is objectui's
* own declaration. It is evidence of intent about the same concept, which is
* why this was ruled rather than applied mechanically.
*/

import { describe, it, expect } from 'vitest';
import type { BaseSchema } from '../base';
import { BaseSchema as Mirror } from '../zod/base.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 declared type is exactly what the evaluator accepts ─────────────── */

export type assertionHidden = Expect<
Equal< BaseSchema['hidden'], boolean | string | undefined >
>;

/** The two siblings, asserted beside it: all three keys carry one type. */
export type assertionHiddenMatchesVisible = Expect<
Equal< BaseSchema['hidden'], BaseSchema['visible'] >
>;
export type assertionHiddenMatchesDisabled = Expect<
Equal< BaseSchema['hidden'], BaseSchema['disabled'] >
>;

/* ── Authorable fixtures ─────────────────────────────────────────────────── */

/** The capability the renderer implements, now declared. */
export const hiddenPredicateStringIsAuthorable: BaseSchema = {
type: 'test-component',
hidden: 'record.status == "draft"',
};

/** The template-expression spelling the shipped react pins use. */
export const hiddenTemplateExpressionIsAuthorable: BaseSchema = {
type: 'test-component',
hidden: '${data.status === "draft"}',
};

/** The boolean form is untouched — this is a widening, not a replacement. */
export const hiddenBooleanIsStillAuthorable: BaseSchema = {
type: 'test-component',
hidden: true,
};

/* ── Runtime companion (the zod mirror) ──────────────────────────────────── */

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

describe('BaseSchema.hidden (objectui#7455)', () => {
it('type-level: hidden is boolean | string, pinned invariantly against both siblings', () => {
// Erased at runtime; `tsc -p tsconfig.test.json` is the checker, chained
// from this package's `type-check` script. The runtime case exists so a
// green vitest run is not mistaken for the proof.
expect(hiddenPredicateStringIsAuthorable.hidden).toBe('record.status == "draft"');
expect(hiddenBooleanIsStillAuthorable.hidden).toBe(true);
});

it('zod mirror: a predicate string on `hidden` parses in full', () => {
const result = Mirror.safeParse({ type: 'test-component', hidden: PREDICATE });
// Full parse, not just "no unrecognized_keys": this is a judgement about
// the VALUE, so nothing short of a green `safeParse` measures it.
expect(result.success).toBe(true);
});

it('zod mirror: `visible` and `disabled` take the same string — the control', () => {
// If these ever go red, the failure is NOT about `hidden`, and the
// assertion above would have been passing for the wrong reason.
expect(Mirror.safeParse({ type: 'test-component', visible: PREDICATE }).success).toBe(true);
expect(Mirror.safeParse({ type: 'test-component', disabled: PREDICATE }).success).toBe(true);
});

it('zod mirror: the boolean form still parses — a widening, not a replacement', () => {
expect(Mirror.safeParse({ type: 'test-component', hidden: true }).success).toBe(true);
expect(Mirror.safeParse({ type: 'test-component', hidden: false }).success).toBe(true);
});

it('zod mirror: a number is still refused at path `hidden` — the anti-overshoot guard', () => {
// `BaseSchema` is `.passthrough()`, but `hidden` is a DECLARED key, so a
// wrong-typed value is an `invalid_type` error rather than a passthrough.
// Without this case, widening the key to `z.any()` would satisfy every
// positive assertion above.
const result = Mirror.safeParse({ type: 'test-component', hidden: 123 });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.join('.') === 'hidden')).toBe(true);
}
});
});
41 changes: 37 additions & 4 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,12 @@
* already pins equal to `keyof Declared`. Nothing asserts it against a written
* number, so this line is prose and can rot; the pin that cannot is the one
* comparing the two halves to each other.
* - **39 entries** in `KnownDrift`, **55 keys** across them — 56 until objectui#6940
* - **40 entries** in `KnownDrift`, **56 keys** across them — 39 / 55 until
* objectui#7455 SEEDED `app.zod.ts#AppComponentSchema` with its one
* spec-derived key `hidden` (a pair born ledgered, not growth on an existing
* entry: both faces read `boolean` until the base was widened, and only the
* DECLARED face moved — see that entry). It stood at 39 / 55 rather than
* 39 / 56 because objectui#6940
* REPAIRED `DataTableSchema.rowActions` (the entry kept its other four keys, so
* the entry count did not move). It was 12 / 17 until
* objectui#6124 added the RUNTIME-SLOT class (28 pairs touched, 35 keys) — see
Expand DownExpand Up@@ -85,7 +90,7 @@
* "no entry in either" population dropped by one to 141 — went to 142 when
* objectui#6576 added two pairs, one of them ledgered, and stands at **143**
* since objectui#7129 retired `DetailViewSectionSchema`'s only ledgered key.
* - 160 − 39 = **121**, the "pairs with no entry" `LedgerMismatch` speaks of.
* - 160 − 40 = **120**, the "pairs with no entry" `LedgerMismatch` speaks of.
*
* ## Two ratchets, because the forward comparison has two halves
*
Expand All@@ -106,7 +111,7 @@
*
* ## KNOWN_DRIFT is a ratchet, not a waiver
*
* 39 of the 160 pairs carry TYPE drift TODAY (measured, not assumed). Each is
* 40 of the 160 pairs carry TYPE drift TODAY (measured, not assumed). Each is
* pinned to its EXACT drifted key set, so the entry fails when new drift appears on
* that mirror AND when the recorded drift is fixed — a stale entry cannot rot
* quietly. Correcting them is not one change: the pairs below split into DISJOINT
Expand DownExpand Up@@ -685,6 +690,34 @@ export type UnmirroredOf< K extends MirrorKey > = UnmirroredDeclaredKeys< (typeo
* new drift on a listed mirror fails, and so does a listed key that has been fixed.
*/
interface KnownDrift {
/**
* SPEC-DERIVED, not a mirroring debt, and NOT closable by editing this entry.
*
* Measured on `@objectstack/spec@17.2.0` by resolving `AppSchema.shape`: the
* spec's `AppSchema` declares `hidden` (`z.boolean().optional()` -- accepts a
* boolean, refuses a string) and declares NEITHER `visible` NOR `disabled`.
* `AppComponentSchema` is `BaseSchema.extend(SpecAppFields.shape).extend(...)`
* and `SpecAppFields` excludes six keys -- `name`, `label`, `description`,
* `navigation`, `areas`, `contextSelectors` -- with `hidden` not among them,
* so on the MIRROR face the spec's boolean lands after the base's and
* overrides it. On the DECLARED face `interface AppComponentSchema extends
* BaseSchema` does not restate the key at all, so it inherits the base.
*
* That is why widening `BaseSchema.hidden` to `boolean | string`
* (objectui#7455, ruled 2026-09-03) moved only the TS side of THIS pair and
* seeded this entry, while the same widening on `visible` (objectui#4581) and
* `disabled` (objectui#4580 ruling Q3-A) moved both sides and seeded nothing.
* The asymmetry is the spec's, one layer under the one #7455 removed.
*
* The two keys collide in NAME and differ in MEANING -- the spec's is an
* app-catalogue flag (does the app show in the switcher), the base's is the
* renderer's hide predicate -- so this is a contract ruling, not a repair.
* objectui#7542 carries it, with the directions measured and none chosen.
* The one direction that reads easy and is probably wrong: dropping `hidden`
* from `SpecAppFields` would make a spec-DERIVED schema accept, by local
* divergence, a value the spec refuses.
*/
'app.zod.ts#AppComponentSchema': 'hidden';
/**
* RUNTIME SLOT (objectui#6124): `calendar-view`'s `pickHostCallbacks` reads
* `onViewChange` off the spread props (function values only) and hands it to
Expand DownExpand Up@@ -1318,7 +1351,7 @@ export type assertionLedgerHalvesAreDisjoint = Expect< Equal< DoubleFiledKey, ne

/**
* Every pair's TYPE drift equals what `KnownDrift` records for it — `never` for the
* 121 pairs with no entry (160 − 39).
* 120 pairs with no entry (160 − 40).
*
* Routed through `ReconcileAgainstLedger` rather than spelling the conditional
* inline. That is a semantics-preserving refactor and nothing else — the type is
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
13 changes: 13 additions & 0 deletions .changeset/hidden-predicate-widen-7455.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
'@object-ui/types': minor
---

**`BaseSchema.hidden` now declares the predicate string the renderer already evaluates** (objectui#7455, maintainer ruling 2026-09-03).

`hidden?: boolean` becomes `hidden?: boolean | string`, and the Zod mirror's `z.boolean()` becomes `z.union([z.boolean(), z.string()])` — matching `visible` (#4581) and `disabled` (#4580 ruling Q3-A) on both faces. `hidden` was the third key on the same evaluated path and the only one still declared boolean-only.

This is a **widening**, not a replacement: every boolean `hidden` keeps parsing and keeps type-checking unchanged, and the renderer's behaviour is untouched by this change — `SchemaRenderer`'s `shouldHide` chain already routed this key through `hasDeclaredPredicate` and evaluated it, which is the evidence the widening rests on. What changes is that authors and their tooling can now write `hidden: "${data.status === 'draft'}"` without casting past the declaration, and the Zod mirror stops refusing it (before this, that value failed `safeParse` with `invalid_type` at path `hidden` while the identical string on `visible` parsed).

`hiddenOn` is unchanged and remains the sibling expression spelling. The CEL envelope object form is still declared on none of `visible` / `hidden` / `disabled`; objectui#7530 rules on all three together.

Per this repository's version-alignment convention, a widening of a published type surface ships as `minor` with the semantics spelled out here rather than as `major` (see AGENTS.md, "版本号策略").
2 changes: 1 addition & 1 deletion content/docs/api/schema-reference.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,7 +69,7 @@ One row per declared member, in declaration order, so the list can be checked ag
| `visible` | `boolean \| string` | Visibility control. Accepts a boolean **or** a predicate expression string — the renderer evaluates this key rather than reading it as a boolean. |
| `visibleWhen` | `string` | Canonical conditional-visibility predicate (ADR-0089); the element is shown when it evaluates truthy. Evaluated **before** `visible` and `visibleOn`, and outranks both. |
| `visibleOn` | `string` | Expression for conditional visibility. **Deprecated** (ADR-0089) — use `visibleWhen`. |
| `hidden` | `boolean` | Inverse of `visible`. Boolean only — unlike `visible`, this key takes no expression. |
| `hidden` | `boolean \| string` | Inverse of `visible` — the node is not rendered. Accepts a boolean **or** a predicate expression string, which the renderer evaluates rather than reading as a boolean; `hiddenOn` remains the sibling spelling. |
| `hiddenOn` | `string` | Expression for conditional hiding. |
| `disabled` | `boolean \| string` | Disabled state. Accepts a boolean **or** a predicate expression string, on the same evaluated path as `visible`. |
| `disabledOn` | `string` | Expression for conditional disabling. |
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,6 +89,7 @@ import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import type { BaseSchema } from '@object-ui/types';
import { SchemaRenderer } from '../SchemaRenderer';
import { SchemaRendererContext } from '../context/SchemaRendererContext';

Expand All@@ -113,6 +114,34 @@ function renderNode(schema: Record<string, unknown>) {
);
}

/**
* The DECLARED path -- no cast at all.
*
* `renderNode` above spreads a `Record<string, unknown>` through `as never`
* because most of this file exercises shapes `BaseSchema` does not declare and
* should not: `null`, `0`, `[]`, `{}`, and the CEL envelope object. Those keep
* the cast.
*
* The STRING form is different since objectui#7455 (ruled 2026-09-03):
* `hidden` is declared `boolean | string`, so an expression-valued `hidden` is
* authorable and the compiler is the right checker for it. Narrowing `hidden`
* back to `boolean` makes the call sites below TS2322 -- and `tsc -p
* tsconfig.test.json` (chained from this package's `type-check` script) is the
* only thing that can see that; vitest cannot, because the annotation is erased
* before a single case runs.
*
* The envelope pin below deliberately stays on `renderNode`: the envelope form
* is declared on NONE of `visible` / `hidden` / `disabled`, and objectui#7530
* rules on all three together.
*/
function renderDeclaredNode(schema: BaseSchema) {
return render(
<SchemaRendererContext.Provider value={{ dataSource: DATA }}>
<SchemaRenderer schema={schema} />
</SchemaRendererContext.Provider>,
);
}

/** Did the node render at all? */
function rendered(): boolean {
return screen.queryByTestId('probe') !== null;
Expand DownExpand Up@@ -177,11 +206,11 @@ describe('SchemaRenderer `hidden` — an empty predicate is not a declared gate
expect(rendered()).toBe(true);
});

it('an expression-valued `hidden` keeps its verdict, both ways', () => {
const { unmount } = renderNode({ hidden: '${data.status === "draft"}' });
it('an expression-valued `hidden` keeps its verdict, both ways -- through the DECLARED path, no cast (objectui#7455)', () => {
const { unmount } = renderDeclaredNode({ type: 'probe-3955', hidden: '${data.status === "draft"}' });
expect(rendered()).toBe(false);
unmount();
renderNode({ hidden: '${data.published}' });
renderDeclaredNode({ type: 'probe-3955', hidden: '${data.published}' });
expect(rendered()).toBe(true);
});

Expand Down
164 changes: 164 additions & 0 deletions packages/types/src/__tests__/base-schema-hidden-predicate.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `BaseSchema.hidden` admits the predicate string the renderer evaluates
* (objectui#7455, maintainer ruling 2026-09-03: option A, widen).
*
* The twin of `base-schema-visible-predicate.test.ts` (#4581) and of
* `disabled-twin-symmetry-7087.test.ts` (#4580 Q3-A). `hidden` was the third
* key on the same evaluated path and the only one still declared boolean-only
* on both faces.
*
* ## The evidence
*
* `SchemaRenderer`'s `shouldHide` chain does not read this key as a boolean:
*
* ```ts
* if (hasDeclaredPredicate(newSchema.hidden)) {
* return evaluateVisibilityPredicate(newSchema.hidden, 'hidden');
* }
* ```
*
* `hasDeclaredPredicate` (`packages/core/src/evaluator/declaredPredicate.ts`)
* is the repo's single shared definition of "declared", asked by all three
* keys, all three `*On` siblings, `ActionRunner` and `ActionEngine`; the
* evaluator underneath is declared
* `(condition: string | boolean | undefined, ...) => boolean`. Predicate
* strings on `hidden` were already SHIPPED and PINNED — see
* `packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx`,
* which drove them through a `Record<string, unknown>` helper because the
* declaration refused them.
*
* ## Measured before the change (red-first, on `origin/main` d04e79a80)
*
* • TS — `base.ts:328` was `hidden?: boolean`.
* • zod — `base.zod.ts:175` was `z.boolean()`, and
* `BaseSchema.safeParse({ type: 'probe', hidden: '${data.status === "draft"}' })`
* returned `success: false`,
* `{ code: 'invalid_type', expected: 'boolean', path: ['hidden'] }`,
* while the identical string on `visible` parsed. So the zod mirror was NOT
* already ahead of TS — both faces refused it.
*
* ## What this file pins, and why in this shape
*
* 1. Type level — `BaseSchema['hidden']` 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 widening that never happened, and `BaseSchema`'s
* `[key: string]: any` index signature means a DELETED member reads `any`,
* which a one-way check also accepts. The overshoot is the live risk here,
* not a hypothetical.
* 2. The three keys are asserted to carry the SAME declared type. The ruling's
* words are "matching `visible` and `disabled` on both faces"; asserting
* `hidden` alone would stay green if a later change narrowed one of the
* other two, which is the asymmetry this card exists to remove.
* 3. Runtime (zod face) — the string form and the boolean form both
* `safeParse` GREEN in full, and a NUMBER is still refused at path
* `hidden`. The refusal is the anti-overshoot guard: `z.any()` would
* satisfy every positive case on its own.
*
* ## Deliberately NOT pinned here: the CEL envelope object
*
* `hasDeclaredPredicate` accepts `{ dialect, source }` on this key, and NO key
* declares it — `visible` and `disabled` are `boolean | string` and under-report
* it too. objectui#7530 rules on all three together (declare on all three, or
* refuse on all three). This file therefore asserts nothing about that shape in
* either direction; pinning the current refusal on `hidden` alone would
* pre-empt that ruling and re-introduce, in the pins, exactly the three-way
* asymmetry the widening just removed.
*
* ADR-0089's carve-out ("the boolean `visible` ... is explicitly out of scope")
* governs `packages/spec`'s keys, not this surface — `BaseSchema` is objectui's
* own declaration. It is evidence of intent about the same concept, which is
* why this was ruled rather than applied mechanically.
*/

import { describe, it, expect } from 'vitest';
import type { BaseSchema } from '../base';
import { BaseSchema as Mirror } from '../zod/base.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 declared type is exactly what the evaluator accepts ─────────────── */

export type assertionHidden = Expect<
Equal< BaseSchema['hidden'], boolean | string | undefined >
>;

/** The two siblings, asserted beside it: all three keys carry one type. */
export type assertionHiddenMatchesVisible = Expect<
Equal< BaseSchema['hidden'], BaseSchema['visible'] >
>;
export type assertionHiddenMatchesDisabled = Expect<
Equal< BaseSchema['hidden'], BaseSchema['disabled'] >
>;

/* ── Authorable fixtures ─────────────────────────────────────────────────── */

/** The capability the renderer implements, now declared. */
export const hiddenPredicateStringIsAuthorable: BaseSchema = {
type: 'test-component',
hidden: 'record.status == "draft"',
};

/** The template-expression spelling the shipped react pins use. */
export const hiddenTemplateExpressionIsAuthorable: BaseSchema = {
type: 'test-component',
hidden: '${data.status === "draft"}',
};

/** The boolean form is untouched — this is a widening, not a replacement. */
export const hiddenBooleanIsStillAuthorable: BaseSchema = {
type: 'test-component',
hidden: true,
};

/* ── Runtime companion (the zod mirror) ──────────────────────────────────── */

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

describe('BaseSchema.hidden (objectui#7455)', () => {
it('type-level: hidden is boolean | string, pinned invariantly against both siblings', () => {
// Erased at runtime; `tsc -p tsconfig.test.json` is the checker, chained
// from this package's `type-check` script. The runtime case exists so a
// green vitest run is not mistaken for the proof.
expect(hiddenPredicateStringIsAuthorable.hidden).toBe('record.status == "draft"');
expect(hiddenBooleanIsStillAuthorable.hidden).toBe(true);
});

it('zod mirror: a predicate string on `hidden` parses in full', () => {
const result = Mirror.safeParse({ type: 'test-component', hidden: PREDICATE });
// Full parse, not just "no unrecognized_keys": this is a judgement about
// the VALUE, so nothing short of a green `safeParse` measures it.
expect(result.success).toBe(true);
});

it('zod mirror: `visible` and `disabled` take the same string — the control', () => {
// If these ever go red, the failure is NOT about `hidden`, and the
// assertion above would have been passing for the wrong reason.
expect(Mirror.safeParse({ type: 'test-component', visible: PREDICATE }).success).toBe(true);
expect(Mirror.safeParse({ type: 'test-component', disabled: PREDICATE }).success).toBe(true);
});

it('zod mirror: the boolean form still parses — a widening, not a replacement', () => {
expect(Mirror.safeParse({ type: 'test-component', hidden: true }).success).toBe(true);
expect(Mirror.safeParse({ type: 'test-component', hidden: false }).success).toBe(true);
});

it('zod mirror: a number is still refused at path `hidden` — the anti-overshoot guard', () => {
// `BaseSchema` is `.passthrough()`, but `hidden` is a DECLARED key, so a
// wrong-typed value is an `invalid_type` error rather than a passthrough.
// Without this case, widening the key to `z.any()` would satisfy every
// positive assertion above.
const result = Mirror.safeParse({ type: 'test-component', hidden: 123 });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.join('.') === 'hidden')).toBe(true);
}
});
});
41 changes: 37 additions & 4 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,12 @@
* already pins equal to `keyof Declared`. Nothing asserts it against a written
* number, so this line is prose and can rot; the pin that cannot is the one
* comparing the two halves to each other.
* - **39 entries** in `KnownDrift`, **55 keys** across them — 56 until objectui#6940
* - **40 entries** in `KnownDrift`, **56 keys** across them — 39 / 55 until
* objectui#7455 SEEDED `app.zod.ts#AppComponentSchema` with its one
* spec-derived key `hidden` (a pair born ledgered, not growth on an existing
* entry: both faces read `boolean` until the base was widened, and only the
* DECLARED face moved — see that entry). It stood at 39 / 55 rather than
* 39 / 56 because objectui#6940
* REPAIRED `DataTableSchema.rowActions` (the entry kept its other four keys, so
* the entry count did not move). It was 12 / 17 until
* objectui#6124 added the RUNTIME-SLOT class (28 pairs touched, 35 keys) — see
Expand DownExpand Up@@ -85,7 +90,7 @@
* "no entry in either" population dropped by one to 141 — went to 142 when
* objectui#6576 added two pairs, one of them ledgered, and stands at **143**
* since objectui#7129 retired `DetailViewSectionSchema`'s only ledgered key.
* - 160 − 39 = **121**, the "pairs with no entry" `LedgerMismatch` speaks of.
* - 160 − 40 = **120**, the "pairs with no entry" `LedgerMismatch` speaks of.
*
* ## Two ratchets, because the forward comparison has two halves
*
Expand All@@ -106,7 +111,7 @@
*
* ## KNOWN_DRIFT is a ratchet, not a waiver
*
* 39 of the 160 pairs carry TYPE drift TODAY (measured, not assumed). Each is
* 40 of the 160 pairs carry TYPE drift TODAY (measured, not assumed). Each is
* pinned to its EXACT drifted key set, so the entry fails when new drift appears on
* that mirror AND when the recorded drift is fixed — a stale entry cannot rot
* quietly. Correcting them is not one change: the pairs below split into DISJOINT
Expand DownExpand Up@@ -685,6 +690,34 @@ export type UnmirroredOf< K extends MirrorKey > = UnmirroredDeclaredKeys< (typeo
* new drift on a listed mirror fails, and so does a listed key that has been fixed.
*/
interface KnownDrift {
/**
* SPEC-DERIVED, not a mirroring debt, and NOT closable by editing this entry.
*
* Measured on `@objectstack/spec@17.2.0` by resolving `AppSchema.shape`: the
* spec's `AppSchema` declares `hidden` (`z.boolean().optional()` -- accepts a
* boolean, refuses a string) and declares NEITHER `visible` NOR `disabled`.
* `AppComponentSchema` is `BaseSchema.extend(SpecAppFields.shape).extend(...)`
* and `SpecAppFields` excludes six keys -- `name`, `label`, `description`,
* `navigation`, `areas`, `contextSelectors` -- with `hidden` not among them,
* so on the MIRROR face the spec's boolean lands after the base's and
* overrides it. On the DECLARED face `interface AppComponentSchema extends
* BaseSchema` does not restate the key at all, so it inherits the base.
*
* That is why widening `BaseSchema.hidden` to `boolean | string`
* (objectui#7455, ruled 2026-09-03) moved only the TS side of THIS pair and
* seeded this entry, while the same widening on `visible` (objectui#4581) and
* `disabled` (objectui#4580 ruling Q3-A) moved both sides and seeded nothing.
* The asymmetry is the spec's, one layer under the one #7455 removed.
*
* The two keys collide in NAME and differ in MEANING -- the spec's is an
* app-catalogue flag (does the app show in the switcher), the base's is the
* renderer's hide predicate -- so this is a contract ruling, not a repair.
* objectui#7542 carries it, with the directions measured and none chosen.
* The one direction that reads easy and is probably wrong: dropping `hidden`
* from `SpecAppFields` would make a spec-DERIVED schema accept, by local
* divergence, a value the spec refuses.
*/
'app.zod.ts#AppComponentSchema': 'hidden';
/**
* RUNTIME SLOT (objectui#6124): `calendar-view`'s `pickHostCallbacks` reads
* `onViewChange` off the spread props (function values only) and hands it to
Expand DownExpand Up@@ -1318,7 +1351,7 @@ export type assertionLedgerHalvesAreDisjoint = Expect< Equal< DoubleFiledKey, ne

/**
* Every pair's TYPE drift equals what `KnownDrift` records for it — `never` for the
* 121 pairs with no entry (160 − 39).
* 120 pairs with no entry (160 − 40).
*
* Routed through `ReconcileAgainstLedger` rather than spelling the conditional
* inline. That is a semantics-preserving refactor and nothing else — the type is
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
13 changes: 13 additions & 0 deletions .changeset/hidden-predicate-widen-7455.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
'@object-ui/types': minor
---

**`BaseSchema.hidden` now declares the predicate string the renderer already evaluates** (objectui#7455, maintainer ruling 2026-09-03).

`hidden?: boolean` becomes `hidden?: boolean | string`, and the Zod mirror's `z.boolean()` becomes `z.union([z.boolean(), z.string()])` — matching `visible` (#4581) and `disabled` (#4580 ruling Q3-A) on both faces. `hidden` was the third key on the same evaluated path and the only one still declared boolean-only.

This is a **widening**, not a replacement: every boolean `hidden` keeps parsing and keeps type-checking unchanged, and the renderer's behaviour is untouched by this change — `SchemaRenderer`'s `shouldHide` chain already routed this key through `hasDeclaredPredicate` and evaluated it, which is the evidence the widening rests on. What changes is that authors and their tooling can now write `hidden: "${data.status === 'draft'}"` without casting past the declaration, and the Zod mirror stops refusing it (before this, that value failed `safeParse` with `invalid_type` at path `hidden` while the identical string on `visible` parsed).

`hiddenOn` is unchanged and remains the sibling expression spelling. The CEL envelope object form is still declared on none of `visible` / `hidden` / `disabled`; objectui#7530 rules on all three together.

Per this repository's version-alignment convention, a widening of a published type surface ships as `minor` with the semantics spelled out here rather than as `major` (see AGENTS.md, "版本号策略").
2 changes: 1 addition & 1 deletion content/docs/api/schema-reference.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,7 +69,7 @@ One row per declared member, in declaration order, so the list can be checked ag
| `visible` | `boolean \| string` | Visibility control. Accepts a boolean **or** a predicate expression string — the renderer evaluates this key rather than reading it as a boolean. |
| `visibleWhen` | `string` | Canonical conditional-visibility predicate (ADR-0089); the element is shown when it evaluates truthy. Evaluated **before** `visible` and `visibleOn`, and outranks both. |
| `visibleOn` | `string` | Expression for conditional visibility. **Deprecated** (ADR-0089) — use `visibleWhen`. |
| `hidden` | `boolean` | Inverse of `visible`. Boolean only — unlike `visible`, this key takes no expression. |
| `hidden` | `boolean \| string` | Inverse of `visible` — the node is not rendered. Accepts a boolean **or** a predicate expression string, which the renderer evaluates rather than reading as a boolean; `hiddenOn` remains the sibling spelling. |
| `hiddenOn` | `string` | Expression for conditional hiding. |
| `disabled` | `boolean \| string` | Disabled state. Accepts a boolean **or** a predicate expression string, on the same evaluated path as `visible`. |
| `disabledOn` | `string` | Expression for conditional disabling. |
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,6 +89,7 @@ import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import type { BaseSchema } from '@object-ui/types';
import { SchemaRenderer } from '../SchemaRenderer';
import { SchemaRendererContext } from '../context/SchemaRendererContext';

Expand All@@ -113,6 +114,34 @@ function renderNode(schema: Record<string, unknown>) {
);
}

/**
* The DECLARED path -- no cast at all.
*
* `renderNode` above spreads a `Record<string, unknown>` through `as never`
* because most of this file exercises shapes `BaseSchema` does not declare and
* should not: `null`, `0`, `[]`, `{}`, and the CEL envelope object. Those keep
* the cast.
*
* The STRING form is different since objectui#7455 (ruled 2026-09-03):
* `hidden` is declared `boolean | string`, so an expression-valued `hidden` is
* authorable and the compiler is the right checker for it. Narrowing `hidden`
* back to `boolean` makes the call sites below TS2322 -- and `tsc -p
* tsconfig.test.json` (chained from this package's `type-check` script) is the
* only thing that can see that; vitest cannot, because the annotation is erased
* before a single case runs.
*
* The envelope pin below deliberately stays on `renderNode`: the envelope form
* is declared on NONE of `visible` / `hidden` / `disabled`, and objectui#7530
* rules on all three together.
*/
function renderDeclaredNode(schema: BaseSchema) {
return render(
<SchemaRendererContext.Provider value={{ dataSource: DATA }}>
<SchemaRenderer schema={schema} />
</SchemaRendererContext.Provider>,
);
}

/** Did the node render at all? */
function rendered(): boolean {
return screen.queryByTestId('probe') !== null;
Expand DownExpand Up@@ -177,11 +206,11 @@ describe('SchemaRenderer `hidden` — an empty predicate is not a declared gate
expect(rendered()).toBe(true);
});

it('an expression-valued `hidden` keeps its verdict, both ways', () => {
const { unmount } = renderNode({ hidden: '${data.status === "draft"}' });
it('an expression-valued `hidden` keeps its verdict, both ways -- through the DECLARED path, no cast (objectui#7455)', () => {
const { unmount } = renderDeclaredNode({ type: 'probe-3955', hidden: '${data.status === "draft"}' });
expect(rendered()).toBe(false);
unmount();
renderNode({ hidden: '${data.published}' });
renderDeclaredNode({ type: 'probe-3955', hidden: '${data.published}' });
expect(rendered()).toBe(true);
});

Expand Down
164 changes: 164 additions & 0 deletions packages/types/src/__tests__/base-schema-hidden-predicate.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `BaseSchema.hidden` admits the predicate string the renderer evaluates
* (objectui#7455, maintainer ruling 2026-09-03: option A, widen).
*
* The twin of `base-schema-visible-predicate.test.ts` (#4581) and of
* `disabled-twin-symmetry-7087.test.ts` (#4580 Q3-A). `hidden` was the third
* key on the same evaluated path and the only one still declared boolean-only
* on both faces.
*
* ## The evidence
*
* `SchemaRenderer`'s `shouldHide` chain does not read this key as a boolean:
*
* ```ts
* if (hasDeclaredPredicate(newSchema.hidden)) {
* return evaluateVisibilityPredicate(newSchema.hidden, 'hidden');
* }
* ```
*
* `hasDeclaredPredicate` (`packages/core/src/evaluator/declaredPredicate.ts`)
* is the repo's single shared definition of "declared", asked by all three
* keys, all three `*On` siblings, `ActionRunner` and `ActionEngine`; the
* evaluator underneath is declared
* `(condition: string | boolean | undefined, ...) => boolean`. Predicate
* strings on `hidden` were already SHIPPED and PINNED — see
* `packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx`,
* which drove them through a `Record<string, unknown>` helper because the
* declaration refused them.
*
* ## Measured before the change (red-first, on `origin/main` d04e79a80)
*
* • TS — `base.ts:328` was `hidden?: boolean`.
* • zod — `base.zod.ts:175` was `z.boolean()`, and
* `BaseSchema.safeParse({ type: 'probe', hidden: '${data.status === "draft"}' })`
* returned `success: false`,
* `{ code: 'invalid_type', expected: 'boolean', path: ['hidden'] }`,
* while the identical string on `visible` parsed. So the zod mirror was NOT
* already ahead of TS — both faces refused it.
*
* ## What this file pins, and why in this shape
*
* 1. Type level — `BaseSchema['hidden']` 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 widening that never happened, and `BaseSchema`'s
* `[key: string]: any` index signature means a DELETED member reads `any`,
* which a one-way check also accepts. The overshoot is the live risk here,
* not a hypothetical.
* 2. The three keys are asserted to carry the SAME declared type. The ruling's
* words are "matching `visible` and `disabled` on both faces"; asserting
* `hidden` alone would stay green if a later change narrowed one of the
* other two, which is the asymmetry this card exists to remove.
* 3. Runtime (zod face) — the string form and the boolean form both
* `safeParse` GREEN in full, and a NUMBER is still refused at path
* `hidden`. The refusal is the anti-overshoot guard: `z.any()` would
* satisfy every positive case on its own.
*
* ## Deliberately NOT pinned here: the CEL envelope object
*
* `hasDeclaredPredicate` accepts `{ dialect, source }` on this key, and NO key
* declares it — `visible` and `disabled` are `boolean | string` and under-report
* it too. objectui#7530 rules on all three together (declare on all three, or
* refuse on all three). This file therefore asserts nothing about that shape in
* either direction; pinning the current refusal on `hidden` alone would
* pre-empt that ruling and re-introduce, in the pins, exactly the three-way
* asymmetry the widening just removed.
*
* ADR-0089's carve-out ("the boolean `visible` ... is explicitly out of scope")
* governs `packages/spec`'s keys, not this surface — `BaseSchema` is objectui's
* own declaration. It is evidence of intent about the same concept, which is
* why this was ruled rather than applied mechanically.
*/

import { describe, it, expect } from 'vitest';
import type { BaseSchema } from '../base';
import { BaseSchema as Mirror } from '../zod/base.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 declared type is exactly what the evaluator accepts ─────────────── */

export type assertionHidden = Expect<
Equal< BaseSchema['hidden'], boolean | string | undefined >
>;

/** The two siblings, asserted beside it: all three keys carry one type. */
export type assertionHiddenMatchesVisible = Expect<
Equal< BaseSchema['hidden'], BaseSchema['visible'] >
>;
export type assertionHiddenMatchesDisabled = Expect<
Equal< BaseSchema['hidden'], BaseSchema['disabled'] >
>;

/* ── Authorable fixtures ─────────────────────────────────────────────────── */

/** The capability the renderer implements, now declared. */
export const hiddenPredicateStringIsAuthorable: BaseSchema = {
type: 'test-component',
hidden: 'record.status == "draft"',
};

/** The template-expression spelling the shipped react pins use. */
export const hiddenTemplateExpressionIsAuthorable: BaseSchema = {
type: 'test-component',
hidden: '${data.status === "draft"}',
};

/** The boolean form is untouched — this is a widening, not a replacement. */
export const hiddenBooleanIsStillAuthorable: BaseSchema = {
type: 'test-component',
hidden: true,
};

/* ── Runtime companion (the zod mirror) ──────────────────────────────────── */

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

describe('BaseSchema.hidden (objectui#7455)', () => {
it('type-level: hidden is boolean | string, pinned invariantly against both siblings', () => {
// Erased at runtime; `tsc -p tsconfig.test.json` is the checker, chained
// from this package's `type-check` script. The runtime case exists so a
// green vitest run is not mistaken for the proof.
expect(hiddenPredicateStringIsAuthorable.hidden).toBe('record.status == "draft"');
expect(hiddenBooleanIsStillAuthorable.hidden).toBe(true);
});

it('zod mirror: a predicate string on `hidden` parses in full', () => {
const result = Mirror.safeParse({ type: 'test-component', hidden: PREDICATE });
// Full parse, not just "no unrecognized_keys": this is a judgement about
// the VALUE, so nothing short of a green `safeParse` measures it.
expect(result.success).toBe(true);
});

it('zod mirror: `visible` and `disabled` take the same string — the control', () => {
// If these ever go red, the failure is NOT about `hidden`, and the
// assertion above would have been passing for the wrong reason.
expect(Mirror.safeParse({ type: 'test-component', visible: PREDICATE }).success).toBe(true);
expect(Mirror.safeParse({ type: 'test-component', disabled: PREDICATE }).success).toBe(true);
});

it('zod mirror: the boolean form still parses — a widening, not a replacement', () => {
expect(Mirror.safeParse({ type: 'test-component', hidden: true }).success).toBe(true);
expect(Mirror.safeParse({ type: 'test-component', hidden: false }).success).toBe(true);
});

it('zod mirror: a number is still refused at path `hidden` — the anti-overshoot guard', () => {
// `BaseSchema` is `.passthrough()`, but `hidden` is a DECLARED key, so a
// wrong-typed value is an `invalid_type` error rather than a passthrough.
// Without this case, widening the key to `z.any()` would satisfy every
// positive assertion above.
const result = Mirror.safeParse({ type: 'test-component', hidden: 123 });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.join('.') === 'hidden')).toBe(true);
}
});
});
41 changes: 37 additions & 4 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,12 @@
* already pins equal to `keyof Declared`. Nothing asserts it against a written
* number, so this line is prose and can rot; the pin that cannot is the one
* comparing the two halves to each other.
* - **39 entries** in `KnownDrift`, **55 keys** across them — 56 until objectui#6940
* - **40 entries** in `KnownDrift`, **56 keys** across them — 39 / 55 until
* objectui#7455 SEEDED `app.zod.ts#AppComponentSchema` with its one
* spec-derived key `hidden` (a pair born ledgered, not growth on an existing
* entry: both faces read `boolean` until the base was widened, and only the
* DECLARED face moved — see that entry). It stood at 39 / 55 rather than
* 39 / 56 because objectui#6940
* REPAIRED `DataTableSchema.rowActions` (the entry kept its other four keys, so
* the entry count did not move). It was 12 / 17 until
* objectui#6124 added the RUNTIME-SLOT class (28 pairs touched, 35 keys) — see
Expand DownExpand Up@@ -85,7 +90,7 @@
* "no entry in either" population dropped by one to 141 — went to 142 when
* objectui#6576 added two pairs, one of them ledgered, and stands at **143**
* since objectui#7129 retired `DetailViewSectionSchema`'s only ledgered key.
* - 160 − 39 = **121**, the "pairs with no entry" `LedgerMismatch` speaks of.
* - 160 − 40 = **120**, the "pairs with no entry" `LedgerMismatch` speaks of.
*
* ## Two ratchets, because the forward comparison has two halves
*
Expand All@@ -106,7 +111,7 @@
*
* ## KNOWN_DRIFT is a ratchet, not a waiver
*
* 39 of the 160 pairs carry TYPE drift TODAY (measured, not assumed). Each is
* 40 of the 160 pairs carry TYPE drift TODAY (measured, not assumed). Each is
* pinned to its EXACT drifted key set, so the entry fails when new drift appears on
* that mirror AND when the recorded drift is fixed — a stale entry cannot rot
* quietly. Correcting them is not one change: the pairs below split into DISJOINT
Expand DownExpand Up@@ -685,6 +690,34 @@ export type UnmirroredOf< K extends MirrorKey > = UnmirroredDeclaredKeys< (typeo
* new drift on a listed mirror fails, and so does a listed key that has been fixed.
*/
interface KnownDrift {
/**
* SPEC-DERIVED, not a mirroring debt, and NOT closable by editing this entry.
*
* Measured on `@objectstack/spec@17.2.0` by resolving `AppSchema.shape`: the
* spec's `AppSchema` declares `hidden` (`z.boolean().optional()` -- accepts a
* boolean, refuses a string) and declares NEITHER `visible` NOR `disabled`.
* `AppComponentSchema` is `BaseSchema.extend(SpecAppFields.shape).extend(...)`
* and `SpecAppFields` excludes six keys -- `name`, `label`, `description`,
* `navigation`, `areas`, `contextSelectors` -- with `hidden` not among them,
* so on the MIRROR face the spec's boolean lands after the base's and
* overrides it. On the DECLARED face `interface AppComponentSchema extends
* BaseSchema` does not restate the key at all, so it inherits the base.
*
* That is why widening `BaseSchema.hidden` to `boolean | string`
* (objectui#7455, ruled 2026-09-03) moved only the TS side of THIS pair and
* seeded this entry, while the same widening on `visible` (objectui#4581) and
* `disabled` (objectui#4580 ruling Q3-A) moved both sides and seeded nothing.
* The asymmetry is the spec's, one layer under the one #7455 removed.
*
* The two keys collide in NAME and differ in MEANING -- the spec's is an
* app-catalogue flag (does the app show in the switcher), the base's is the
* renderer's hide predicate -- so this is a contract ruling, not a repair.
* objectui#7542 carries it, with the directions measured and none chosen.
* The one direction that reads easy and is probably wrong: dropping `hidden`
* from `SpecAppFields` would make a spec-DERIVED schema accept, by local
* divergence, a value the spec refuses.
*/
'app.zod.ts#AppComponentSchema': 'hidden';
/**
* RUNTIME SLOT (objectui#6124): `calendar-view`'s `pickHostCallbacks` reads
* `onViewChange` off the spread props (function values only) and hands it to
Expand DownExpand Up@@ -1318,7 +1351,7 @@ export type assertionLedgerHalvesAreDisjoint = Expect< Equal< DoubleFiledKey, ne

/**
* Every pair's TYPE drift equals what `KnownDrift` records for it — `never` for the
* 121 pairs with no entry (160 − 39).
* 120 pairs with no entry (160 − 40).
*
* Routed through `ReconcileAgainstLedger` rather than spelling the conditional
* inline. That is a semantics-preserving refactor and nothing else — the type is
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
13 changes: 13 additions & 0 deletions .changeset/hidden-predicate-widen-7455.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
'@object-ui/types': minor
---

**`BaseSchema.hidden` now declares the predicate string the renderer already evaluates** (objectui#7455, maintainer ruling 2026-09-03).

`hidden?: boolean` becomes `hidden?: boolean | string`, and the Zod mirror's `z.boolean()` becomes `z.union([z.boolean(), z.string()])` — matching `visible` (#4581) and `disabled` (#4580 ruling Q3-A) on both faces. `hidden` was the third key on the same evaluated path and the only one still declared boolean-only.

This is a **widening**, not a replacement: every boolean `hidden` keeps parsing and keeps type-checking unchanged, and the renderer's behaviour is untouched by this change — `SchemaRenderer`'s `shouldHide` chain already routed this key through `hasDeclaredPredicate` and evaluated it, which is the evidence the widening rests on. What changes is that authors and their tooling can now write `hidden: "${data.status === 'draft'}"` without casting past the declaration, and the Zod mirror stops refusing it (before this, that value failed `safeParse` with `invalid_type` at path `hidden` while the identical string on `visible` parsed).

`hiddenOn` is unchanged and remains the sibling expression spelling. The CEL envelope object form is still declared on none of `visible` / `hidden` / `disabled`; objectui#7530 rules on all three together.

Per this repository's version-alignment convention, a widening of a published type surface ships as `minor` with the semantics spelled out here rather than as `major` (see AGENTS.md, "版本号策略").
2 changes: 1 addition & 1 deletion content/docs/api/schema-reference.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,7 +69,7 @@ One row per declared member, in declaration order, so the list can be checked ag
| `visible` | `boolean \| string` | Visibility control. Accepts a boolean **or** a predicate expression string — the renderer evaluates this key rather than reading it as a boolean. |
| `visibleWhen` | `string` | Canonical conditional-visibility predicate (ADR-0089); the element is shown when it evaluates truthy. Evaluated **before** `visible` and `visibleOn`, and outranks both. |
| `visibleOn` | `string` | Expression for conditional visibility. **Deprecated** (ADR-0089) — use `visibleWhen`. |
| `hidden` | `boolean` | Inverse of `visible`. Boolean only — unlike `visible`, this key takes no expression. |
| `hidden` | `boolean \| string` | Inverse of `visible` — the node is not rendered. Accepts a boolean **or** a predicate expression string, which the renderer evaluates rather than reading as a boolean; `hiddenOn` remains the sibling spelling. |
| `hiddenOn` | `string` | Expression for conditional hiding. |
| `disabled` | `boolean \| string` | Disabled state. Accepts a boolean **or** a predicate expression string, on the same evaluated path as `visible`. |
| `disabledOn` | `string` | Expression for conditional disabling. |
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,6 +89,7 @@ import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import type { BaseSchema } from '@object-ui/types';
import { SchemaRenderer } from '../SchemaRenderer';
import { SchemaRendererContext } from '../context/SchemaRendererContext';

Expand All@@ -113,6 +114,34 @@ function renderNode(schema: Record<string, unknown>) {
);
}

/**
* The DECLARED path -- no cast at all.
*
* `renderNode` above spreads a `Record<string, unknown>` through `as never`
* because most of this file exercises shapes `BaseSchema` does not declare and
* should not: `null`, `0`, `[]`, `{}`, and the CEL envelope object. Those keep
* the cast.
*
* The STRING form is different since objectui#7455 (ruled 2026-09-03):
* `hidden` is declared `boolean | string`, so an expression-valued `hidden` is
* authorable and the compiler is the right checker for it. Narrowing `hidden`
* back to `boolean` makes the call sites below TS2322 -- and `tsc -p
* tsconfig.test.json` (chained from this package's `type-check` script) is the
* only thing that can see that; vitest cannot, because the annotation is erased
* before a single case runs.
*
* The envelope pin below deliberately stays on `renderNode`: the envelope form
* is declared on NONE of `visible` / `hidden` / `disabled`, and objectui#7530
* rules on all three together.
*/
function renderDeclaredNode(schema: BaseSchema) {
return render(
<SchemaRendererContext.Provider value={{ dataSource: DATA }}>
<SchemaRenderer schema={schema} />
</SchemaRendererContext.Provider>,
);
}

/** Did the node render at all? */
function rendered(): boolean {
return screen.queryByTestId('probe') !== null;
Expand DownExpand Up@@ -177,11 +206,11 @@ describe('SchemaRenderer `hidden` — an empty predicate is not a declared gate
expect(rendered()).toBe(true);
});

it('an expression-valued `hidden` keeps its verdict, both ways', () => {
const { unmount } = renderNode({ hidden: '${data.status === "draft"}' });
it('an expression-valued `hidden` keeps its verdict, both ways -- through the DECLARED path, no cast (objectui#7455)', () => {
const { unmount } = renderDeclaredNode({ type: 'probe-3955', hidden: '${data.status === "draft"}' });
expect(rendered()).toBe(false);
unmount();
renderNode({ hidden: '${data.published}' });
renderDeclaredNode({ type: 'probe-3955', hidden: '${data.published}' });
expect(rendered()).toBe(true);
});

Expand Down
164 changes: 164 additions & 0 deletions packages/types/src/__tests__/base-schema-hidden-predicate.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `BaseSchema.hidden` admits the predicate string the renderer evaluates
* (objectui#7455, maintainer ruling 2026-09-03: option A, widen).
*
* The twin of `base-schema-visible-predicate.test.ts` (#4581) and of
* `disabled-twin-symmetry-7087.test.ts` (#4580 Q3-A). `hidden` was the third
* key on the same evaluated path and the only one still declared boolean-only
* on both faces.
*
* ## The evidence
*
* `SchemaRenderer`'s `shouldHide` chain does not read this key as a boolean:
*
* ```ts
* if (hasDeclaredPredicate(newSchema.hidden)) {
* return evaluateVisibilityPredicate(newSchema.hidden, 'hidden');
* }
* ```
*
* `hasDeclaredPredicate` (`packages/core/src/evaluator/declaredPredicate.ts`)
* is the repo's single shared definition of "declared", asked by all three
* keys, all three `*On` siblings, `ActionRunner` and `ActionEngine`; the
* evaluator underneath is declared
* `(condition: string | boolean | undefined, ...) => boolean`. Predicate
* strings on `hidden` were already SHIPPED and PINNED — see
* `packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx`,
* which drove them through a `Record<string, unknown>` helper because the
* declaration refused them.
*
* ## Measured before the change (red-first, on `origin/main` d04e79a80)
*
* • TS — `base.ts:328` was `hidden?: boolean`.
* • zod — `base.zod.ts:175` was `z.boolean()`, and
* `BaseSchema.safeParse({ type: 'probe', hidden: '${data.status === "draft"}' })`
* returned `success: false`,
* `{ code: 'invalid_type', expected: 'boolean', path: ['hidden'] }`,
* while the identical string on `visible` parsed. So the zod mirror was NOT
* already ahead of TS — both faces refused it.
*
* ## What this file pins, and why in this shape
*
* 1. Type level — `BaseSchema['hidden']` 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 widening that never happened, and `BaseSchema`'s
* `[key: string]: any` index signature means a DELETED member reads `any`,
* which a one-way check also accepts. The overshoot is the live risk here,
* not a hypothetical.
* 2. The three keys are asserted to carry the SAME declared type. The ruling's
* words are "matching `visible` and `disabled` on both faces"; asserting
* `hidden` alone would stay green if a later change narrowed one of the
* other two, which is the asymmetry this card exists to remove.
* 3. Runtime (zod face) — the string form and the boolean form both
* `safeParse` GREEN in full, and a NUMBER is still refused at path
* `hidden`. The refusal is the anti-overshoot guard: `z.any()` would
* satisfy every positive case on its own.
*
* ## Deliberately NOT pinned here: the CEL envelope object
*
* `hasDeclaredPredicate` accepts `{ dialect, source }` on this key, and NO key
* declares it — `visible` and `disabled` are `boolean | string` and under-report
* it too. objectui#7530 rules on all three together (declare on all three, or
* refuse on all three). This file therefore asserts nothing about that shape in
* either direction; pinning the current refusal on `hidden` alone would
* pre-empt that ruling and re-introduce, in the pins, exactly the three-way
* asymmetry the widening just removed.
*
* ADR-0089's carve-out ("the boolean `visible` ... is explicitly out of scope")
* governs `packages/spec`'s keys, not this surface — `BaseSchema` is objectui's
* own declaration. It is evidence of intent about the same concept, which is
* why this was ruled rather than applied mechanically.
*/

import { describe, it, expect } from 'vitest';
import type { BaseSchema } from '../base';
import { BaseSchema as Mirror } from '../zod/base.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 declared type is exactly what the evaluator accepts ─────────────── */

export type assertionHidden = Expect<
Equal< BaseSchema['hidden'], boolean | string | undefined >
>;

/** The two siblings, asserted beside it: all three keys carry one type. */
export type assertionHiddenMatchesVisible = Expect<
Equal< BaseSchema['hidden'], BaseSchema['visible'] >
>;
export type assertionHiddenMatchesDisabled = Expect<
Equal< BaseSchema['hidden'], BaseSchema['disabled'] >
>;

/* ── Authorable fixtures ─────────────────────────────────────────────────── */

/** The capability the renderer implements, now declared. */
export const hiddenPredicateStringIsAuthorable: BaseSchema = {
type: 'test-component',
hidden: 'record.status == "draft"',
};

/** The template-expression spelling the shipped react pins use. */
export const hiddenTemplateExpressionIsAuthorable: BaseSchema = {
type: 'test-component',
hidden: '${data.status === "draft"}',
};

/** The boolean form is untouched — this is a widening, not a replacement. */
export const hiddenBooleanIsStillAuthorable: BaseSchema = {
type: 'test-component',
hidden: true,
};

/* ── Runtime companion (the zod mirror) ──────────────────────────────────── */

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

describe('BaseSchema.hidden (objectui#7455)', () => {
it('type-level: hidden is boolean | string, pinned invariantly against both siblings', () => {
// Erased at runtime; `tsc -p tsconfig.test.json` is the checker, chained
// from this package's `type-check` script. The runtime case exists so a
// green vitest run is not mistaken for the proof.
expect(hiddenPredicateStringIsAuthorable.hidden).toBe('record.status == "draft"');
expect(hiddenBooleanIsStillAuthorable.hidden).toBe(true);
});

it('zod mirror: a predicate string on `hidden` parses in full', () => {
const result = Mirror.safeParse({ type: 'test-component', hidden: PREDICATE });
// Full parse, not just "no unrecognized_keys": this is a judgement about
// the VALUE, so nothing short of a green `safeParse` measures it.
expect(result.success).toBe(true);
});

it('zod mirror: `visible` and `disabled` take the same string — the control', () => {
// If these ever go red, the failure is NOT about `hidden`, and the
// assertion above would have been passing for the wrong reason.
expect(Mirror.safeParse({ type: 'test-component', visible: PREDICATE }).success).toBe(true);
expect(Mirror.safeParse({ type: 'test-component', disabled: PREDICATE }).success).toBe(true);
});

it('zod mirror: the boolean form still parses — a widening, not a replacement', () => {
expect(Mirror.safeParse({ type: 'test-component', hidden: true }).success).toBe(true);
expect(Mirror.safeParse({ type: 'test-component', hidden: false }).success).toBe(true);
});

it('zod mirror: a number is still refused at path `hidden` — the anti-overshoot guard', () => {
// `BaseSchema` is `.passthrough()`, but `hidden` is a DECLARED key, so a
// wrong-typed value is an `invalid_type` error rather than a passthrough.
// Without this case, widening the key to `z.any()` would satisfy every
// positive assertion above.
const result = Mirror.safeParse({ type: 'test-component', hidden: 123 });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.join('.') === 'hidden')).toBe(true);
}
});
});
41 changes: 37 additions & 4 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,12 @@
* already pins equal to `keyof Declared`. Nothing asserts it against a written
* number, so this line is prose and can rot; the pin that cannot is the one
* comparing the two halves to each other.
* - **39 entries** in `KnownDrift`, **55 keys** across them — 56 until objectui#6940
* - **40 entries** in `KnownDrift`, **56 keys** across them — 39 / 55 until
* objectui#7455 SEEDED `app.zod.ts#AppComponentSchema` with its one
* spec-derived key `hidden` (a pair born ledgered, not growth on an existing
* entry: both faces read `boolean` until the base was widened, and only the
* DECLARED face moved — see that entry). It stood at 39 / 55 rather than
* 39 / 56 because objectui#6940
* REPAIRED `DataTableSchema.rowActions` (the entry kept its other four keys, so
* the entry count did not move). It was 12 / 17 until
* objectui#6124 added the RUNTIME-SLOT class (28 pairs touched, 35 keys) — see
Expand DownExpand Up@@ -85,7 +90,7 @@
* "no entry in either" population dropped by one to 141 — went to 142 when
* objectui#6576 added two pairs, one of them ledgered, and stands at **143**
* since objectui#7129 retired `DetailViewSectionSchema`'s only ledgered key.
* - 160 − 39 = **121**, the "pairs with no entry" `LedgerMismatch` speaks of.
* - 160 − 40 = **120**, the "pairs with no entry" `LedgerMismatch` speaks of.
*
* ## Two ratchets, because the forward comparison has two halves
*
Expand All@@ -106,7 +111,7 @@
*
* ## KNOWN_DRIFT is a ratchet, not a waiver
*
* 39 of the 160 pairs carry TYPE drift TODAY (measured, not assumed). Each is
* 40 of the 160 pairs carry TYPE drift TODAY (measured, not assumed). Each is
* pinned to its EXACT drifted key set, so the entry fails when new drift appears on
* that mirror AND when the recorded drift is fixed — a stale entry cannot rot
* quietly. Correcting them is not one change: the pairs below split into DISJOINT
Expand DownExpand Up@@ -685,6 +690,34 @@ export type UnmirroredOf< K extends MirrorKey > = UnmirroredDeclaredKeys< (typeo
* new drift on a listed mirror fails, and so does a listed key that has been fixed.
*/
interface KnownDrift {
/**
* SPEC-DERIVED, not a mirroring debt, and NOT closable by editing this entry.
*
* Measured on `@objectstack/spec@17.2.0` by resolving `AppSchema.shape`: the
* spec's `AppSchema` declares `hidden` (`z.boolean().optional()` -- accepts a
* boolean, refuses a string) and declares NEITHER `visible` NOR `disabled`.
* `AppComponentSchema` is `BaseSchema.extend(SpecAppFields.shape).extend(...)`
* and `SpecAppFields` excludes six keys -- `name`, `label`, `description`,
* `navigation`, `areas`, `contextSelectors` -- with `hidden` not among them,
* so on the MIRROR face the spec's boolean lands after the base's and
* overrides it. On the DECLARED face `interface AppComponentSchema extends
* BaseSchema` does not restate the key at all, so it inherits the base.
*
* That is why widening `BaseSchema.hidden` to `boolean | string`
* (objectui#7455, ruled 2026-09-03) moved only the TS side of THIS pair and
* seeded this entry, while the same widening on `visible` (objectui#4581) and
* `disabled` (objectui#4580 ruling Q3-A) moved both sides and seeded nothing.
* The asymmetry is the spec's, one layer under the one #7455 removed.
*
* The two keys collide in NAME and differ in MEANING -- the spec's is an
* app-catalogue flag (does the app show in the switcher), the base's is the
* renderer's hide predicate -- so this is a contract ruling, not a repair.
* objectui#7542 carries it, with the directions measured and none chosen.
* The one direction that reads easy and is probably wrong: dropping `hidden`
* from `SpecAppFields` would make a spec-DERIVED schema accept, by local
* divergence, a value the spec refuses.
*/
'app.zod.ts#AppComponentSchema': 'hidden';
/**
* RUNTIME SLOT (objectui#6124): `calendar-view`'s `pickHostCallbacks` reads
* `onViewChange` off the spread props (function values only) and hands it to
Expand DownExpand Up@@ -1318,7 +1351,7 @@ export type assertionLedgerHalvesAreDisjoint = Expect< Equal< DoubleFiledKey, ne

/**
* Every pair's TYPE drift equals what `KnownDrift` records for it — `never` for the
* 121 pairs with no entry (160 − 39).
* 120 pairs with no entry (160 − 40).
*
* Routed through `ReconcileAgainstLedger` rather than spelling the conditional
* inline. That is a semantics-preserving refactor and nothing else — the type is
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
13 changes: 13 additions & 0 deletions .changeset/hidden-predicate-widen-7455.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
'@object-ui/types': minor
---

**`BaseSchema.hidden` now declares the predicate string the renderer already evaluates** (objectui#7455, maintainer ruling 2026-09-03).

`hidden?: boolean` becomes `hidden?: boolean | string`, and the Zod mirror's `z.boolean()` becomes `z.union([z.boolean(), z.string()])` — matching `visible` (#4581) and `disabled` (#4580 ruling Q3-A) on both faces. `hidden` was the third key on the same evaluated path and the only one still declared boolean-only.

This is a **widening**, not a replacement: every boolean `hidden` keeps parsing and keeps type-checking unchanged, and the renderer's behaviour is untouched by this change — `SchemaRenderer`'s `shouldHide` chain already routed this key through `hasDeclaredPredicate` and evaluated it, which is the evidence the widening rests on. What changes is that authors and their tooling can now write `hidden: "${data.status === 'draft'}"` without casting past the declaration, and the Zod mirror stops refusing it (before this, that value failed `safeParse` with `invalid_type` at path `hidden` while the identical string on `visible` parsed).

`hiddenOn` is unchanged and remains the sibling expression spelling. The CEL envelope object form is still declared on none of `visible` / `hidden` / `disabled`; objectui#7530 rules on all three together.

Per this repository's version-alignment convention, a widening of a published type surface ships as `minor` with the semantics spelled out here rather than as `major` (see AGENTS.md, "版本号策略").
2 changes: 1 addition & 1 deletion content/docs/api/schema-reference.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,7 +69,7 @@ One row per declared member, in declaration order, so the list can be checked ag
| `visible` | `boolean \| string` | Visibility control. Accepts a boolean **or** a predicate expression string — the renderer evaluates this key rather than reading it as a boolean. |
| `visibleWhen` | `string` | Canonical conditional-visibility predicate (ADR-0089); the element is shown when it evaluates truthy. Evaluated **before** `visible` and `visibleOn`, and outranks both. |
| `visibleOn` | `string` | Expression for conditional visibility. **Deprecated** (ADR-0089) — use `visibleWhen`. |
| `hidden` | `boolean` | Inverse of `visible`. Boolean only — unlike `visible`, this key takes no expression. |
| `hidden` | `boolean \| string` | Inverse of `visible` — the node is not rendered. Accepts a boolean **or** a predicate expression string, which the renderer evaluates rather than reading as a boolean; `hiddenOn` remains the sibling spelling. |
| `hiddenOn` | `string` | Expression for conditional hiding. |
| `disabled` | `boolean \| string` | Disabled state. Accepts a boolean **or** a predicate expression string, on the same evaluated path as `visible`. |
| `disabledOn` | `string` | Expression for conditional disabling. |
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,6 +89,7 @@ import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import type { BaseSchema } from '@object-ui/types';
import { SchemaRenderer } from '../SchemaRenderer';
import { SchemaRendererContext } from '../context/SchemaRendererContext';

Expand All@@ -113,6 +114,34 @@ function renderNode(schema: Record<string, unknown>) {
);
}

/**
* The DECLARED path -- no cast at all.
*
* `renderNode` above spreads a `Record<string, unknown>` through `as never`
* because most of this file exercises shapes `BaseSchema` does not declare and
* should not: `null`, `0`, `[]`, `{}`, and the CEL envelope object. Those keep
* the cast.
*
* The STRING form is different since objectui#7455 (ruled 2026-09-03):
* `hidden` is declared `boolean | string`, so an expression-valued `hidden` is
* authorable and the compiler is the right checker for it. Narrowing `hidden`
* back to `boolean` makes the call sites below TS2322 -- and `tsc -p
* tsconfig.test.json` (chained from this package's `type-check` script) is the
* only thing that can see that; vitest cannot, because the annotation is erased
* before a single case runs.
*
* The envelope pin below deliberately stays on `renderNode`: the envelope form
* is declared on NONE of `visible` / `hidden` / `disabled`, and objectui#7530
* rules on all three together.
*/
function renderDeclaredNode(schema: BaseSchema) {
return render(
<SchemaRendererContext.Provider value={{ dataSource: DATA }}>
<SchemaRenderer schema={schema} />
</SchemaRendererContext.Provider>,
);
}

/** Did the node render at all? */
function rendered(): boolean {
return screen.queryByTestId('probe') !== null;
Expand DownExpand Up@@ -177,11 +206,11 @@ describe('SchemaRenderer `hidden` — an empty predicate is not a declared gate
expect(rendered()).toBe(true);
});

it('an expression-valued `hidden` keeps its verdict, both ways', () => {
const { unmount } = renderNode({ hidden: '${data.status === "draft"}' });
it('an expression-valued `hidden` keeps its verdict, both ways -- through the DECLARED path, no cast (objectui#7455)', () => {
const { unmount } = renderDeclaredNode({ type: 'probe-3955', hidden: '${data.status === "draft"}' });
expect(rendered()).toBe(false);
unmount();
renderNode({ hidden: '${data.published}' });
renderDeclaredNode({ type: 'probe-3955', hidden: '${data.published}' });
expect(rendered()).toBe(true);
});

Expand Down
164 changes: 164 additions & 0 deletions packages/types/src/__tests__/base-schema-hidden-predicate.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `BaseSchema.hidden` admits the predicate string the renderer evaluates
* (objectui#7455, maintainer ruling 2026-09-03: option A, widen).
*
* The twin of `base-schema-visible-predicate.test.ts` (#4581) and of
* `disabled-twin-symmetry-7087.test.ts` (#4580 Q3-A). `hidden` was the third
* key on the same evaluated path and the only one still declared boolean-only
* on both faces.
*
* ## The evidence
*
* `SchemaRenderer`'s `shouldHide` chain does not read this key as a boolean:
*
* ```ts
* if (hasDeclaredPredicate(newSchema.hidden)) {
* return evaluateVisibilityPredicate(newSchema.hidden, 'hidden');
* }
* ```
*
* `hasDeclaredPredicate` (`packages/core/src/evaluator/declaredPredicate.ts`)
* is the repo's single shared definition of "declared", asked by all three
* keys, all three `*On` siblings, `ActionRunner` and `ActionEngine`; the
* evaluator underneath is declared
* `(condition: string | boolean | undefined, ...) => boolean`. Predicate
* strings on `hidden` were already SHIPPED and PINNED — see
* `packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx`,
* which drove them through a `Record<string, unknown>` helper because the
* declaration refused them.
*
* ## Measured before the change (red-first, on `origin/main` d04e79a80)
*
* • TS — `base.ts:328` was `hidden?: boolean`.
* • zod — `base.zod.ts:175` was `z.boolean()`, and
* `BaseSchema.safeParse({ type: 'probe', hidden: '${data.status === "draft"}' })`
* returned `success: false`,
* `{ code: 'invalid_type', expected: 'boolean', path: ['hidden'] }`,
* while the identical string on `visible` parsed. So the zod mirror was NOT
* already ahead of TS — both faces refused it.
*
* ## What this file pins, and why in this shape
*
* 1. Type level — `BaseSchema['hidden']` 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 widening that never happened, and `BaseSchema`'s
* `[key: string]: any` index signature means a DELETED member reads `any`,
* which a one-way check also accepts. The overshoot is the live risk here,
* not a hypothetical.
* 2. The three keys are asserted to carry the SAME declared type. The ruling's
* words are "matching `visible` and `disabled` on both faces"; asserting
* `hidden` alone would stay green if a later change narrowed one of the
* other two, which is the asymmetry this card exists to remove.
* 3. Runtime (zod face) — the string form and the boolean form both
* `safeParse` GREEN in full, and a NUMBER is still refused at path
* `hidden`. The refusal is the anti-overshoot guard: `z.any()` would
* satisfy every positive case on its own.
*
* ## Deliberately NOT pinned here: the CEL envelope object
*
* `hasDeclaredPredicate` accepts `{ dialect, source }` on this key, and NO key
* declares it — `visible` and `disabled` are `boolean | string` and under-report
* it too. objectui#7530 rules on all three together (declare on all three, or
* refuse on all three). This file therefore asserts nothing about that shape in
* either direction; pinning the current refusal on `hidden` alone would
* pre-empt that ruling and re-introduce, in the pins, exactly the three-way
* asymmetry the widening just removed.
*
* ADR-0089's carve-out ("the boolean `visible` ... is explicitly out of scope")
* governs `packages/spec`'s keys, not this surface — `BaseSchema` is objectui's
* own declaration. It is evidence of intent about the same concept, which is
* why this was ruled rather than applied mechanically.
*/

import { describe, it, expect } from 'vitest';
import type { BaseSchema } from '../base';
import { BaseSchema as Mirror } from '../zod/base.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 declared type is exactly what the evaluator accepts ─────────────── */

export type assertionHidden = Expect<
Equal< BaseSchema['hidden'], boolean | string | undefined >
>;

/** The two siblings, asserted beside it: all three keys carry one type. */
export type assertionHiddenMatchesVisible = Expect<
Equal< BaseSchema['hidden'], BaseSchema['visible'] >
>;
export type assertionHiddenMatchesDisabled = Expect<
Equal< BaseSchema['hidden'], BaseSchema['disabled'] >
>;

/* ── Authorable fixtures ─────────────────────────────────────────────────── */

/** The capability the renderer implements, now declared. */
export const hiddenPredicateStringIsAuthorable: BaseSchema = {
type: 'test-component',
hidden: 'record.status == "draft"',
};

/** The template-expression spelling the shipped react pins use. */
export const hiddenTemplateExpressionIsAuthorable: BaseSchema = {
type: 'test-component',
hidden: '${data.status === "draft"}',
};

/** The boolean form is untouched — this is a widening, not a replacement. */
export const hiddenBooleanIsStillAuthorable: BaseSchema = {
type: 'test-component',
hidden: true,
};

/* ── Runtime companion (the zod mirror) ──────────────────────────────────── */

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

describe('BaseSchema.hidden (objectui#7455)', () => {
it('type-level: hidden is boolean | string, pinned invariantly against both siblings', () => {
// Erased at runtime; `tsc -p tsconfig.test.json` is the checker, chained
// from this package's `type-check` script. The runtime case exists so a
// green vitest run is not mistaken for the proof.
expect(hiddenPredicateStringIsAuthorable.hidden).toBe('record.status == "draft"');
expect(hiddenBooleanIsStillAuthorable.hidden).toBe(true);
});

it('zod mirror: a predicate string on `hidden` parses in full', () => {
const result = Mirror.safeParse({ type: 'test-component', hidden: PREDICATE });
// Full parse, not just "no unrecognized_keys": this is a judgement about
// the VALUE, so nothing short of a green `safeParse` measures it.
expect(result.success).toBe(true);
});

it('zod mirror: `visible` and `disabled` take the same string — the control', () => {
// If these ever go red, the failure is NOT about `hidden`, and the
// assertion above would have been passing for the wrong reason.
expect(Mirror.safeParse({ type: 'test-component', visible: PREDICATE }).success).toBe(true);
expect(Mirror.safeParse({ type: 'test-component', disabled: PREDICATE }).success).toBe(true);
});

it('zod mirror: the boolean form still parses — a widening, not a replacement', () => {
expect(Mirror.safeParse({ type: 'test-component', hidden: true }).success).toBe(true);
expect(Mirror.safeParse({ type: 'test-component', hidden: false }).success).toBe(true);
});

it('zod mirror: a number is still refused at path `hidden` — the anti-overshoot guard', () => {
// `BaseSchema` is `.passthrough()`, but `hidden` is a DECLARED key, so a
// wrong-typed value is an `invalid_type` error rather than a passthrough.
// Without this case, widening the key to `z.any()` would satisfy every
// positive assertion above.
const result = Mirror.safeParse({ type: 'test-component', hidden: 123 });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.join('.') === 'hidden')).toBe(true);
}
});
});
41 changes: 37 additions & 4 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,12 @@
* already pins equal to `keyof Declared`. Nothing asserts it against a written
* number, so this line is prose and can rot; the pin that cannot is the one
* comparing the two halves to each other.
* - **39 entries** in `KnownDrift`, **55 keys** across them — 56 until objectui#6940
* - **40 entries** in `KnownDrift`, **56 keys** across them — 39 / 55 until
* objectui#7455 SEEDED `app.zod.ts#AppComponentSchema` with its one
* spec-derived key `hidden` (a pair born ledgered, not growth on an existing
* entry: both faces read `boolean` until the base was widened, and only the
* DECLARED face moved — see that entry). It stood at 39 / 55 rather than
* 39 / 56 because objectui#6940
* REPAIRED `DataTableSchema.rowActions` (the entry kept its other four keys, so
* the entry count did not move). It was 12 / 17 until
* objectui#6124 added the RUNTIME-SLOT class (28 pairs touched, 35 keys) — see
Expand DownExpand Up@@ -85,7 +90,7 @@
* "no entry in either" population dropped by one to 141 — went to 142 when
* objectui#6576 added two pairs, one of them ledgered, and stands at **143**
* since objectui#7129 retired `DetailViewSectionSchema`'s only ledgered key.
* - 160 − 39 = **121**, the "pairs with no entry" `LedgerMismatch` speaks of.
* - 160 − 40 = **120**, the "pairs with no entry" `LedgerMismatch` speaks of.
*
* ## Two ratchets, because the forward comparison has two halves
*
Expand All@@ -106,7 +111,7 @@
*
* ## KNOWN_DRIFT is a ratchet, not a waiver
*
* 39 of the 160 pairs carry TYPE drift TODAY (measured, not assumed). Each is
* 40 of the 160 pairs carry TYPE drift TODAY (measured, not assumed). Each is
* pinned to its EXACT drifted key set, so the entry fails when new drift appears on
* that mirror AND when the recorded drift is fixed — a stale entry cannot rot
* quietly. Correcting them is not one change: the pairs below split into DISJOINT
Expand DownExpand Up@@ -685,6 +690,34 @@ export type UnmirroredOf< K extends MirrorKey > = UnmirroredDeclaredKeys< (typeo
* new drift on a listed mirror fails, and so does a listed key that has been fixed.
*/
interface KnownDrift {
/**
* SPEC-DERIVED, not a mirroring debt, and NOT closable by editing this entry.
*
* Measured on `@objectstack/spec@17.2.0` by resolving `AppSchema.shape`: the
* spec's `AppSchema` declares `hidden` (`z.boolean().optional()` -- accepts a
* boolean, refuses a string) and declares NEITHER `visible` NOR `disabled`.
* `AppComponentSchema` is `BaseSchema.extend(SpecAppFields.shape).extend(...)`
* and `SpecAppFields` excludes six keys -- `name`, `label`, `description`,
* `navigation`, `areas`, `contextSelectors` -- with `hidden` not among them,
* so on the MIRROR face the spec's boolean lands after the base's and
* overrides it. On the DECLARED face `interface AppComponentSchema extends
* BaseSchema` does not restate the key at all, so it inherits the base.
*
* That is why widening `BaseSchema.hidden` to `boolean | string`
* (objectui#7455, ruled 2026-09-03) moved only the TS side of THIS pair and
* seeded this entry, while the same widening on `visible` (objectui#4581) and
* `disabled` (objectui#4580 ruling Q3-A) moved both sides and seeded nothing.
* The asymmetry is the spec's, one layer under the one #7455 removed.
*
* The two keys collide in NAME and differ in MEANING -- the spec's is an
* app-catalogue flag (does the app show in the switcher), the base's is the
* renderer's hide predicate -- so this is a contract ruling, not a repair.
* objectui#7542 carries it, with the directions measured and none chosen.
* The one direction that reads easy and is probably wrong: dropping `hidden`
* from `SpecAppFields` would make a spec-DERIVED schema accept, by local
* divergence, a value the spec refuses.
*/
'app.zod.ts#AppComponentSchema': 'hidden';
/**
* RUNTIME SLOT (objectui#6124): `calendar-view`'s `pickHostCallbacks` reads
* `onViewChange` off the spread props (function values only) and hands it to
Expand DownExpand Up@@ -1318,7 +1351,7 @@ export type assertionLedgerHalvesAreDisjoint = Expect< Equal< DoubleFiledKey, ne

/**
* Every pair's TYPE drift equals what `KnownDrift` records for it — `never` for the
* 121 pairs with no entry (160 − 39).
* 120 pairs with no entry (160 − 40).
*
* Routed through `ReconcileAgainstLedger` rather than spelling the conditional
* inline. That is a semantics-preserving refactor and nothing else — the type is
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
13 changes: 13 additions & 0 deletions .changeset/hidden-predicate-widen-7455.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
'@object-ui/types': minor
---

**`BaseSchema.hidden` now declares the predicate string the renderer already evaluates** (objectui#7455, maintainer ruling 2026-09-03).

`hidden?: boolean` becomes `hidden?: boolean | string`, and the Zod mirror's `z.boolean()` becomes `z.union([z.boolean(), z.string()])` — matching `visible` (#4581) and `disabled` (#4580 ruling Q3-A) on both faces. `hidden` was the third key on the same evaluated path and the only one still declared boolean-only.

This is a **widening**, not a replacement: every boolean `hidden` keeps parsing and keeps type-checking unchanged, and the renderer's behaviour is untouched by this change — `SchemaRenderer`'s `shouldHide` chain already routed this key through `hasDeclaredPredicate` and evaluated it, which is the evidence the widening rests on. What changes is that authors and their tooling can now write `hidden: "${data.status === 'draft'}"` without casting past the declaration, and the Zod mirror stops refusing it (before this, that value failed `safeParse` with `invalid_type` at path `hidden` while the identical string on `visible` parsed).

`hiddenOn` is unchanged and remains the sibling expression spelling. The CEL envelope object form is still declared on none of `visible` / `hidden` / `disabled`; objectui#7530 rules on all three together.

Per this repository's version-alignment convention, a widening of a published type surface ships as `minor` with the semantics spelled out here rather than as `major` (see AGENTS.md, "版本号策略").
2 changes: 1 addition & 1 deletion content/docs/api/schema-reference.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,7 +69,7 @@ One row per declared member, in declaration order, so the list can be checked ag
| `visible` | `boolean \| string` | Visibility control. Accepts a boolean **or** a predicate expression string — the renderer evaluates this key rather than reading it as a boolean. |
| `visibleWhen` | `string` | Canonical conditional-visibility predicate (ADR-0089); the element is shown when it evaluates truthy. Evaluated **before** `visible` and `visibleOn`, and outranks both. |
| `visibleOn` | `string` | Expression for conditional visibility. **Deprecated** (ADR-0089) — use `visibleWhen`. |
| `hidden` | `boolean` | Inverse of `visible`. Boolean only — unlike `visible`, this key takes no expression. |
| `hidden` | `boolean \| string` | Inverse of `visible` — the node is not rendered. Accepts a boolean **or** a predicate expression string, which the renderer evaluates rather than reading as a boolean; `hiddenOn` remains the sibling spelling. |
| `hiddenOn` | `string` | Expression for conditional hiding. |
| `disabled` | `boolean \| string` | Disabled state. Accepts a boolean **or** a predicate expression string, on the same evaluated path as `visible`. |
| `disabledOn` | `string` | Expression for conditional disabling. |
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,6 +89,7 @@ import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import type { BaseSchema } from '@object-ui/types';
import { SchemaRenderer } from '../SchemaRenderer';
import { SchemaRendererContext } from '../context/SchemaRendererContext';

Expand All@@ -113,6 +114,34 @@ function renderNode(schema: Record<string, unknown>) {
);
}

/**
* The DECLARED path -- no cast at all.
*
* `renderNode` above spreads a `Record<string, unknown>` through `as never`
* because most of this file exercises shapes `BaseSchema` does not declare and
* should not: `null`, `0`, `[]`, `{}`, and the CEL envelope object. Those keep
* the cast.
*
* The STRING form is different since objectui#7455 (ruled 2026-09-03):
* `hidden` is declared `boolean | string`, so an expression-valued `hidden` is
* authorable and the compiler is the right checker for it. Narrowing `hidden`
* back to `boolean` makes the call sites below TS2322 -- and `tsc -p
* tsconfig.test.json` (chained from this package's `type-check` script) is the
* only thing that can see that; vitest cannot, because the annotation is erased
* before a single case runs.
*
* The envelope pin below deliberately stays on `renderNode`: the envelope form
* is declared on NONE of `visible` / `hidden` / `disabled`, and objectui#7530
* rules on all three together.
*/
function renderDeclaredNode(schema: BaseSchema) {
return render(
<SchemaRendererContext.Provider value={{ dataSource: DATA }}>
<SchemaRenderer schema={schema} />
</SchemaRendererContext.Provider>,
);
}

/** Did the node render at all? */
function rendered(): boolean {
return screen.queryByTestId('probe') !== null;
Expand DownExpand Up@@ -177,11 +206,11 @@ describe('SchemaRenderer `hidden` — an empty predicate is not a declared gate
expect(rendered()).toBe(true);
});

it('an expression-valued `hidden` keeps its verdict, both ways', () => {
const { unmount } = renderNode({ hidden: '${data.status === "draft"}' });
it('an expression-valued `hidden` keeps its verdict, both ways -- through the DECLARED path, no cast (objectui#7455)', () => {
const { unmount } = renderDeclaredNode({ type: 'probe-3955', hidden: '${data.status === "draft"}' });
expect(rendered()).toBe(false);
unmount();
renderNode({ hidden: '${data.published}' });
renderDeclaredNode({ type: 'probe-3955', hidden: '${data.published}' });
expect(rendered()).toBe(true);
});

Expand Down
164 changes: 164 additions & 0 deletions packages/types/src/__tests__/base-schema-hidden-predicate.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `BaseSchema.hidden` admits the predicate string the renderer evaluates
* (objectui#7455, maintainer ruling 2026-09-03: option A, widen).
*
* The twin of `base-schema-visible-predicate.test.ts` (#4581) and of
* `disabled-twin-symmetry-7087.test.ts` (#4580 Q3-A). `hidden` was the third
* key on the same evaluated path and the only one still declared boolean-only
* on both faces.
*
* ## The evidence
*
* `SchemaRenderer`'s `shouldHide` chain does not read this key as a boolean:
*
* ```ts
* if (hasDeclaredPredicate(newSchema.hidden)) {
* return evaluateVisibilityPredicate(newSchema.hidden, 'hidden');
* }
* ```
*
* `hasDeclaredPredicate` (`packages/core/src/evaluator/declaredPredicate.ts`)
* is the repo's single shared definition of "declared", asked by all three
* keys, all three `*On` siblings, `ActionRunner` and `ActionEngine`; the
* evaluator underneath is declared
* `(condition: string | boolean | undefined, ...) => boolean`. Predicate
* strings on `hidden` were already SHIPPED and PINNED — see
* `packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx`,
* which drove them through a `Record<string, unknown>` helper because the
* declaration refused them.
*
* ## Measured before the change (red-first, on `origin/main` d04e79a80)
*
* • TS — `base.ts:328` was `hidden?: boolean`.
* • zod — `base.zod.ts:175` was `z.boolean()`, and
* `BaseSchema.safeParse({ type: 'probe', hidden: '${data.status === "draft"}' })`
* returned `success: false`,
* `{ code: 'invalid_type', expected: 'boolean', path: ['hidden'] }`,
* while the identical string on `visible` parsed. So the zod mirror was NOT
* already ahead of TS — both faces refused it.
*
* ## What this file pins, and why in this shape
*
* 1. Type level — `BaseSchema['hidden']` 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 widening that never happened, and `BaseSchema`'s
* `[key: string]: any` index signature means a DELETED member reads `any`,
* which a one-way check also accepts. The overshoot is the live risk here,
* not a hypothetical.
* 2. The three keys are asserted to carry the SAME declared type. The ruling's
* words are "matching `visible` and `disabled` on both faces"; asserting
* `hidden` alone would stay green if a later change narrowed one of the
* other two, which is the asymmetry this card exists to remove.
* 3. Runtime (zod face) — the string form and the boolean form both
* `safeParse` GREEN in full, and a NUMBER is still refused at path
* `hidden`. The refusal is the anti-overshoot guard: `z.any()` would
* satisfy every positive case on its own.
*
* ## Deliberately NOT pinned here: the CEL envelope object
*
* `hasDeclaredPredicate` accepts `{ dialect, source }` on this key, and NO key
* declares it — `visible` and `disabled` are `boolean | string` and under-report
* it too. objectui#7530 rules on all three together (declare on all three, or
* refuse on all three). This file therefore asserts nothing about that shape in
* either direction; pinning the current refusal on `hidden` alone would
* pre-empt that ruling and re-introduce, in the pins, exactly the three-way
* asymmetry the widening just removed.
*
* ADR-0089's carve-out ("the boolean `visible` ... is explicitly out of scope")
* governs `packages/spec`'s keys, not this surface — `BaseSchema` is objectui's
* own declaration. It is evidence of intent about the same concept, which is
* why this was ruled rather than applied mechanically.
*/

import { describe, it, expect } from 'vitest';
import type { BaseSchema } from '../base';
import { BaseSchema as Mirror } from '../zod/base.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 declared type is exactly what the evaluator accepts ─────────────── */

export type assertionHidden = Expect<
Equal< BaseSchema['hidden'], boolean | string | undefined >
>;

/** The two siblings, asserted beside it: all three keys carry one type. */
export type assertionHiddenMatchesVisible = Expect<
Equal< BaseSchema['hidden'], BaseSchema['visible'] >
>;
export type assertionHiddenMatchesDisabled = Expect<
Equal< BaseSchema['hidden'], BaseSchema['disabled'] >
>;

/* ── Authorable fixtures ─────────────────────────────────────────────────── */

/** The capability the renderer implements, now declared. */
export const hiddenPredicateStringIsAuthorable: BaseSchema = {
type: 'test-component',
hidden: 'record.status == "draft"',
};

/** The template-expression spelling the shipped react pins use. */
export const hiddenTemplateExpressionIsAuthorable: BaseSchema = {
type: 'test-component',
hidden: '${data.status === "draft"}',
};

/** The boolean form is untouched — this is a widening, not a replacement. */
export const hiddenBooleanIsStillAuthorable: BaseSchema = {
type: 'test-component',
hidden: true,
};

/* ── Runtime companion (the zod mirror) ──────────────────────────────────── */

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

describe('BaseSchema.hidden (objectui#7455)', () => {
it('type-level: hidden is boolean | string, pinned invariantly against both siblings', () => {
// Erased at runtime; `tsc -p tsconfig.test.json` is the checker, chained
// from this package's `type-check` script. The runtime case exists so a
// green vitest run is not mistaken for the proof.
expect(hiddenPredicateStringIsAuthorable.hidden).toBe('record.status == "draft"');
expect(hiddenBooleanIsStillAuthorable.hidden).toBe(true);
});

it('zod mirror: a predicate string on `hidden` parses in full', () => {
const result = Mirror.safeParse({ type: 'test-component', hidden: PREDICATE });
// Full parse, not just "no unrecognized_keys": this is a judgement about
// the VALUE, so nothing short of a green `safeParse` measures it.
expect(result.success).toBe(true);
});

it('zod mirror: `visible` and `disabled` take the same string — the control', () => {
// If these ever go red, the failure is NOT about `hidden`, and the
// assertion above would have been passing for the wrong reason.
expect(Mirror.safeParse({ type: 'test-component', visible: PREDICATE }).success).toBe(true);
expect(Mirror.safeParse({ type: 'test-component', disabled: PREDICATE }).success).toBe(true);
});

it('zod mirror: the boolean form still parses — a widening, not a replacement', () => {
expect(Mirror.safeParse({ type: 'test-component', hidden: true }).success).toBe(true);
expect(Mirror.safeParse({ type: 'test-component', hidden: false }).success).toBe(true);
});

it('zod mirror: a number is still refused at path `hidden` — the anti-overshoot guard', () => {
// `BaseSchema` is `.passthrough()`, but `hidden` is a DECLARED key, so a
// wrong-typed value is an `invalid_type` error rather than a passthrough.
// Without this case, widening the key to `z.any()` would satisfy every
// positive assertion above.
const result = Mirror.safeParse({ type: 'test-component', hidden: 123 });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.join('.') === 'hidden')).toBe(true);
}
});
});
41 changes: 37 additions & 4 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,12 @@
* already pins equal to `keyof Declared`. Nothing asserts it against a written
* number, so this line is prose and can rot; the pin that cannot is the one
* comparing the two halves to each other.
* - **39 entries** in `KnownDrift`, **55 keys** across them — 56 until objectui#6940
* - **40 entries** in `KnownDrift`, **56 keys** across them — 39 / 55 until
* objectui#7455 SEEDED `app.zod.ts#AppComponentSchema` with its one
* spec-derived key `hidden` (a pair born ledgered, not growth on an existing
* entry: both faces read `boolean` until the base was widened, and only the
* DECLARED face moved — see that entry). It stood at 39 / 55 rather than
* 39 / 56 because objectui#6940
* REPAIRED `DataTableSchema.rowActions` (the entry kept its other four keys, so
* the entry count did not move). It was 12 / 17 until
* objectui#6124 added the RUNTIME-SLOT class (28 pairs touched, 35 keys) — see
Expand DownExpand Up@@ -85,7 +90,7 @@
* "no entry in either" population dropped by one to 141 — went to 142 when
* objectui#6576 added two pairs, one of them ledgered, and stands at **143**
* since objectui#7129 retired `DetailViewSectionSchema`'s only ledgered key.
* - 160 − 39 = **121**, the "pairs with no entry" `LedgerMismatch` speaks of.
* - 160 − 40 = **120**, the "pairs with no entry" `LedgerMismatch` speaks of.
*
* ## Two ratchets, because the forward comparison has two halves
*
Expand All@@ -106,7 +111,7 @@
*
* ## KNOWN_DRIFT is a ratchet, not a waiver
*
* 39 of the 160 pairs carry TYPE drift TODAY (measured, not assumed). Each is
* 40 of the 160 pairs carry TYPE drift TODAY (measured, not assumed). Each is
* pinned to its EXACT drifted key set, so the entry fails when new drift appears on
* that mirror AND when the recorded drift is fixed — a stale entry cannot rot
* quietly. Correcting them is not one change: the pairs below split into DISJOINT
Expand DownExpand Up@@ -685,6 +690,34 @@ export type UnmirroredOf< K extends MirrorKey > = UnmirroredDeclaredKeys< (typeo
* new drift on a listed mirror fails, and so does a listed key that has been fixed.
*/
interface KnownDrift {
/**
* SPEC-DERIVED, not a mirroring debt, and NOT closable by editing this entry.
*
* Measured on `@objectstack/spec@17.2.0` by resolving `AppSchema.shape`: the
* spec's `AppSchema` declares `hidden` (`z.boolean().optional()` -- accepts a
* boolean, refuses a string) and declares NEITHER `visible` NOR `disabled`.
* `AppComponentSchema` is `BaseSchema.extend(SpecAppFields.shape).extend(...)`
* and `SpecAppFields` excludes six keys -- `name`, `label`, `description`,
* `navigation`, `areas`, `contextSelectors` -- with `hidden` not among them,
* so on the MIRROR face the spec's boolean lands after the base's and
* overrides it. On the DECLARED face `interface AppComponentSchema extends
* BaseSchema` does not restate the key at all, so it inherits the base.
*
* That is why widening `BaseSchema.hidden` to `boolean | string`
* (objectui#7455, ruled 2026-09-03) moved only the TS side of THIS pair and
* seeded this entry, while the same widening on `visible` (objectui#4581) and
* `disabled` (objectui#4580 ruling Q3-A) moved both sides and seeded nothing.
* The asymmetry is the spec's, one layer under the one #7455 removed.
*
* The two keys collide in NAME and differ in MEANING -- the spec's is an
* app-catalogue flag (does the app show in the switcher), the base's is the
* renderer's hide predicate -- so this is a contract ruling, not a repair.
* objectui#7542 carries it, with the directions measured and none chosen.
* The one direction that reads easy and is probably wrong: dropping `hidden`
* from `SpecAppFields` would make a spec-DERIVED schema accept, by local
* divergence, a value the spec refuses.
*/
'app.zod.ts#AppComponentSchema': 'hidden';
/**
* RUNTIME SLOT (objectui#6124): `calendar-view`'s `pickHostCallbacks` reads
* `onViewChange` off the spread props (function values only) and hands it to
Expand DownExpand Up@@ -1318,7 +1351,7 @@ export type assertionLedgerHalvesAreDisjoint = Expect< Equal< DoubleFiledKey, ne

/**
* Every pair's TYPE drift equals what `KnownDrift` records for it — `never` for the
* 121 pairs with no entry (160 − 39).
* 120 pairs with no entry (160 − 40).
*
* Routed through `ReconcileAgainstLedger` rather than spelling the conditional
* inline. That is a semantics-preserving refactor and nothing else — the type is
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
13 changes: 13 additions & 0 deletions .changeset/hidden-predicate-widen-7455.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
'@object-ui/types': minor
---

**`BaseSchema.hidden` now declares the predicate string the renderer already evaluates** (objectui#7455, maintainer ruling 2026-09-03).

`hidden?: boolean` becomes `hidden?: boolean | string`, and the Zod mirror's `z.boolean()` becomes `z.union([z.boolean(), z.string()])` — matching `visible` (#4581) and `disabled` (#4580 ruling Q3-A) on both faces. `hidden` was the third key on the same evaluated path and the only one still declared boolean-only.

This is a **widening**, not a replacement: every boolean `hidden` keeps parsing and keeps type-checking unchanged, and the renderer's behaviour is untouched by this change — `SchemaRenderer`'s `shouldHide` chain already routed this key through `hasDeclaredPredicate` and evaluated it, which is the evidence the widening rests on. What changes is that authors and their tooling can now write `hidden: "${data.status === 'draft'}"` without casting past the declaration, and the Zod mirror stops refusing it (before this, that value failed `safeParse` with `invalid_type` at path `hidden` while the identical string on `visible` parsed).

`hiddenOn` is unchanged and remains the sibling expression spelling. The CEL envelope object form is still declared on none of `visible` / `hidden` / `disabled`; objectui#7530 rules on all three together.

Per this repository's version-alignment convention, a widening of a published type surface ships as `minor` with the semantics spelled out here rather than as `major` (see AGENTS.md, "版本号策略").
2 changes: 1 addition & 1 deletion content/docs/api/schema-reference.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,7 +69,7 @@ One row per declared member, in declaration order, so the list can be checked ag
| `visible` | `boolean \| string` | Visibility control. Accepts a boolean **or** a predicate expression string — the renderer evaluates this key rather than reading it as a boolean. |
| `visibleWhen` | `string` | Canonical conditional-visibility predicate (ADR-0089); the element is shown when it evaluates truthy. Evaluated **before** `visible` and `visibleOn`, and outranks both. |
| `visibleOn` | `string` | Expression for conditional visibility. **Deprecated** (ADR-0089) — use `visibleWhen`. |
| `hidden` | `boolean` | Inverse of `visible`. Boolean only — unlike `visible`, this key takes no expression. |
| `hidden` | `boolean \| string` | Inverse of `visible` — the node is not rendered. Accepts a boolean **or** a predicate expression string, which the renderer evaluates rather than reading as a boolean; `hiddenOn` remains the sibling spelling. |
| `hiddenOn` | `string` | Expression for conditional hiding. |
| `disabled` | `boolean \| string` | Disabled state. Accepts a boolean **or** a predicate expression string, on the same evaluated path as `visible`. |
| `disabledOn` | `string` | Expression for conditional disabling. |
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,6 +89,7 @@ import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import type { BaseSchema } from '@object-ui/types';
import { SchemaRenderer } from '../SchemaRenderer';
import { SchemaRendererContext } from '../context/SchemaRendererContext';

Expand All@@ -113,6 +114,34 @@ function renderNode(schema: Record<string, unknown>) {
);
}

/**
* The DECLARED path -- no cast at all.
*
* `renderNode` above spreads a `Record<string, unknown>` through `as never`
* because most of this file exercises shapes `BaseSchema` does not declare and
* should not: `null`, `0`, `[]`, `{}`, and the CEL envelope object. Those keep
* the cast.
*
* The STRING form is different since objectui#7455 (ruled 2026-09-03):
* `hidden` is declared `boolean | string`, so an expression-valued `hidden` is
* authorable and the compiler is the right checker for it. Narrowing `hidden`
* back to `boolean` makes the call sites below TS2322 -- and `tsc -p
* tsconfig.test.json` (chained from this package's `type-check` script) is the
* only thing that can see that; vitest cannot, because the annotation is erased
* before a single case runs.
*
* The envelope pin below deliberately stays on `renderNode`: the envelope form
* is declared on NONE of `visible` / `hidden` / `disabled`, and objectui#7530
* rules on all three together.
*/
function renderDeclaredNode(schema: BaseSchema) {
return render(
<SchemaRendererContext.Provider value={{ dataSource: DATA }}>
<SchemaRenderer schema={schema} />
</SchemaRendererContext.Provider>,
);
}

/** Did the node render at all? */
function rendered(): boolean {
return screen.queryByTestId('probe') !== null;
Expand DownExpand Up@@ -177,11 +206,11 @@ describe('SchemaRenderer `hidden` — an empty predicate is not a declared gate
expect(rendered()).toBe(true);
});

it('an expression-valued `hidden` keeps its verdict, both ways', () => {
const { unmount } = renderNode({ hidden: '${data.status === "draft"}' });
it('an expression-valued `hidden` keeps its verdict, both ways -- through the DECLARED path, no cast (objectui#7455)', () => {
const { unmount } = renderDeclaredNode({ type: 'probe-3955', hidden: '${data.status === "draft"}' });
expect(rendered()).toBe(false);
unmount();
renderNode({ hidden: '${data.published}' });
renderDeclaredNode({ type: 'probe-3955', hidden: '${data.published}' });
expect(rendered()).toBe(true);
});

Expand Down
164 changes: 164 additions & 0 deletions packages/types/src/__tests__/base-schema-hidden-predicate.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `BaseSchema.hidden` admits the predicate string the renderer evaluates
* (objectui#7455, maintainer ruling 2026-09-03: option A, widen).
*
* The twin of `base-schema-visible-predicate.test.ts` (#4581) and of
* `disabled-twin-symmetry-7087.test.ts` (#4580 Q3-A). `hidden` was the third
* key on the same evaluated path and the only one still declared boolean-only
* on both faces.
*
* ## The evidence
*
* `SchemaRenderer`'s `shouldHide` chain does not read this key as a boolean:
*
* ```ts
* if (hasDeclaredPredicate(newSchema.hidden)) {
* return evaluateVisibilityPredicate(newSchema.hidden, 'hidden');
* }
* ```
*
* `hasDeclaredPredicate` (`packages/core/src/evaluator/declaredPredicate.ts`)
* is the repo's single shared definition of "declared", asked by all three
* keys, all three `*On` siblings, `ActionRunner` and `ActionEngine`; the
* evaluator underneath is declared
* `(condition: string | boolean | undefined, ...) => boolean`. Predicate
* strings on `hidden` were already SHIPPED and PINNED — see
* `packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx`,
* which drove them through a `Record<string, unknown>` helper because the
* declaration refused them.
*
* ## Measured before the change (red-first, on `origin/main` d04e79a80)
*
* • TS — `base.ts:328` was `hidden?: boolean`.
* • zod — `base.zod.ts:175` was `z.boolean()`, and
* `BaseSchema.safeParse({ type: 'probe', hidden: '${data.status === "draft"}' })`
* returned `success: false`,
* `{ code: 'invalid_type', expected: 'boolean', path: ['hidden'] }`,
* while the identical string on `visible` parsed. So the zod mirror was NOT
* already ahead of TS — both faces refused it.
*
* ## What this file pins, and why in this shape
*
* 1. Type level — `BaseSchema['hidden']` 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 widening that never happened, and `BaseSchema`'s
* `[key: string]: any` index signature means a DELETED member reads `any`,
* which a one-way check also accepts. The overshoot is the live risk here,
* not a hypothetical.
* 2. The three keys are asserted to carry the SAME declared type. The ruling's
* words are "matching `visible` and `disabled` on both faces"; asserting
* `hidden` alone would stay green if a later change narrowed one of the
* other two, which is the asymmetry this card exists to remove.
* 3. Runtime (zod face) — the string form and the boolean form both
* `safeParse` GREEN in full, and a NUMBER is still refused at path
* `hidden`. The refusal is the anti-overshoot guard: `z.any()` would
* satisfy every positive case on its own.
*
* ## Deliberately NOT pinned here: the CEL envelope object
*
* `hasDeclaredPredicate` accepts `{ dialect, source }` on this key, and NO key
* declares it — `visible` and `disabled` are `boolean | string` and under-report
* it too. objectui#7530 rules on all three together (declare on all three, or
* refuse on all three). This file therefore asserts nothing about that shape in
* either direction; pinning the current refusal on `hidden` alone would
* pre-empt that ruling and re-introduce, in the pins, exactly the three-way
* asymmetry the widening just removed.
*
* ADR-0089's carve-out ("the boolean `visible` ... is explicitly out of scope")
* governs `packages/spec`'s keys, not this surface — `BaseSchema` is objectui's
* own declaration. It is evidence of intent about the same concept, which is
* why this was ruled rather than applied mechanically.
*/

import { describe, it, expect } from 'vitest';
import type { BaseSchema } from '../base';
import { BaseSchema as Mirror } from '../zod/base.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 declared type is exactly what the evaluator accepts ─────────────── */

export type assertionHidden = Expect<
Equal< BaseSchema['hidden'], boolean | string | undefined >
>;

/** The two siblings, asserted beside it: all three keys carry one type. */
export type assertionHiddenMatchesVisible = Expect<
Equal< BaseSchema['hidden'], BaseSchema['visible'] >
>;
export type assertionHiddenMatchesDisabled = Expect<
Equal< BaseSchema['hidden'], BaseSchema['disabled'] >
>;

/* ── Authorable fixtures ─────────────────────────────────────────────────── */

/** The capability the renderer implements, now declared. */
export const hiddenPredicateStringIsAuthorable: BaseSchema = {
type: 'test-component',
hidden: 'record.status == "draft"',
};

/** The template-expression spelling the shipped react pins use. */
export const hiddenTemplateExpressionIsAuthorable: BaseSchema = {
type: 'test-component',
hidden: '${data.status === "draft"}',
};

/** The boolean form is untouched — this is a widening, not a replacement. */
export const hiddenBooleanIsStillAuthorable: BaseSchema = {
type: 'test-component',
hidden: true,
};

/* ── Runtime companion (the zod mirror) ──────────────────────────────────── */

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

describe('BaseSchema.hidden (objectui#7455)', () => {
it('type-level: hidden is boolean | string, pinned invariantly against both siblings', () => {
// Erased at runtime; `tsc -p tsconfig.test.json` is the checker, chained
// from this package's `type-check` script. The runtime case exists so a
// green vitest run is not mistaken for the proof.
expect(hiddenPredicateStringIsAuthorable.hidden).toBe('record.status == "draft"');
expect(hiddenBooleanIsStillAuthorable.hidden).toBe(true);
});

it('zod mirror: a predicate string on `hidden` parses in full', () => {
const result = Mirror.safeParse({ type: 'test-component', hidden: PREDICATE });
// Full parse, not just "no unrecognized_keys": this is a judgement about
// the VALUE, so nothing short of a green `safeParse` measures it.
expect(result.success).toBe(true);
});

it('zod mirror: `visible` and `disabled` take the same string — the control', () => {
// If these ever go red, the failure is NOT about `hidden`, and the
// assertion above would have been passing for the wrong reason.
expect(Mirror.safeParse({ type: 'test-component', visible: PREDICATE }).success).toBe(true);
expect(Mirror.safeParse({ type: 'test-component', disabled: PREDICATE }).success).toBe(true);
});

it('zod mirror: the boolean form still parses — a widening, not a replacement', () => {
expect(Mirror.safeParse({ type: 'test-component', hidden: true }).success).toBe(true);
expect(Mirror.safeParse({ type: 'test-component', hidden: false }).success).toBe(true);
});

it('zod mirror: a number is still refused at path `hidden` — the anti-overshoot guard', () => {
// `BaseSchema` is `.passthrough()`, but `hidden` is a DECLARED key, so a
// wrong-typed value is an `invalid_type` error rather than a passthrough.
// Without this case, widening the key to `z.any()` would satisfy every
// positive assertion above.
const result = Mirror.safeParse({ type: 'test-component', hidden: 123 });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.join('.') === 'hidden')).toBe(true);
}
});
});
41 changes: 37 additions & 4 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,12 @@
* already pins equal to `keyof Declared`. Nothing asserts it against a written
* number, so this line is prose and can rot; the pin that cannot is the one
* comparing the two halves to each other.
* - **39 entries** in `KnownDrift`, **55 keys** across them — 56 until objectui#6940
* - **40 entries** in `KnownDrift`, **56 keys** across them — 39 / 55 until
* objectui#7455 SEEDED `app.zod.ts#AppComponentSchema` with its one
* spec-derived key `hidden` (a pair born ledgered, not growth on an existing
* entry: both faces read `boolean` until the base was widened, and only the
* DECLARED face moved — see that entry). It stood at 39 / 55 rather than
* 39 / 56 because objectui#6940
* REPAIRED `DataTableSchema.rowActions` (the entry kept its other four keys, so
* the entry count did not move). It was 12 / 17 until
* objectui#6124 added the RUNTIME-SLOT class (28 pairs touched, 35 keys) — see
Expand DownExpand Up@@ -85,7 +90,7 @@
* "no entry in either" population dropped by one to 141 — went to 142 when
* objectui#6576 added two pairs, one of them ledgered, and stands at **143**
* since objectui#7129 retired `DetailViewSectionSchema`'s only ledgered key.
* - 160 − 39 = **121**, the "pairs with no entry" `LedgerMismatch` speaks of.
* - 160 − 40 = **120**, the "pairs with no entry" `LedgerMismatch` speaks of.
*
* ## Two ratchets, because the forward comparison has two halves
*
Expand All@@ -106,7 +111,7 @@
*
* ## KNOWN_DRIFT is a ratchet, not a waiver
*
* 39 of the 160 pairs carry TYPE drift TODAY (measured, not assumed). Each is
* 40 of the 160 pairs carry TYPE drift TODAY (measured, not assumed). Each is
* pinned to its EXACT drifted key set, so the entry fails when new drift appears on
* that mirror AND when the recorded drift is fixed — a stale entry cannot rot
* quietly. Correcting them is not one change: the pairs below split into DISJOINT
Expand DownExpand Up@@ -685,6 +690,34 @@ export type UnmirroredOf< K extends MirrorKey > = UnmirroredDeclaredKeys< (typeo
* new drift on a listed mirror fails, and so does a listed key that has been fixed.
*/
interface KnownDrift {
/**
* SPEC-DERIVED, not a mirroring debt, and NOT closable by editing this entry.
*
* Measured on `@objectstack/spec@17.2.0` by resolving `AppSchema.shape`: the
* spec's `AppSchema` declares `hidden` (`z.boolean().optional()` -- accepts a
* boolean, refuses a string) and declares NEITHER `visible` NOR `disabled`.
* `AppComponentSchema` is `BaseSchema.extend(SpecAppFields.shape).extend(...)`
* and `SpecAppFields` excludes six keys -- `name`, `label`, `description`,
* `navigation`, `areas`, `contextSelectors` -- with `hidden` not among them,
* so on the MIRROR face the spec's boolean lands after the base's and
* overrides it. On the DECLARED face `interface AppComponentSchema extends
* BaseSchema` does not restate the key at all, so it inherits the base.
*
* That is why widening `BaseSchema.hidden` to `boolean | string`
* (objectui#7455, ruled 2026-09-03) moved only the TS side of THIS pair and
* seeded this entry, while the same widening on `visible` (objectui#4581) and
* `disabled` (objectui#4580 ruling Q3-A) moved both sides and seeded nothing.
* The asymmetry is the spec's, one layer under the one #7455 removed.
*
* The two keys collide in NAME and differ in MEANING -- the spec's is an
* app-catalogue flag (does the app show in the switcher), the base's is the
* renderer's hide predicate -- so this is a contract ruling, not a repair.
* objectui#7542 carries it, with the directions measured and none chosen.
* The one direction that reads easy and is probably wrong: dropping `hidden`
* from `SpecAppFields` would make a spec-DERIVED schema accept, by local
* divergence, a value the spec refuses.
*/
'app.zod.ts#AppComponentSchema': 'hidden';
/**
* RUNTIME SLOT (objectui#6124): `calendar-view`'s `pickHostCallbacks` reads
* `onViewChange` off the spread props (function values only) and hands it to
Expand DownExpand Up@@ -1318,7 +1351,7 @@ export type assertionLedgerHalvesAreDisjoint = Expect< Equal< DoubleFiledKey, ne

/**
* Every pair's TYPE drift equals what `KnownDrift` records for it — `never` for the
* 121 pairs with no entry (160 − 39).
* 120 pairs with no entry (160 − 40).
*
* Routed through `ReconcileAgainstLedger` rather than spelling the conditional
* inline. That is a semantics-preserving refactor and nothing else — the type is
Expand Down
Loading
Loading