diff --git a/.changeset/hidden-predicate-widen-7455.md b/.changeset/hidden-predicate-widen-7455.md new file mode 100644 index 000000000..9db73d712 --- /dev/null +++ b/.changeset/hidden-predicate-widen-7455.md @@ -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, "版本号策略"). diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 7dd7b7d63..b575d4962 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -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. | diff --git a/packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx b/packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx index 7fe947e59..a88b5d74d 100644 --- a/packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx @@ -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'; @@ -113,6 +114,34 @@ function renderNode(schema: Record) { ); } +/** + * The DECLARED path -- no cast at all. + * + * `renderNode` above spreads a `Record` 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( + + + , + ); +} + /** Did the node render at all? */ function rendered(): boolean { return screen.queryByTestId('probe') !== null; @@ -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); }); diff --git a/packages/types/src/__tests__/base-schema-hidden-predicate.test.ts b/packages/types/src/__tests__/base-schema-hidden-predicate.test.ts new file mode 100644 index 000000000..e92d2d62f --- /dev/null +++ b/packages/types/src/__tests__/base-schema-hidden-predicate.test.ts @@ -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` 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); + } + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 43809e691..28092c3e9 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -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 @@ -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 * @@ -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 @@ -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 @@ -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 diff --git a/packages/types/src/base.ts b/packages/types/src/base.ts index 84e8aeab4..2e57dcc18 100644 --- a/packages/types/src/base.ts +++ b/packages/types/src/base.ts @@ -323,9 +323,40 @@ export interface BaseSchema { * (`SchemaRenderer.expressions.test.tsx`, * `SchemaRenderer.hiddenDeclaredGate.test.tsx`). * + * Accepts a PREDICATE STRING as well as a boolean (objectui#7455, ruled + * 2026-09-03), on exactly the evidence that widened `visible` (#4581) and + * `disabled` (#4580 ruling Q3-A): the renderer does not read this key as a + * boolean, it evaluates it. The `shouldHide` chain asks core's one definition + * of "declared" and then evaluates the value -- + * `hasDeclaredPredicate(newSchema.hidden)` then + * `evaluateVisibilityPredicate(newSchema.hidden, 'hidden')` in + * `SchemaRenderer.tsx` (the `hidden` leg, :1236 as of d04e79a80 -- re-derive + * the line, do not trust it) -- and the evaluator underneath is declared + * `(condition: string | boolean | undefined, ...) => boolean`. The sibling + * key `hiddenOn` is `string` for the same reason, and its existence was NOT + * taken to mean this key should stay boolean -- exactly as it was not taken + * that way for `visibleWhen` / `visibleOn` beside `visible`, or `disabledOn` + * beside `disabled`. The declaration simply under-reported a shipped, pinned + * capability (`SchemaRenderer.hiddenDeclaredGate.test.tsx`), and the fixtures + * exercising it had to cast past it. + * + * ADR-0089 -- "The boolean `visible` (Tab on/off) is a different type and + * concept and is explicitly out of scope" -- governs `packages/spec`'s keys, + * NOT this surface: `BaseSchema` is objectui's own declaration. The ADR is + * evidence of intent about the same concept, which is why this widening was + * ruled rather than applied mechanically. + * + * The CEL ENVELOPE OBJECT form is deliberately NOT declared here. + * `hasDeclaredPredicate` accepts it on this key, and it is declared on NONE + * of the three: `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); do not declare it on this key alone. + * * @default false + * @example true + * @example "${data.status === 'draft'}" */ - hidden?: boolean; + hidden?: boolean | string; /** * Expression for conditional hiding. diff --git a/packages/types/src/zod/base.zod.ts b/packages/types/src/zod/base.zod.ts index c82523ed3..3cc82e00b 100644 --- a/packages/types/src/zod/base.zod.ts +++ b/packages/types/src/zod/base.zod.ts @@ -170,9 +170,22 @@ const BaseSchemaCore = z.object({ visibleOn: z.string().optional().describe('[DEPRECATED → visibleWhen] Expression for conditional visibility'), /** - * Hidden control + * Hidden control -- a boolean, or the predicate STRING the renderer evaluates. + * + * Mirrors `BaseSchema.hidden: boolean | string` (`../base.ts`), widened by + * objectui#7455 (ruled 2026-09-03) on the same evidence that widened + * `visible` (#4581) and `disabled` (#4580 Q3-A): `SchemaRenderer`'s + * `shouldHide` chain asks `hasDeclaredPredicate` and then evaluates the + * value, never reading this key as a boolean. Measured before the widening: + * `BaseSchema.safeParse({ type, hidden: '${data.status === "draft"}' })` + * returned `success: false` with `invalid_type` (expected boolean, received + * string) while the identical string on `visible` parsed -- this validator + * was the one surface still refusing a shipped, pinned capability. + * + * The CEL envelope object form is NOT declared here, and is declared on none + * of the three keys; objectui#7530 rules on all three together. */ - hidden: z.boolean().optional().describe('Hidden control'), + hidden: z.union([z.boolean(), z.string()]).optional().describe('Hidden control (boolean or predicate expression)'), /** * Conditional hidden expression