diff --git a/.changeset/25926-retire-node-visibility-dialects.md b/.changeset/25926-retire-node-visibility-dialects.md new file mode 100644 index 00000000000..294d44258c3 --- /dev/null +++ b/.changeset/25926-retire-node-visibility-dialects.md @@ -0,0 +1,26 @@ +--- +'@object-ui/types': minor +'@object-ui/react': minor +'@object-ui/sdui-parser': minor +'@object-ui/app-shell': patch +--- + +feat(types,react,sdui-parser,app-shell)!: the node-level `visibleOn` / `hiddenOn` / `disabledOn` spellings are retired; `visibleWhen` / `hidden` / `disabled` are the one predicate each + +Clause-②: no (narrowing) +Metadata-protocol: narrowed + + + +`@objectstack/spec` declares exactly one conditional-visibility predicate at every layer, `visibleWhen`, and objectui carried three spellings of its own beside it: `visibleOn` beside `visibleWhen`, `hiddenOn` beside `hidden`, `disabledOn` beside `disabled`. Each pair was evaluated by the same code with the same polarity, so the second spelling added a dialect and nothing else. The three are retired, with no alias window. + +- **`@object-ui/types`**: `BaseSchema.visibleOn`, `hiddenOn` and `disabledOn` are `never` on the TypeScript face and alias refusals on the Zod mirror. A document that writes one is refused at that key, and the message names the key that survives (`visibleWhen`, `hidden`, `disabled`), says the polarity is the same, and says the value is unchanged. `objectui validate` reads the same mirror, so it prints the same refusal. The `object-view` table slot's refusal of the same three keys now names the surviving node key too (write `visibleWhen` / `hidden` / `disabled` on the object-view node itself) instead of sending the author to a retired spelling. Boolean `visible` stays: the spec declares it on actions, navigation entries, tabs, alerts and settings, and the renderer keeps evaluating it. +- **`@object-ui/react`**: `SchemaRenderer`'s visibility chain has three legs, `visibleWhen` and `visible` (shown when truthy) and `hidden` (hidden when truthy), and the enablement gate has one, `disabled`. A stale retired key that reaches the renderer unvalidated is not consulted and is held out of the component's props, so it neither gates the node nor lands in the DOM. +- **`@object-ui/sdui-parser`**: the three keys are no longer base props. `validateTree` reports each as an `unknown-prop` diagnostic that names the surviving key, so a TSX tree is told the same thing a JSON document is. `checkRetiredVisibilityDialect` and `RETIRED_VISIBILITY_DIALECTS` are exported for that reading. +- **`@object-ui/app-shell`**: the command palette reads a navigation entry's `visible` only; the `visibleOn` fallback it carried read a key `NavigationItem` never declared. + +**BREAKING** (an accept-set narrowing) for any page, view or component document that writes `visibleOn`, `hiddenOn` or `disabledOn` on a node. No shipped example, fixture or skill example in this repository wrote one; the ones in the documentation and the `objectui` skill are rewritten in this change. + +- **Before.** The three keys parsed, were evaluated beside their canonical twin, and the docs taught both spellings. +- **Now.** Each is refused by name on `objectui validate`, on the Zod mirror, by the TypeScript compiler and by the SDUI parser, and the renderer does not read it. +- **Fix.** Rename the key and keep the value: `visibleOn` becomes `visibleWhen`, `hiddenOn` becomes `hidden`, `disabledOn` becomes `disabled`. A form field's `fields[].visibleOn` is a different, view-level slot and is unchanged. diff --git a/scripts/sdui-manifest.sources/components.json b/scripts/sdui-manifest.sources/components.json index abf8b2d57ee..e705b060750 100644 --- a/scripts/sdui-manifest.sources/components.json +++ b/scripts/sdui-manifest.sources/components.json @@ -1 +1 @@ -{"package":"ui/packages/components","sources":226,"sha256":"75ed8c66878430e296c285bb0885304449f091b00da2209889cb99f634ef1690","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/components","sources":226,"sha256":"2321bcd998e35f7367cbab8745cb1837768ff5dec8a5684709b8e06e13be6c69","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/scripts/sdui-manifest.sources/core.json b/scripts/sdui-manifest.sources/core.json index 8c1a7049cd0..8fe6fe5a4f1 100644 --- a/scripts/sdui-manifest.sources/core.json +++ b/scripts/sdui-manifest.sources/core.json @@ -1 +1 @@ -{"package":"ui/packages/core","sources":112,"sha256":"e586f7573193068eefdbf8c22651072941bbd3843d60612cc2bc5d7e16003fca","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/core","sources":112,"sha256":"810fe0e8f6db868fd443540cb2e30a85f691a48a08d58ba7ba8db0c577d579e7","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/scripts/sdui-manifest.sources/react.json b/scripts/sdui-manifest.sources/react.json index 5946a845eba..fa655b7e874 100644 --- a/scripts/sdui-manifest.sources/react.json +++ b/scripts/sdui-manifest.sources/react.json @@ -1 +1 @@ -{"package":"ui/packages/react","sources":70,"sha256":"0a4e839e9459da5ce7a6eb9b112d527bf8afecd3f7e8c6f63b936e5eb9593c68","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/react","sources":70,"sha256":"d7a6fb1f906784fd1c2d04592bf54d095db68a222bc5dce8b4747f944288a944","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/scripts/sdui-manifest.sources/sdui-parser.json b/scripts/sdui-manifest.sources/sdui-parser.json index d8c156cf0b9..32deb03f6a7 100644 --- a/scripts/sdui-manifest.sources/sdui-parser.json +++ b/scripts/sdui-manifest.sources/sdui-parser.json @@ -1 +1 @@ -{"package":"ui/packages/sdui-parser","sources":9,"sha256":"08704fc188cb4749560f2a3faedd77b60820c1990f03311bcad21f283b2d7be4","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/sdui-parser","sources":10,"sha256":"57c2952e2ffa03ee0144f1b26fb0af73272c684004f8e8dd689b2b061243509e","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/scripts/sdui-manifest.sources/types.json b/scripts/sdui-manifest.sources/types.json index 3afa6f37e67..ea9118118b3 100644 --- a/scripts/sdui-manifest.sources/types.json +++ b/scripts/sdui-manifest.sources/types.json @@ -1 +1 @@ -{"package":"ui/packages/types","sources":81,"sha256":"0373b9f45d8be39821641918e2e8058b8016ad24232b39111193116732fdf00f","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/types","sources":81,"sha256":"f8450c19832a17c2f45e0164ec9e6e68cb247efdc00c81998c77e8c42c11da65","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/skills/objectui/guides/schema-expressions.md b/skills/objectui/guides/schema-expressions.md index 9b97e3c4a69..413df5b154b 100644 --- a/skills/objectui/guides/schema-expressions.md +++ b/skills/objectui/guides/schema-expressions.md @@ -236,7 +236,8 @@ name, so calls are case-insensitive. ### Syntax options -Each condition field has two forms — a shorthand and an `On` suffix: +The node-level condition keys are `visibleWhen` (show), `hidden` and +`disabled`. Each takes a boolean, a `${}` template, or a raw expression: ```jsonc @@ -250,10 +251,11 @@ Each condition field has two forms — a shorthand and an `On` suffix: ```jsonc -{ "hiddenOn": "data.status !== 'active'" } // raw expression (no ${} needed) +{ "hidden": "data.status !== 'active'" } // raw expression (no ${} needed) ``` -The `On` variants accept raw expressions without `${}` wrapping — the entire string is the expression. +`visibleOn` / `hiddenOn` / `disabledOn` are retired and refused by name — +rename to `visibleWhen` / `hidden` / `disabled`; the value is unchanged. ### Visibility patterns @@ -269,12 +271,6 @@ The `On` variants accept raw expressions without `${}` wrapping — the entire s { "visible": "${data.record.status === 'active'}" } ``` -**Data-dependent:** - -```json -{ "hidden": "${!data.items || data.items.length === 0}" } -``` - **Combined conditions:** ```json @@ -290,13 +286,13 @@ The `On` variants accept raw expressions without `${}` wrapping — the entire s ```json -{ "disabledOn": "!data.canPerformAction || data.isLocked" } +{ "disabled": "!data.canPerformAction || data.isLocked" } ``` ## Field-level conditional rules (`visibleWhen` / `readonlyWhen` / `requiredWhen`) These live on the **object's field metadata**, are written in CEL, and are -evaluated by `@objectstack/formula` -- a different tier from the `${}` / `On` +evaluated by `@objectstack/formula` -- a different tier from the node-level conditions above, which are schema/widget-level and run on the recursive-descent `SafeExpressionParser`. **How to author them is the `objectstack-data` skill's job**, under "Conditional Field Rules": the predicate scope, the client-only vs @@ -641,6 +637,6 @@ When an expression isn't working: 1. **Which key is it on, and does that type declare the key?** `content` and the predicate keys are evaluated and read on every type. `title` / `label` / `value` / `description` are evaluated **only on the types that declare them** — `statistic` (`label` / `value` / `description`), `card` (`title` / `description`), `button` (`label`) — and read raw everywhere else, including on a namespaced spelling such as `ui:statistic`. A `${...}` inside a `props` envelope is evaluated and then discarded. (A `properties` envelope is the one that is evaluated *and* hoisted onto the node — see [`rules/protocol.md`](../rules/protocol.md) for why that is recorded, not recommended.) 2. Is the `${}` syntax correct? Check for unmatched braces. 3. Is the data actually available in scope? Check what the host published on `PredicateScopeProvider` — ⛔ not `SchemaRendererProvider`'s `dataSource`, which publishes no expression root. -4. For conditions: are you using `On` suffix correctly? (`hiddenOn` takes raw expression, `hidden` needs `${}` if it's a string). +4. For conditions: `visibleWhen` / `hidden` / `disabled` take a raw expression or a `${}` template; the `On` spellings are refused. 5. Does the expression use a blocked pattern? Check for constructors, `eval`, `window`, etc. 6. Is type coercion causing issues? `${0 && "yes"}` returns `0`, not `false`. diff --git a/skills/objectui/rules/protocol.md b/skills/objectui/rules/protocol.md index 9826f3456e3..358b523498d 100644 --- a/skills/objectui/rules/protocol.md +++ b/skills/objectui/rules/protocol.md @@ -13,17 +13,15 @@ value must clear before it reaches the screen. | Field | Evaluation Type | Return Type | Example | |---|---|---|---| | `content` | Template (`${}`) | string | `"content": "Total: ${data.total}"` | -| `hidden` | Condition | boolean | `"hidden": "${data.status !== 'active'}"` | -| `hiddenOn` | Condition | boolean | `"hiddenOn": "data.status === 'draft'"` | +| `visibleWhen` | Condition | boolean | `"visibleWhen": "data.permissions.canView"` (raw expression, no `${}`) | +| `hidden` | Condition | boolean | `"hidden": "data.status === 'draft'"` | | `visible` | Condition | boolean | `"visible": "${data.isActive}"` | -| `visibleOn` | Condition | boolean | `"visibleOn": "data.permissions.canView"` | -| `disabled` | Condition | boolean | `"disabled": "${form.isSubmitting}"` | -| `disabledOn` | Condition | boolean | `"disabledOn": "!data.hasPermission"` | +| `disabled` | Condition | boolean | `"disabled": "!data.hasPermission"` | | `title` / `label` / `value` / `description` | Template (`${}`) | Preserves original type | **Only on the component types the spec declares** — see "Rule: Bindable Text Keys" below. `"value": "${data.total}"` on a `statistic` renders the number. | | `properties.*` | Template (`${}`) | Preserves original type | Evaluated, then **hoisted onto the node** (`type` / `id` excepted), so the result lands where every renderer reads. See "Rule: Keys Live on the Node" below. | | `props.*` | Template (`${}`) | Preserves original type | Evaluated, then spread as **React props** — a `ui:*` / `page:*` renderer reads `schema.*` and never sees the result. Consumed only by `element:*` components. | -**Precedence rule:** `visible` takes priority over `hidden`. +**Precedence rule:** `visible` takes priority over `hidden`. `visibleOn` / `hiddenOn` / `disabledOn` are retired: `objectui validate` refuses them by name, pointing at `visibleWhen` / `hidden` / `disabled`. ### Fields That are NOT Evaluated @@ -111,9 +109,8 @@ as React props (`packages/react/src/SchemaRenderer.tsx`), which those renderers ignore. A key parked under `props` is therefore dropped: the component renders an empty frame, and the envelope itself lands in the DOM as the invalid attribute `props="[object Object]"`. Silent for every key except the node-gate -predicates (`visibleWhen` / `visible` / `visibleOn` / `visibility` / `hidden` / -`hiddenOn` / `disabled` / `disabledOn`), which are refused by name on the -console, with the migration, instead of gating nothing. +predicates (`visibleWhen` / `visible` / `hidden` / `disabled`), which are +refused by name on the console, with the migration, instead of gating nothing. **❌ WRONG — renders an empty card:** diff --git a/ui/content/docs/api/schema-reference.md b/ui/content/docs/api/schema-reference.md index ecc3c5b6c0b..77add900293 100644 --- a/ui/content/docs/api/schema-reference.md +++ b/ui/content/docs/api/schema-reference.md @@ -70,9 +70,8 @@ All schema types extend `BaseSchema`. These shared properties are available on e "description": "A container element", "className": "p-4 bg-white rounded-lg", "visible": true, - "visibleOn": "${data.showWrapper}", - "disabled": false, - "disabledOn": "${data.isLocked}", + "visibleWhen": "${data.showWrapper}", + "disabled": "${data.isLocked}", "testId": "wrapper-element", "ariaLabel": "Content wrapper", "children": [] @@ -96,12 +95,12 @@ One row per declared member, in declaration order, so the list can be checked ag | `body` | *retired* | ⛔ Refused by name (objectui#6771). `body` was a second child-list spelling `BaseSchema` declared beside `children`; it is now `never` on the TypeScript face and an alias refusal on the Zod mirror, and the refusal names `children`. | | `children` | `SchemaNode \| SchemaNode[]` | Child components rendered inside this component — the child-list key, and since objectui#6771 the only one. Whether a given node type renders a child list at all is still per component; see the note below. The keys OTHER than this one through which a renderer hands nodes to `SchemaRenderer` (a dialog's `trigger`, a tab item's `content`, a page's `regions[].components`) are per type and declared once as `NODE_SLOT_DECLARATIONS` / `nodeSlotsFor` (objectui#11170); `objectui check`, the core schema validator and the SDUI parser walk those positions as they walk `children`. | | `visible` | `boolean \| string \| { dialect?: string; source: string }` | Visibility control. Accepts a boolean, a predicate expression string, **or** the CEL envelope object (`{ dialect: 'cel', source }` — what `objectstack build` emits for every authored predicate) — the renderer evaluates this key rather than reading it as a boolean. The string-or-envelope half is `ExpressionWire`, the one wire type `visibleWhen` on form fields already carries. | -| `visibleWhen` | `string \| { dialect, source }` | Canonical conditional-visibility predicate (ADR-0089); the element is shown when it evaluates truthy. Typed as `@objectstack/spec`'s `EvaluatedExpressionInput`: a predicate string, or the envelope the spec's parse writes (`dialect` is `cel`, `cron` or `template`, and `source` is not blank). Evaluated **before** `visible` and `visibleOn`, and outranks both. | -| `visibleOn` | `string` | Expression for conditional visibility. **Deprecated** (ADR-0089) — use `visibleWhen`. | -| `hidden` | `boolean \| string \| { dialect?: string; source: string }` | Inverse of `visible` — the node is not rendered. Accepts a boolean, a predicate expression string **or** the CEL envelope object (`ExpressionWire`), which the renderer evaluates rather than reading as a boolean; `hiddenOn` remains the sibling spelling. | -| `hiddenOn` | `string` | Expression for conditional hiding. | +| `visibleWhen` | `string \| { dialect, source }` | Canonical conditional-visibility predicate (ADR-0089); the element is shown when it evaluates truthy. Typed as `@objectstack/spec`'s `EvaluatedExpressionInput`: a predicate string, or the envelope the spec's parse writes (`dialect` is `cel`, `cron` or `template`, and `source` is not blank). Evaluated **before** `visible`, and outranks it. | +| `visibleOn` | *retired* | ⛔ Refused by name. `visibleOn` was objectui's own spelling of `visibleWhen`; it is `never` on the TypeScript face and an alias refusal on the Zod mirror, and the refusal names `visibleWhen` (same predicate, same polarity). | +| `hidden` | `boolean \| string \| { dialect?: string; source: string }` | Inverse of `visible` — the node is not rendered. Accepts a boolean, a predicate expression string **or** the CEL envelope object (`ExpressionWire`), which the renderer evaluates rather than reading as a boolean. | +| `hiddenOn` | *retired* | ⛔ Refused by name; the refusal names `hidden` (same predicate, same polarity). | | `disabled` | `boolean \| string \| { dialect?: string; source: string }` | Disabled state. Accepts a boolean, a predicate expression string **or** the CEL envelope object (`ExpressionWire`), on the same evaluated path as `visible`. | -| `disabledOn` | `string` | Expression for conditional disabling. | +| `disabledOn` | *retired* | ⛔ Refused by name; the refusal names `disabled` (same predicate, same polarity). | | `testId` | `string` | Test identifier, rendered as `data-testid`. | | `ariaLabel` | `string \| KeyedI18nLabel` | Accessibility label, rendered as `aria-label`. `KeyedI18nLabel` is the **keyed** form (`{ key, defaultValue?, params? }`), resolved by `resolveKeyedI18nLabel` — **not** the `I18nLabel` that `label` and `description` carry. The two are structurally confusable and each returns nothing useful for the other's input. | @@ -1439,6 +1438,6 @@ import type { BaseSchema, SchemaNode } from '@object-ui/types'; - **[Schema Overview](/docs/guide/schema-overview)** — High-level guide to ObjectUI schemas - **[Quick Start](/docs/guide/quick-start)** — Build your first ObjectUI application -- **[Expressions](/docs/guide/expressions)** — Dynamic expressions with `visibleOn`, `disabledOn` +- **[Expressions](/docs/guide/expressions)** — Dynamic expressions with `visibleWhen`, `hidden`, `disabled` - **[Fields Guide](/docs/guide/fields)** — Deep dive into form fields - **[Plugin Development](/docs/guide/plugin-development)** — Build custom schema renderers diff --git a/ui/content/docs/guide/expressions.md b/ui/content/docs/guide/expressions.md index 85009281e78..c4e40a7e329 100644 --- a/ui/content/docs/guide/expressions.md +++ b/ui/content/docs/guide/expressions.md @@ -120,7 +120,7 @@ Supported: `>`, `<`, `>=`, `<=`, `==`, `===`, `!=`, `!==` ```json { "type": "button", - "visibleOn": "${user.isAdmin && user.isActive}" + "visibleWhen": "${user.isAdmin && user.isActive}" } ``` @@ -137,7 +137,13 @@ Supported: `&&`, `||`, `!` ## Conditional Properties -### visibleOn +The node-level condition keys are `visibleWhen`, `hidden` and `disabled`. The +`visibleOn` / `hiddenOn` / `disabledOn` spellings are retired: `objectui validate` +refuses them by name and points at the surviving key (same predicate, same +polarity — rename the key, the value is unchanged). A form field's `fields[].visibleOn` +is a different, view-level slot and is unaffected. + +### visibleWhen Show component when expression is true: @@ -145,22 +151,22 @@ Show component when expression is true: { "type": "button", "label": "Admin Panel", - "visibleOn": "${user.role === 'admin'}" + "visibleWhen": "${user.role === 'admin'}" } ``` -### hiddenOn +### hidden Hide component when expression is true: ```json { "type": "section", - "hiddenOn": "${user.settings.hideSection}" + "hidden": "${user.settings.hideSection}" } ``` -### disabledOn +### disabled Disable component when expression is true: @@ -168,7 +174,7 @@ Disable component when expression is true: { "type": "button", "label": "Submit", - "disabledOn": "${record.status === 'submitted'}" + "disabled": "${record.status === 'submitted'}" } ``` @@ -350,7 +356,7 @@ Available: All standard `Math` functions ```json { "type": "alert", - "visibleOn": "${(user.role === 'admin' || user.role === 'moderator') && user.isActive && !user.isSuspended}" + "visibleWhen": "${(user.role === 'admin' || user.role === 'moderator') && user.isActive && !user.isSuspended}" } ``` @@ -387,7 +393,7 @@ type and stay expressions: "type": "badge", "label": "Completed", "variant": "secondary", - "visibleOn": "${status !== 'draft'}" + "visibleWhen": "${status !== 'draft'}" } ``` @@ -409,7 +415,7 @@ Two neighbouring traps: `text` is not a `badge` key at all — the badge's text ```json { "type": "empty", - "visibleOn": "${items.length === 0}", + "visibleWhen": "${items.length === 0}", "title": "No items to display", "description": "Start by adding your first item" } @@ -427,7 +433,7 @@ the condition keys are evaluated on every type and stay expressions: "type": "progress", "value": 75, "label": "75% complete", - "visibleOn": "${total > 0}" + "visibleWhen": "${total > 0}" } ``` @@ -443,7 +449,7 @@ or author each variant and gate it with a condition key: "type": "card", "title": "${task.name}", "className": "border-red-500 border-2", - "visibleOn": "${task.isPriority}" + "visibleWhen": "${task.isPriority}" } ``` @@ -478,7 +484,7 @@ row: "name": "state", "type": "select", "label": "State/Province", - "visibleOn": "${record.country === 'USA'}", + "visibleWhen": "${record.country === 'USA'}", "options": [ { "label": "CA", "value": "CA" }, { "label": "NY", "value": "NY" }, @@ -660,7 +666,7 @@ language's own. A membership test is written with the array method: ```json { "type": "button", - "visibleOn": "${user.permissions.includes('admin')}" + "visibleWhen": "${user.permissions.includes('admin')}" } ``` @@ -690,7 +696,7 @@ language's own. A membership test is written with the array method: ```json { - "visibleOn": "${x && y || z}" + "visibleWhen": "${x && y || z}" } ``` @@ -698,7 +704,7 @@ language's own. A membership test is written with the array method: ```json { - "visibleOn": "${isAdmin && isActive || isSuperUser}" + "visibleWhen": "${isAdmin && isActive || isSuperUser}" } ``` diff --git a/ui/content/docs/guide/metadata-diagnostics.md b/ui/content/docs/guide/metadata-diagnostics.md index 7d84249d048..9613eaadcc7 100644 --- a/ui/content/docs/guide/metadata-diagnostics.md +++ b/ui/content/docs/guide/metadata-diagnostics.md @@ -205,9 +205,8 @@ a blank predicate at parse, as their protocol counterparts do. `visible`, and is only diagnosed. The same is now true of a **component node's own gate** — `visibleWhen` on a -page component (and its `visible` / `visibleOn` / `hidden` / `hiddenOn` -siblings; the page component's old `visibility` spelling is retired and not -read), plus a `page:tabs` item's `visibleWhen`. These used to +page component (and its `visible` / `hidden` siblings; the `visibility`, +`visibleOn` and `hiddenOn` spellings are retired and not read), plus a `page:tabs` item's `visibleWhen`. These used to report in a development build only, so a gate that stopped biting in production left nothing on the console at all. They now warn in **both** builds, with the node type, the node id, the gate key, the predicate source @@ -220,16 +219,15 @@ and the engine's reason: ... ``` -The rest of the line says what the fault did to the node: on `visibleWhen`, -`visible` and `visibleOn` the node was **withheld**, on `hidden` and `hiddenOn` -it was removed (see below). +The rest of the line says what the fault did to the node: on `visibleWhen` and +`visible` the node was **withheld**, on `hidden` it was removed (see below). The line is **rate limited to one per distinct predicate source**, so a broken predicate rendered down two hundred rows of a list is one line, not two hundred — while a second, differently-broken predicate still gets its own. Page visibility gates fail **closed**: a page node's `visibleWhen` (and its -`visible` / `visibleOn` / `hidden` / `hiddenOn` siblings, objectui#13299) and a +`visible` / `hidden` siblings, objectui#13299) and a `page:tabs` item's `visibleWhen` (objectui#13286). A declared predicate that cannot be evaluated — over the CEL engine's AST bound, a type error, an unbound root such as `record` on a surface with no row, a missing function — withholds diff --git a/ui/content/docs/guide/schema-playground.md b/ui/content/docs/guide/schema-playground.md index 7f423f92355..6041d5d470a 100644 --- a/ui/content/docs/guide/schema-playground.md +++ b/ui/content/docs/guide/schema-playground.md @@ -254,7 +254,7 @@ Every schema goes through the following pipeline before becoming visible UI: 1. **Parse** — The JSON string is parsed into a JavaScript object. 2. **Validate** — The `type` field is checked against the component registry. Required fields are verified. -3. **Resolve** — The registry maps the `type` to a React component. Expressions like `"visibleOn": "${data.role === 'admin'}"` are evaluated. +3. **Resolve** — The registry maps the `type` to a React component. Expressions like `"visibleWhen": "${data.role === 'admin'}"` are evaluated. 4. **Render** — The matched component receives the schema properties as props and renders Shadcn primitives with Tailwind classes. In code, this is a single call: @@ -280,7 +280,7 @@ const schema: CardSchema = { - **Use `className`** — Any schema object accepts a `className` property for Tailwind utility classes. - **Check the `type`** — If nothing renders, verify the `type` value matches a registered component. - **Nest schemas** — Use container types like `stack`, `grid`, and `page` to compose multiple components. -- **Add expressions** — Use `visibleOn` and `disabledOn` for dynamic behavior: `"visibleOn": "${data.showAdvanced}"`. +- **Add expressions** — Use `visibleWhen`, `hidden` and `disabled` for dynamic behavior: `"visibleWhen": "${data.showAdvanced}"`. ## Related Resources diff --git a/ui/content/docs/guide/schema-rendering.md b/ui/content/docs/guide/schema-rendering.md index fefaf830804..0c2d5cc2b43 100644 --- a/ui/content/docs/guide/schema-rendering.md +++ b/ui/content/docs/guide/schema-rendering.md @@ -52,9 +52,9 @@ interface BaseSchema { id?: string // Optional unique identifier className?: string // Tailwind CSS classes style?: CSSProperties // Inline styles (use sparingly) - visibleOn?: string // Expression for conditional visibility - hiddenOn?: string // Expression for conditional hiding - disabledOn?: string // Expression for conditional disabling + visibleWhen?: string // Expression for conditional visibility + hidden?: boolean | string // Expression for conditional hiding + disabled?: boolean | string // Expression for conditional disabling } ``` @@ -66,7 +66,7 @@ interface BaseSchema { "id": "stats-card", "className": "p-6 shadow-lg", "title": "User Statistics", - "visibleOn": "${user.role === 'admin'}", + "visibleWhen": "${user.role === 'admin'}", "children": { "type": "text", "content": "Total Users: ${stats.totalUsers}" @@ -266,7 +266,7 @@ it on `label`: `text` is not a `BadgeSchema` key. { "type": "button", "label": "Delete", - "visibleOn": "${user.role === 'admin'}" + "visibleWhen": "${user.role === 'admin'}" } ``` @@ -574,11 +574,11 @@ const schema = { children: [ { type: "admin-panel", - visibleOn: "${user.isAdmin}" + visibleWhen: "${user.isAdmin}" }, { type: "user-panel", - visibleOn: "${!user.isAdmin}" + visibleWhen: "${!user.isAdmin}" } ] } @@ -597,7 +597,7 @@ Always type your schemas for better IDE support and fewer runtime errors. "type": "container", "children": { "type": "spinner", - "visibleOn": "${loading}" + "visibleWhen": "${loading}" } } ``` @@ -607,7 +607,7 @@ Always type your schemas for better IDE support and fewer runtime errors. ```json { "type": "empty", - "visibleOn": "${items.length === 0}", + "visibleWhen": "${items.length === 0}", "title": "No items found", "action": { "type": "button", @@ -622,13 +622,13 @@ Always type your schemas for better IDE support and fewer runtime errors. { "type": "alert", "variant": "destructive", - "visibleOn": "${error}", + "visibleWhen": "${error}", "title": "Something went wrong", "children": { "type": "text", "content": "${error.message}" } } ``` -`visibleOn` is a condition key and is evaluated on every node type. The message text is a +`visibleWhen` is a condition key and is evaluated on every node type. The message text is a nested `text` node because `alert` carries no expression rows — and `message` is not an `AlertSchema` key at all: the alert's own text keys are `title` and `description`, and the renderer falls back from `description` to `children`. `destructive` is the variant this state diff --git a/ui/content/docs/utilities/runner.mdx b/ui/content/docs/utilities/runner.mdx index 6cde9a5bb72..6c4d7be70a2 100644 --- a/ui/content/docs/utilities/runner.mdx +++ b/ui/content/docs/utilities/runner.mdx @@ -543,7 +543,7 @@ with a Retry button instead of blanking the page — see [Architecture Overview](/docs/guide/architecture-overview). That inner boundary only exists once the component has been resolved. Everything -`SchemaRenderer` does before that point — evaluating `visibleOn`, `disabled` and the +`SchemaRenderer` does before that point — evaluating `visibleWhen`, `disabled` and the other dynamic expressions, then looking the component type up in the registry — runs outside it, so a throw there propagates past the inner boundary. Wrap `SchemaRenderer` itself to catch that class of failure: diff --git a/ui/packages/app-shell/src/chrome/CommandPalette.tsx b/ui/packages/app-shell/src/chrome/CommandPalette.tsx index a7b420efc1f..97fee375502 100644 --- a/ui/packages/app-shell/src/chrome/CommandPalette.tsx +++ b/ui/packages/app-shell/src/chrome/CommandPalette.tsx @@ -204,9 +204,13 @@ function CommandPaletteBody(props: CommandPaletteProps) { // its edits; OK, or a clean page, goes at once. const leaveTo = (to: string) => runCommand(() => confirmUnsavedLeave(() => navigate(to))); - // Extract navigation items from active app, filtering by visibility expressions + // Extract navigation items from active app, filtering by the spec's one + // navigation gate, `visible`. The `?? item.visibleOn` fallback this read + // once carried was a consumer-side alias of a key the spec never declared on + // a navigation entry (`NavigationItem.visible` is the gate) — dropped with the + // node-level dialects. const navItems = flattenNavigation(activeApp?.navigation || []).filter( - (item) => evalVis(item.visible ?? item.visibleOn, item) + (item) => evalVis(item.visible, item) ); // The entries the palette shows for the query: a navigation entry or app by diff --git a/ui/packages/cli/src/commands/check.ts b/ui/packages/cli/src/commands/check.ts index da21c3eae33..b231ee50f46 100644 --- a/ui/packages/cli/src/commands/check.ts +++ b/ui/packages/cli/src/commands/check.ts @@ -57,7 +57,15 @@ const OBJECTUI_STRUCTURAL_KEYS: readonly string[] = [ 'className', 'style', 'placeholder', - // Conditional rendering and interaction state. + // Conditional rendering and interaction state. `visibleOn` / `hiddenOn` / + // `disabledOn` are RETIRED spellings of `visibleWhen` / `hidden` / + // `disabled` (`?: never` tombstones, refused by name on the zod mirror), + // kept here for the same reason `body` is: a file authored under a retired + // spelling must still be RECOGNISED as an ObjectUI node. This sweep then + // reports it as "recognised but not validated" and sends the reader to + // `objectui validate`, whose strict parse refuses the key by name and names + // the canonical one; dropping the marker would let a leaf file carrying only + // a retired key and an unregistered `type` fall out of the sweep unmentioned. 'visible', 'visibleWhen', 'visibleOn', diff --git a/ui/packages/components/src/renderers/__tests__/base-props-one-list-11044.test.tsx b/ui/packages/components/src/renderers/__tests__/base-props-one-list-11044.test.tsx index 176434fecba..4659c1fc690 100644 --- a/ui/packages/components/src/renderers/__tests__/base-props-one-list-11044.test.tsx +++ b/ui/packages/components/src/renderers/__tests__/base-props-one-list-11044.test.tsx @@ -13,14 +13,16 @@ * `validateTree` and the generated JSX types read. This file holds it against * the two things it answers to: * - * - `BaseSchema` (`@object-ui/types`): every entry is a member, and `body` — - * retired by objectui#6771 and answered by name in `body-dialect.ts` — is - * the one member left out. A member added to `BaseSchema` without a - * decision here turns this red, which is how `visibleWhen`, `hiddenOn`, - * `testId` (and `bind` / `hidden` before them, objectui#11008) sat as false + * - `BaseSchema` (`@object-ui/types`): every entry is a member, and the + * members left out are exactly the RETIRED ones — `body` (objectui#6771, + * answered by name in `body-dialect.ts`) and the node-level visibility + * dialects `visibleOn` / `hiddenOn` / `disabledOn` (answered by name in + * `retired-visibility-dialects.ts`). A member added to `BaseSchema` without + * a decision here turns this red, which is how `visibleWhen`, `testId` + * (and `bind` / `hidden` before them, objectui#11008) sat as false * `unknown-prop`s; - * - the live registrations: no registration declares `visibleWhen`, - * `hiddenOn` or `testId`, so making them `every-node` silences no declared + * - the live registrations: no registration declares `visibleWhen`, `hidden` + * or `testId`, so making them `every-node` silences no declared * type check, and none of the three draws a diagnostic naming it on any * registered type. For the `where-undeclared` members, every registration * that declares one keeps its type check, and every one that does not @@ -56,7 +58,7 @@ const naming = (manifest: Manifest, node: Record, key: string): d.message.includes(`"${key}"`), ); -const MEMBER_1 = { visibleWhen: "data.role == 'admin'", hiddenOn: 'data.archived', testId: 'the-node' } as const; +const MEMBER_1 = { visibleWhen: "data.role == 'admin'", hidden: 'data.archived', testId: 'the-node' } as const; const WHERE_UNDECLARED = SDUI_BASE_PROPS.filter((p) => p.scope === 'where-undeclared').map((p) => p.name); @@ -71,14 +73,14 @@ const WELL_TYPED: Record = { }; describe('the base-prop list answers to `BaseSchema` and to the live registry (objectui#11044)', () => { - it('every entry is a `BaseSchema` member, and `body` is the one member left out', () => { + it('every entry is a `BaseSchema` member, and the retired members are the ones left out', () => { const names = SDUI_BASE_PROPS.map((p) => p.name); const members = Object.keys(BaseSchema.shape); expect(names.filter((n) => !members.includes(n))).toEqual([]); - expect(members.filter((m) => !names.includes(m))).toEqual(['body']); + expect(members.filter((m) => !names.includes(m))).toEqual(['body', 'visibleOn', 'hiddenOn', 'disabledOn']); }); - it('no live registration declares `visibleWhen`, `hiddenOn` or `testId` as an input', () => { + it('no live registration declares `visibleWhen`, `hidden` or `testId` as an input', () => { const types = ComponentRegistry.getKnownTypes(); expect(types.length).toBeGreaterThan(0); const declaring = types.flatMap((type) => diff --git a/ui/packages/components/src/renderers/action/__tests__/action-enablement-cel-envelope.test.tsx b/ui/packages/components/src/renderers/action/__tests__/action-enablement-cel-envelope.test.tsx index 8a46d19de24..1b1d29077c1 100644 --- a/ui/packages/components/src/renderers/action/__tests__/action-enablement-cel-envelope.test.tsx +++ b/ui/packages/components/src/renderers/action/__tests__/action-enablement-cel-envelope.test.tsx @@ -125,7 +125,7 @@ describe.each(['action:button', 'action:icon'] as const)( (type) => { // The `fails: false` half is what a flattened envelope could not produce: // on the broken tree BOTH rows came out disabled. - it.each(['disabled', 'disabledOn'])( + it.each(['disabled'])( 'properties.%s: the holding row is disabled, the failing row is usable', (key) => { expect(pair(type, key)).toEqual({ holds: true, fails: false }); diff --git a/ui/packages/components/src/renderers/action/action-button.tsx b/ui/packages/components/src/renderers/action/action-button.tsx index 909fea8a54a..f3eee7a4703 100644 --- a/ui/packages/components/src/renderers/action/action-button.tsx +++ b/ui/packages/components/src/renderers/action/action-button.tsx @@ -105,7 +105,7 @@ export interface ActionButtonRendererProps { /** * The host-EVALUATED enablement verdict, declared rather than left to the * index signature (objectui#9131). `SchemaRenderer` evaluates the node's - * `disabled` / `disabledOn` and forwards the answer under this name as + * `disabled` and forwards the answer under this name as * `disabled: __disabled || undefined`. It is consumed by name below and * therefore never reaches the DOM spread — see the destructure. */ diff --git a/ui/packages/components/src/renderers/disclosure/collapsible.tsx b/ui/packages/components/src/renderers/disclosure/collapsible.tsx index 7be440e766c..f7e9e7a84e9 100644 --- a/ui/packages/components/src/renderers/disclosure/collapsible.tsx +++ b/ui/packages/components/src/renderers/disclosure/collapsible.tsx @@ -17,8 +17,8 @@ import { import { renderChildren } from '../../lib/utils'; ComponentRegistry.register('collapsible', - // `hostDisabled` is `SchemaRenderer`'s EVALUATED verdict on `disabled` / - // `disabledOn`, not the raw authored key — which may be a predicate STRING, + // `hostDisabled` is `SchemaRenderer`'s EVALUATED verdict on `disabled`, + // not the raw authored key — which may be a predicate STRING, // truthy however it evaluates (objectui#7238, precedent objectui#6169). // // `open: _open` is a NAMED EXCLUSION, not an unused parameter (objectui#8236, diff --git a/ui/packages/components/src/renderers/form/button.tsx b/ui/packages/components/src/renderers/form/button.tsx index 409845fb821..b737e937c4d 100644 --- a/ui/packages/components/src/renderers/form/button.tsx +++ b/ui/packages/components/src/renderers/form/button.tsx @@ -20,8 +20,8 @@ import { resolveIcon } from '../action/resolve-icon'; // `__tests__/forwardref-props-annotation.guard.test.ts`. const ButtonRenderer = forwardRef( // `disabled` is the host-EVALUATED verdict, taken by name. `SchemaRenderer` - // evaluates the node's `disabled` / `disabledOn` (either may be a predicate - // STRING), strips the raw key from the props it spreads, and forwards the + // evaluates the node's `disabled` (which may be a predicate STRING), + // strips the raw key from the props it spreads, and forwards the // verdict as a real `disabled` prop. Consuming it here rather than re-reading // `schema.disabled` keeps one carrier for one question (AGENTS.md #0.1, // objectui#7238; precedent `plugin-chatbot`, objectui#6169) — and taking it diff --git a/ui/packages/components/src/renderers/form/checkbox.tsx b/ui/packages/components/src/renderers/form/checkbox.tsx index ad9a9b774d1..fb7a0064d15 100644 --- a/ui/packages/components/src/renderers/form/checkbox.tsx +++ b/ui/packages/components/src/renderers/form/checkbox.tsx @@ -14,8 +14,8 @@ import { toFormControlDomProps } from '../../lib/form-control-dom-props'; import React from 'react'; const CheckboxRenderer = ({ schema, className, onChange, value, disabled: hostDisabled, ...props }: { schema: CheckboxSchema; className?: string; onChange?: (val: any) => void; value?: any; disabled?: boolean; [key: string]: any }) => { - // `hostDisabled` is `SchemaRenderer`'s EVALUATED verdict on `disabled` / - // `disabledOn`, not the raw authored key — which may be a predicate STRING, + // `hostDisabled` is `SchemaRenderer`'s EVALUATED verdict on `disabled`, + // not the raw authored key — which may be a predicate STRING, // truthy however it evaluates (objectui#7238, precedent objectui#6169). // Extract designer-related props const { diff --git a/ui/packages/components/src/renderers/form/combobox.tsx b/ui/packages/components/src/renderers/form/combobox.tsx index eabba167683..05407968ab8 100644 --- a/ui/packages/components/src/renderers/form/combobox.tsx +++ b/ui/packages/components/src/renderers/form/combobox.tsx @@ -63,8 +63,8 @@ import { toFormControlDomProps } from '../../lib/form-control-dom-props'; * moving to the control never announces. */ const ComboboxRenderer = ({ schema, disabled: hostDisabled, ...props }: { schema: ComboboxSchema; disabled?: boolean; [key: string]: any }) => { - // `hostDisabled` is `SchemaRenderer`'s EVALUATED verdict on `disabled` / - // `disabledOn`, not the raw authored key — which may be a predicate STRING, + // `hostDisabled` is `SchemaRenderer`'s EVALUATED verdict on `disabled`, + // not the raw authored key — which may be a predicate STRING, // truthy however it evaluates (objectui#7238, precedent objectui#6169). const { 'data-obj-id': dataObjId, diff --git a/ui/packages/components/src/renderers/form/form.tsx b/ui/packages/components/src/renderers/form/form.tsx index fa032bb62d8..45afe7680ab 100644 --- a/ui/packages/components/src/renderers/form/form.tsx +++ b/ui/packages/components/src/renderers/form/form.tsx @@ -1594,8 +1594,8 @@ ComponentRegistry.register('form', } = schema; // The form-wide enablement verdict, and NOT `schema.disabled` beside it. - // `SchemaRenderer` evaluates the node's `disabled` / `disabledOn` — either - // may be a predicate STRING — strips the raw key from the props it spreads, + // `SchemaRenderer` evaluates the node's `disabled` — which may be a + // predicate STRING — strips the raw key from the props it spreads, // and forwards the answer as a real `disabled` prop. The raw value is truthy // HOWEVER it evaluates, so destructuring it off `schema` here greyed out // every field, the submit button and the cancel button on any form that diff --git a/ui/packages/components/src/renderers/form/input.tsx b/ui/packages/components/src/renderers/form/input.tsx index 4e68bb4b3d4..b76c1fb6a76 100644 --- a/ui/packages/components/src/renderers/form/input.tsx +++ b/ui/packages/components/src/renderers/form/input.tsx @@ -15,7 +15,7 @@ import { toFormControlDomProps } from '../../lib/form-control-dom-props'; const InputRenderer = ({ schema, className, onChange, value, disabled: hostDisabled, ...props }: { schema: InputSchema; className?: string; onChange?: (val: any) => void; value?: any; disabled?: boolean; [key: string]: any }) => { // `disabled` is NOT read off `schema` here. `SchemaRenderer` evaluates the - // node's `disabled` / `disabledOn` (either may be a predicate STRING), strips + // node's `disabled` (which may be a predicate STRING), strips // the raw key from the props it spreads, and forwards the VERDICT as a real // `disabled` prop — `hostDisabled` below. One carrier for one question // (AGENTS.md #0.1, objectui#7238): the authored value is truthy however it diff --git a/ui/packages/components/src/renderers/form/select.tsx b/ui/packages/components/src/renderers/form/select.tsx index 8fc729cbd26..9bde131b90e 100644 --- a/ui/packages/components/src/renderers/form/select.tsx +++ b/ui/packages/components/src/renderers/form/select.tsx @@ -21,8 +21,8 @@ import { toControlValue, matchOptionValue } from './option-value'; import React from 'react'; const SelectRenderer = ({ schema, className, onChange, value, disabled: hostDisabled, ...props }: { schema: SelectSchema; className?: string; onChange?: (val: any) => void; value?: any; disabled?: boolean; [key: string]: any }) => { - // `hostDisabled` is `SchemaRenderer`'s EVALUATED verdict on `disabled` / - // `disabledOn`, not the raw authored key — which may be a predicate STRING, + // `hostDisabled` is `SchemaRenderer`'s EVALUATED verdict on `disabled`, + // not the raw authored key — which may be a predicate STRING, // truthy however it evaluates (objectui#7238, precedent objectui#6169). // Extract designer-related props const { diff --git a/ui/packages/components/src/renderers/form/textarea.tsx b/ui/packages/components/src/renderers/form/textarea.tsx index b772fbde9dd..2aba00b0828 100644 --- a/ui/packages/components/src/renderers/form/textarea.tsx +++ b/ui/packages/components/src/renderers/form/textarea.tsx @@ -14,8 +14,8 @@ import { toFormControlDomProps } from '../../lib/form-control-dom-props'; import React from 'react'; const TextareaRenderer = ({ schema, className, onChange, value, disabled: hostDisabled, ...props }: { schema: TextareaSchema; className?: string; onChange?: (val: any) => void; value?: any; disabled?: boolean; [key: string]: any }) => { - // `hostDisabled` is `SchemaRenderer`'s EVALUATED verdict on `disabled` / - // `disabledOn`, not the raw authored key — which may be a predicate STRING, + // `hostDisabled` is `SchemaRenderer`'s EVALUATED verdict on `disabled`, + // not the raw authored key — which may be a predicate STRING, // truthy however it evaluates (objectui#7238, precedent objectui#6169). // Handle change for both raw inputs and form-bound inputs const handleChange = (e: React.ChangeEvent) => { diff --git a/ui/packages/core/src/evaluator/declaredPredicate.ts b/ui/packages/core/src/evaluator/declaredPredicate.ts index 0820b5271a1..45703ef88f5 100644 --- a/ui/packages/core/src/evaluator/declaredPredicate.ts +++ b/ui/packages/core/src/evaluator/declaredPredicate.ts @@ -125,8 +125,8 @@ export function isBlankPredicateText(value: unknown): boolean { * nothing for it: a declared gate always goes on to an evaluation, and two * reports for one fault is the noise objectstack-ai/objectstack#5149 removed. * On every action `visible` leg that direction is CLOSED (objectui#11212), on - * every `disabled` leg too (objectui#11242), and the hide-polarity `hidden` / - * `hiddenOn` legs of `SchemaRenderer` answer "hidden". The keys whose fault + * every `disabled` leg too (objectui#11242), and the hide-polarity `hidden` + * leg of `SchemaRenderer` answers "hidden". The keys whose fault * direction is OPEN keep it — a `condition` still lets the action run, a * legacy `enabled` leaves the control enabled, an action param's `visible` * still shows the param — now diagnosed rather than silent. @@ -183,13 +183,13 @@ export function isBlankPredicateText(value: unknown): boolean { * definition with one scope replaces all three: * `components/renderers/action/visibility-gate.ts` re-exports this function * under its historic name `hasDeclaredVisibilityGate` (the five member-action - * renderer call sites are unchanged), `SchemaRenderer`'s `disabled` / - * `disabledOn` chain reads it, and `ActionRunner`'s two gates read it instead of + * renderer call sites are unchanged), `SchemaRenderer`'s `disabled` + * gate reads it, and `ActionRunner`'s two gates read it instead of * a private twin. * * Two more consumers joined afterwards, and with them the last two places that * answered this question with a range of their own: - * `SchemaRenderer`'s `hidden` / `hiddenOn` legs, whose verdict is NOT negated so + * `SchemaRenderer`'s `hidden` leg, whose verdict is NOT negated so * an empty predicate made the node VANISH (objectui#3955), and * `ActionEngine.getActionsForLocation`'s `visible` filter, which folded some * empty spellings by hand and coerced the rest with `Boolean(raw)`, so diff --git a/ui/packages/core/src/utils/dom-props.ts b/ui/packages/core/src/utils/dom-props.ts index 923a44990c9..387ed74c38d 100644 --- a/ui/packages/core/src/utils/dom-props.ts +++ b/ui/packages/core/src/utils/dom-props.ts @@ -77,7 +77,7 @@ * * A semantic prop is CONSUMED, never forwarded: a widget that can be disabled * reads `disabled` and applies it (see `plugin-chatbot`'s registrations), which - * is what keeps `disabledOn` working without putting `disabled="true"` on a + * is what keeps a `disabled` predicate working without putting `disabled="true"` on a * `
`. * * Deliberate DOM pass-through beyond this set stays available the objectui#4435 diff --git a/ui/packages/plugin-chatbot/src/renderer.tsx b/ui/packages/plugin-chatbot/src/renderer.tsx index 3ac8b9bb6b5..eec9d5a15be 100644 --- a/ui/packages/plugin-chatbot/src/renderer.tsx +++ b/ui/packages/plugin-chatbot/src/renderer.tsx @@ -71,8 +71,8 @@ ComponentRegistry.register('chatbot', // read-site-censused first; none were dead, so none took the ADR-0049 // route. `schema` is that one type, referenceable and documentable from // outside this file for the first time. `disabled` here is the sibling, - // host-EVALUATED prop (`SchemaRenderer`'s verdict on `schema.disabled` / - // `schema.disabledOn`, forwarded as `hostDisabled`) — a different carrier + // host-EVALUATED prop (`SchemaRenderer`'s verdict on `schema.disabled`, + // forwarded as `hostDisabled`) — a different carrier // from the authored `schema.disabled` it is derived from; see the comment // on `disabled={hostDisabled || isLoading}` below. ({ schema, className, disabled: hostDisabled, ...props }: { schema: ChatbotSchema; className?: string; disabled?: boolean; [key: string]: any }) => { @@ -117,7 +117,7 @@ ComponentRegistry.register('chatbot', placeholder={schema.placeholder} onSendMessage={handleSendMessage} // `hostDisabled` is `SchemaRenderer`'s EVALUATED verdict on the node's - // `disabled` / `disabledOn` (its `_disabled` flag, forwarded as a real + // `disabled` (its `_disabled` flag, forwarded as a real // `disabled` prop). Consuming it — rather than re-reading the raw // `schema.disabled` beside it — keeps one carrier for one question // (AGENTS.md #0.1): the raw value may be an expression STRING, which is diff --git a/ui/packages/react/src/SchemaRenderer.tsx b/ui/packages/react/src/SchemaRenderer.tsx index e2f0700ce56..121459ac802 100644 --- a/ui/packages/react/src/SchemaRenderer.tsx +++ b/ui/packages/react/src/SchemaRenderer.tsx @@ -356,12 +356,21 @@ function propsWithoutCanonicalKeys( * `HIDE` keys ask {@link hasDeclaredPredicate} instead, because their `true` * means *hidden* and the wider `!== undefined` test would vanish a node for * `hidden: ''` (objectui#3955). See {@link winningVisibilityKey}. + * + * Three legs, not five: the objectui dialects `visibleOn` and `hiddenOn` are + * RETIRED (`?: never` tombstones on `BaseSchema`, alias refusals on its zod + * mirror, ADR-0089 / ADR-0049). `visibleWhen` is the one show predicate and + * `hidden` the one hide predicate; `visible` stays because the spec declares + * it on the nodes that reach this chain (an action's `visible`, a nav entry's, + * `record:alert`'s hoisted `properties.visible`). A retired key that still + * reaches this component is not consulted: the authoring face refuses it by + * name, and the strip below keeps it out of the DOM. */ -const VISIBILITY_SHOW_KEYS = ['visibleWhen', 'visible', 'visibleOn'] as const; -const VISIBILITY_HIDE_KEYS = ['hidden', 'hiddenOn'] as const; +const VISIBILITY_SHOW_KEYS = ['visibleWhen', 'visible'] as const; +const VISIBILITY_HIDE_KEYS = ['hidden'] as const; /** - * The five legs, as ONE closed type — so a seventh cannot be added to the chain + * The three legs, as ONE closed type — so a fourth cannot be added to the chain * without also being classified below. */ type VisibilityChainKey = @@ -369,7 +378,7 @@ type VisibilityChainKey = | (typeof VISIBILITY_HIDE_KEYS)[number]; /** - * The same five legs as a lookup, so the config-bag evaluation loops below can + * The same three legs as a lookup, so the config-bag evaluation loops below can * ask "is this key a visibility predicate?" off the SAME declaration the chain * is built from rather than a second list that can drift from it. * @@ -389,10 +398,14 @@ const VISIBILITY_CHAIN_KEYS: ReadonlySet = new Set([ * * Split the way this file actually consults them, which is not a polarity split: * - * * {@link ENABLEMENT_NODE_GATE_KEYS} are the two legs - * {@link evaluateEnablementPredicate} consults HERE, in this precedence - * order, as one early-return chain — the enablement counterpart of - * `shouldHide`. Their `true` means DISABLED, un-negated. + * * {@link ENABLEMENT_NODE_GATE_KEYS} is the one leg + * {@link evaluateEnablementPredicate} consults HERE — the enablement + * counterpart of `shouldHide`. Its `true` means DISABLED, un-negated. The + * `disabledOn` dialect that used to follow it is RETIRED (a `?: never` + * tombstone on `BaseSchema`, an alias refusal on its zod mirror): `disabled` + * takes the same predicate string or envelope, so the second leg carried + * nothing the first does not. The constant stays a list so the derived + * guards below keep one shape. * * {@link ENABLEMENT_RENDERER_KEYS} is the legacy, non-spec `enabled` alias. * This component never evaluates it: the node gate does not consult it at * all, and the action renderers (`action:button`, `action:icon`) read it one @@ -406,7 +419,7 @@ const VISIBILITY_CHAIN_KEYS: ReadonlySet = new Set([ * {@link VisibilityChainKey} exists "so a seventh cannot be added to the chain * without also being classified below", and {@link visibilityGateKind} is that * classification — it hands each leg the consequence copy for ITS polarity. - * These three keys have neither property: they route through + * These two keys have neither property: `disabled` routes through * {@link evaluateEnablementPredicate}, which states `'enablement'` at its own * call site, and `enabled` is not consulted here at all. Adding them to * {@link VISIBILITY_SHOW_KEYS} / {@link VISIBILITY_HIDE_KEYS} to reach the @@ -421,13 +434,13 @@ const VISIBILITY_CHAIN_KEYS: ReadonlySet = new Set([ * a declared-test or a consequence, so the thing they genuinely share is only * the guard's question, and that is exactly what a derived union expresses. */ -const ENABLEMENT_NODE_GATE_KEYS = ['disabled', 'disabledOn'] as const; +const ENABLEMENT_NODE_GATE_KEYS = ['disabled'] as const; const ENABLEMENT_RENDERER_KEYS = ['enabled'] as const; /** - * The two legs the node gate itself consults, as ONE closed type — the + * The one leg the node gate itself consults, as ONE closed type — the * enablement counterpart of {@link VisibilityChainKey}, load-bearing for the - * same reason: a third node-gate leg cannot be handed to + * same reason: a second node-gate leg cannot be handed to * {@link evaluateEnablementPredicate} without being declared above, which is * the same edit that puts it into the config-bag guard below. */ @@ -511,11 +524,11 @@ const isCelEnvelope = (value: unknown): boolean => * `has(record.x) && …` throws — and the two polarities of the chain then fail * in OPPOSITE directions, both silently: * - * * on the three SHOW legs the fail-soft `true` is negated to "do not hide", + * * on the SHOW legs the fail-soft `true` is negated to "do not hide", * so a gate authored to conceal renders on every row — measured in a real * browser against a real app instance (the card), where a duplicate-lead * banner showed on 6 of 6 leads whose field was `null`; - * * on the two HIDE legs the same `true` sets `_hidden`, so the node is not + * * on the HIDE leg the same `true` sets `_hidden`, so the node is not * on screen at all. * * Neither is distinguishable from a predicate that said so on purpose. That is @@ -536,14 +549,14 @@ const isCelEnvelope = (value: unknown): boolean => * * ## The enablement chain carries the identical defect (objectui#9107) * - * `disabled` / `disabledOn` / `enabled` sit in the same two bags and are + * `disabled` / `enabled` sit in the same two bags and are * flattened by the same two loops, but they are NOT visibility legs — they * route through {@link evaluateEnablementPredicate} and the action renderers' * own `useCondition` call. Measured on the pre-fix tree through this component, * with a CEL stdlib predicate authored at `properties` top level, and the two * halves fail in OPPOSITE directions just as the visibility polarities do: * - * * `disabled` / `disabledOn` — BOTH a holding and a failing predicate left + * * `disabled` — BOTH a holding and a failing predicate left * the control DISABLED. The flattened source faults on the legacy engine * and `evaluateCondition`'s fail-soft `true` is un-negated here, so every * CEL-authored enablement gate greyed its control out on every row, with @@ -769,7 +782,7 @@ export function evaluateConfigBagInScope( * ## The defect this closes * * `evaluateCondition` answers an unevaluable predicate with `true` on every - * path. On the three SHOW legs the chain negated that answer, so the node was + * path. On the SHOW legs the chain negated that answer, so the node was * SHOWN and the reporter's "the gate did NOT bite" was true (a declared SHOW * predicate now fails closed instead, see the last section). On the two HIDE * legs the chain returns it UN-negated (see `shouldHide` below), so the same @@ -795,7 +808,7 @@ export function evaluateConfigBagInScope( * sentence written about a different polarity is now a TYPE error here, not a * line of false console copy. * - * The `disabled` / `disabledOn` gate is deliberately not reachable from this + * The `disabled` gate is deliberately not reachable from this * function: those legs are not in the visibility chain and route through * `evaluateEnablementPredicate`, which states `'enablement'` at its own call * site. @@ -810,14 +823,14 @@ export function evaluateConfigBagInScope( * a missing function used to draw the node for a viewer it was written to * exclude. * - * - **SHOW legs** (`visibleWhen` / `visible` / `visibleOn`, negated by + * - **SHOW legs** (`visibleWhen` / `visible`, negated by * `shouldHide`): a DECLARED predicate answers `false`, so the node is * withheld, and the report prints the `'withholding'` consequence. A gate * that declares nothing ({@link hasDeclaredPredicate} is false: `''`, * whitespace, an empty-`source` envelope) keeps the fail-soft `true`, so it * still means "no gate" and the node still shows, with the `'visibility'` * copy, which is true of it. - * - **HIDE legs** (`hidden` / `hiddenOn`, NOT negated): the fail-soft `true` + * - **HIDE leg** (`hidden`, NOT negated): the fail-soft `true` * already means hidden, so the answer stays `true` and the copy stays * `'concealment'`. They only reach the evaluator declared (`shouldHide` * asks {@link hasDeclaredPredicate} first), so no blank case exists here. @@ -1285,7 +1298,7 @@ export const SchemaRenderer: ForwardRefExoticComponent< // The ambient host scope, plus the roots this tier can answer itself. // `current_user` is aliased to `user` so both `user.email` and - // `current_user.email` resolve in component `visible`/`visibleOn` + // `current_user.email` resolve in component `visible`/`visibleWhen` // expressions. `page` exposes page-local state so predicates can gate on // `page.` (e.g. a record picker's selection toggling another // component's visibility). @@ -1354,7 +1367,7 @@ export const SchemaRenderer: ForwardRefExoticComponent< * "could not evaluate" from a value into a throw. * * Until objectui#13299 this helper reproduced that `true` on a fault, which - * drew a node behind a faulting `visibleWhen` / `visible` / `visibleOn` for + * drew a node behind a faulting `visibleWhen` / `visible` for * a viewer the predicate was written to exclude. Both branches now answer a * fault with {@link visibilityFaultOutcome}'s verdict instead: a DECLARED * predicate that faults leaves the node unrendered on every leg (the SHOW @@ -1498,7 +1511,7 @@ export const SchemaRenderer: ForwardRefExoticComponent< }; /** - * Evaluate ONE enablement predicate (`disabled` / `disabledOn`), and make an + * Evaluate ONE enablement predicate (`disabled`), and make an * unresolvable one LOUD (objectui#6445). * * ## Why this pair needed its own card at all @@ -1545,7 +1558,7 @@ export const SchemaRenderer: ForwardRefExoticComponent< * objectui#6504: the 2026-08-22 ruling was scoped to the visibility gate, * and its message text ("a constant `false` hides the node on every row") * is not what a constant does to a `disabled` gate. The 2026-08-27 ruling - * answers both gaps: extend to `disabled` / `disabledOn`, dev-only exactly + * answers both gaps: extend to `disabled`, dev-only exactly * as the visibility leg is (⛔ always-on was excluded, outside the #5687 * precedent), with its OWN copy — the constant-`true` direction, since on * THIS gate that is the polarity that greys the control out in silence. @@ -1556,7 +1569,7 @@ export const SchemaRenderer: ForwardRefExoticComponent< * ## The `key` parameter is CLOSED (objectui#9107) * * Typed {@link EnablementNodeGateKey} rather than `string`, mirroring what - * {@link VisibilityChainKey} does for the sibling chain: the two legs this + * {@link VisibilityChainKey} does for the sibling chain: the leg this * function serves are declared in ONE place, and the config-bag guard * ({@link preservePredicateEnvelope}) reads that same declaration, so a * third leg cannot arrive here without the same edit also carrying its CEL @@ -2028,7 +2041,7 @@ export const SchemaRenderer: ForwardRefExoticComponent< propsWithoutCanonicalKeys(newSchema.props, newSchema.properties), ); - // Evaluate visibility: visibleWhen / visible / visibleOn / hidden / hiddenOn + // Evaluate visibility: visibleWhen / visible / hidden const shouldHide = (() => { // `visibleWhen` is the single canonical conditional-visibility predicate // across every layer since ADR-0089 (show-when-truthy), and it is the one @@ -2054,17 +2067,15 @@ export const SchemaRenderer: ForwardRefExoticComponent< return !evaluateVisibilityPredicate(newSchema.visibleWhen, 'visibleWhen'); } // `visible` — objectui's own `BaseSchema` tier (`@object-ui/types`), and - // the landing spot of a hoisted `properties.visible`. Kept ABOVE - // `BaseSchema`'s deprecated `visibleOn`: re-ranking it would move - // verdicts for raw metadata that objectui#5454 did not rule on. + // the landing spot of a hoisted `properties.visible`. The spec declares + // it on the nodes that reach this chain (an action's `visible`, a + // navigation entry's, `record:alert`'s), so it stays a leg. The + // objectui dialect `visibleOn` that used to follow it is RETIRED: a + // `?: never` tombstone on `BaseSchema`, refused by name on the zod + // mirror, and not consulted here. if (newSchema.visible !== undefined) { return !evaluateVisibilityPredicate(newSchema.visible, 'visible'); } - // `BaseSchema.visibleOn`, @deprecated ADR-0089 → `visibleWhen`. - // objectui's own node key; the spec's page component never declared it. - if (newSchema.visibleOn !== undefined) { - return !evaluateVisibilityPredicate(newSchema.visibleOn, 'visibleOn'); - } // Ask "is a `hidden` gate DECLARED?" — not "is the key present?" // (objectui#3955). These two legs are the only ones in this chain whose // verdict is NOT negated, so the evaluator's single default for "there is @@ -2082,13 +2093,12 @@ export const SchemaRenderer: ForwardRefExoticComponent< // would have been the Nth dialect of one question. The verdict still reads // the RAW value; only the gate in front of it narrowed. Not an // equivalence, and pinned as a behaviour change: an UNDECLARED `hidden` no - // longer short-circuits, so a declared `hiddenOn` is finally consulted. + // longer short-circuits. The `hiddenOn` dialect that used to follow it is + // RETIRED (tombstoned on `BaseSchema`, refused by name on the zod mirror): + // `hidden` takes the same predicate, so this leg is the whole hide chain. if (hasDeclaredPredicate(newSchema.hidden)) { return evaluateVisibilityPredicate(newSchema.hidden, 'hidden'); } - if (hasDeclaredPredicate(newSchema.hiddenOn)) { - return evaluateVisibilityPredicate(newSchema.hiddenOn, 'hiddenOn'); - } return false; })(); @@ -2096,7 +2106,7 @@ export const SchemaRenderer: ForwardRefExoticComponent< newSchema._hidden = true; } - // Evaluate disabled: disabled / disabledOn + // Evaluate disabled: disabled // // Ask "is a `disabled` gate DECLARED?" — not "is the key present?" // (objectui#3862). `!== undefined` was the widest of the three spellings this @@ -2110,11 +2120,11 @@ export const SchemaRenderer: ForwardRefExoticComponent< // as an internal flag either: `_disabled` is forwarded below as a real // `disabled` prop. // - // The `visible` / `visibleWhen` / `visibleOn` legs above keep + // The `visible` / `visibleWhen` legs above keep // `!== undefined` deliberately: their `true` is NEGATED, so an empty predicate // already lands on "shown", which is what "no gate" means anyway, and // narrowing them would change ALIAS PRECEDENCE rather than fix anything. The - // `hidden` / `hiddenOn` legs were the exception — not negated, so they carried + // `hidden` leg was the exception — not negated, so it carried // this same defect with the polarity that makes the node VANISH — and they now // read this same definition (objectui#3955). // @@ -2125,10 +2135,12 @@ export const SchemaRenderer: ForwardRefExoticComponent< // been a fourth dialect of one question, which is what objectui#3842 / // objectui#3849 spent two PRs merging away. The verdict still reads the RAW // value, exactly as before — only the gate in front of it narrowed. An - // undeclared `disabled` now falls through to `disabledOn` instead of - // short-circuiting on an empty predicate. + // undeclared `disabled` is "no gate" instead of a short-circuit on an empty + // predicate. The `disabledOn` dialect that used to follow it is RETIRED + // (tombstoned on `BaseSchema`, refused by name on the zod mirror): + // `disabled` takes the same predicate, so this leg is the whole chain. // - // Both legs route through `evaluateEnablementPredicate` (objectui#6445), so + // The leg routes through `evaluateEnablementPredicate` (objectui#6445), so // a predicate that cannot be evaluated is reported instead of silently // greying the control out. The verdicts below are unchanged: that helper // returns `evaluateCondition`'s own answer, and an UNDECLARED gate never @@ -2139,9 +2151,6 @@ export const SchemaRenderer: ForwardRefExoticComponent< if (hasDeclaredPredicate(newSchema.disabled)) { return evaluateEnablementPredicate(newSchema.disabled, 'disabled'); } - if (hasDeclaredPredicate(newSchema.disabledOn)) { - return evaluateEnablementPredicate(newSchema.disabledOn, 'disabledOn'); - } return false; })(); @@ -2325,10 +2334,14 @@ export const SchemaRenderer: ForwardRefExoticComponent< schema: _schema, visible: _visible, visibleWhen: _visibleWhen, - visibleOn: _visibleOn, hidden: _hidden, - hiddenOn: _hiddenOn, disabled: _disabled, + // stripped: the RETIRED node-level dialects (`?: never` tombstones on + // `BaseSchema`, refused by name on the zod mirror). Nothing above reads + // them; they are held out of the spread so a stale document's value never + // lands in the DOM as an unknown attribute, exactly as `body` is. + visibleOn: _visibleOn, + hiddenOn: _hiddenOn, disabledOn: _disabledOn, // stripped: `PageComponentSchema.dataSource` is the spec's per-element data // BINDING (`{ object, view, filter, sort, limit }`) — schema metadata, like diff --git a/ui/packages/react/src/__tests__/SchemaRenderer.concealmentGateFaultDiagnostic.test.tsx b/ui/packages/react/src/__tests__/SchemaRenderer.concealmentGateFaultDiagnostic.test.tsx index 125d5e361cf..398464476e2 100644 --- a/ui/packages/react/src/__tests__/SchemaRenderer.concealmentGateFaultDiagnostic.test.tsx +++ b/ui/packages/react/src/__tests__/SchemaRenderer.concealmentGateFaultDiagnostic.test.tsx @@ -14,11 +14,12 @@ * * `evaluateCondition` answers an unevaluable predicate with `true` on every one * of its paths. `SchemaRenderer`'s visibility chain NEGATES that answer on four - * of its six legs (`visibleWhen` / `visible` / `visibleOn` / `visibility`; the - * last was retired by objectui#12654, leaving three of five), so + * of its six legs (`visibleWhen` / `visible` / `visibleOn` / `visibility`; + * `visibility` was retired by objectui#12654 and `visibleOn` / `hiddenOn` since, + * leaving two SHOW legs and one HIDE leg of three), so * there the fail-soft default SHOWS the node and the reporter's sentence — "the * gate did NOT bite" — is exactly right. The chain returns it UN-negated on the - * other two (`hidden` / `hiddenOn`): the same `true` sets `_hidden` and + * other two (`hidden` / `hiddenOn`, now `hidden` alone): the same `true` sets `_hidden` and * `SchemaRenderer` returns `null`. The gate bit, and bit harder than on either * sibling gate — the node is not on screen at all — and those two legs were * handed the sentence that says the opposite. @@ -114,7 +115,7 @@ const TYPE = 'element:probe-6503'; /** What a VANISHED node's author must now read (objectui#6503). */ const CONCEALMENT_CONSEQUENCE = 'The node was treated as its safe default, which on THIS leg is the one\n' + - 'that BITES: `hidden` / `hiddenOn` are NOT negated, so that default\n' + + 'that BITES: `hidden` is NOT negated, so that default\n' + 'REMOVED the node - it is not on the page at all, and an absent node is\n' + 'indistinguishable from metadata that meant to hide it. Nothing is broken\n' + 'in the renderer: the block is missing because the predicate above could\n' + @@ -163,10 +164,8 @@ const ROW = { id: 'r1', status: 'open' }; */ const FAULT_BARE = 'nosuchroot6503.locked == true'; const FAULT_HIDDEN_DEV = 'devbadroot6503hidden.locked == true'; -const FAULT_HIDDENON_DEV = 'devbadroot6503hiddenon.locked == true'; const FAULT_VISIBLEWHEN_DEV = 'devbadroot6503vw.locked == true'; const FAULT_VISIBLE_DEV = 'devbadroot6503v.locked == true'; -const FAULT_VISIBLEON_DEV = 'devbadroot6503von.locked == true'; const FAULT_DISABLED_DEV = 'devbadroot6503dis.locked == true'; const FAULT_TEMPLATE_HIDDEN = '${tmplbadroot6503.locked == true}'; const FAULT_CEL_HIDDEN = { dialect: 'cel', source: 'record.bad6503(' }; @@ -393,7 +392,6 @@ describe('#6503 group 2 — the NEGATED legs are withheld and say so (flipped by const NEGATED: Array<[string, string]> = [ ['visibleWhen', FAULT_VISIBLEWHEN_DEV], ['visible', FAULT_VISIBLE_DEV], - ['visibleOn', FAULT_VISIBLEON_DEV], ]; for (const [key, source] of NEGATED) { @@ -417,34 +415,6 @@ describe('#6503 group 2 — the NEGATED legs are withheld and say so (flipped by } }); -/* -------------------------------------------------------------------------- * - * Group 3 — `hiddenOn`, the second non-negated leg. - * -------------------------------------------------------------------------- */ - -describe('#6503 group 3 — `hiddenOn` is the same gate and gets the same sentence', () => { - it('a faulting `hiddenOn` prints the concealment paragraph and the node vanishes', async () => { - await inProduction((mount) => { - const warn = spyWarn(); - mount([{ id: 'n1', hiddenOn: FAULT_BARE }]); - expect(shownCount()).toBe(0); - const line = reports(warn)[0]; - expect(line).toBeDefined(); - expect(line).toContain('hiddenOn'); - expect(line).toContain(CONCEALMENT_CONSEQUENCE); - expect(line).not.toContain(THE_FALSE_CLAUSE); - }); - }); - - it('development agrees on `hiddenOn` too', async () => { - await inDevelopment((mount) => { - const warn = spyWarn(); - mount([{ id: 'n1', hiddenOn: FAULT_HIDDENON_DEV }]); - expect(shownCount()).toBe(0); - expect(reports(warn)[0]).toContain(CONCEALMENT_CONSEQUENCE); - }); - }); -}); - /* -------------------------------------------------------------------------- * * Group 4 — the objectui#6038 pins this card must NOT break. * -------------------------------------------------------------------------- */ diff --git a/ui/packages/react/src/__tests__/SchemaRenderer.disabledDeclaredGate.test.tsx b/ui/packages/react/src/__tests__/SchemaRenderer.disabledDeclaredGate.test.tsx index 6a096acc741..67b4e886d08 100644 --- a/ui/packages/react/src/__tests__/SchemaRenderer.disabledDeclaredGate.test.tsx +++ b/ui/packages/react/src/__tests__/SchemaRenderer.disabledDeclaredGate.test.tsx @@ -45,15 +45,13 @@ * array, an `ast`-only envelope) → DISABLED since objectui#11358, reported: * a declared gate that cannot be evaluated faults, and a faulting * `disabled` greys the control out (it failed open, as "no gate", before). - * • `disabledOn` in each of those directions → the alias reads the same - * definition, not a copy of it. - * • precedence: an EMPTY `disabled` no longer short-circuits the chain, so a - * declared `disabledOn` is finally consulted. This case is the one that would - * stay green if someone "fixed" the defect by deleting the `disabled` leg. + * • an EMPTY `disabled` no longer short-circuits the chain: it is "no gate", + * and since the `disabledOn` spelling was retired (a `?: never` tombstone, + * refused by name) there is no second spelling left to consult. * • the `visible` chain, unchanged: it keeps `!== undefined` (its alias * precedence is load-bearing and its `true` is benign), so `visible: ''` * still renders and `visible: false` still hides. - * • the `hidden` / `hiddenOn` legs of that same chain, which are NOT negated + * • the `hidden` leg of that same chain, which is NOT negated * and therefore HAD this defect with the polarity that makes the node * VANISH. Measured while writing the equivalence cases, out of this ruling's * scope, filed as objectui#3955 and pinned here as a documented divergence @@ -65,10 +63,9 @@ * * ## Reverse verification (direction predicted before running) * - * Restoring `newSchema.disabled !== undefined` / `disabledOn !== undefined` must - * turn RED exactly the seven empty-shape cases on each key (`disabled`, - * `disabledOn`), their forwarding cases, the `0` junk case and the precedence - * case — and leave every `true` / `false` / expression / `visible` case GREEN. + * Restoring `newSchema.disabled !== undefined` must turn RED exactly the seven + * empty-shape cases on `disabled`, their forwarding cases, the `0` junk case and + * the empty-`disabled` case — and leave every `true` / `false` / expression / `visible` case GREEN. * Nothing here can go red in the other direction: the change only ever removes a * `disabled` prop, never adds one. * @@ -77,7 +74,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, cleanup } from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; @@ -158,11 +155,6 @@ describe('SchemaRenderer `disabled` — an empty predicate is not a declared gat expect(disabledProp()).toBe('absent'); }); - it.each(EMPTY_SHAPES)('disabledOn: $label → not disabled either (the alias reads the same definition)', ({ value }) => { - renderNode({ disabledOn: value }); - expect(disabledProp()).toBe('absent'); - }); - // objectui#11358: junk is a DECLARED gate that cannot be evaluated, not "no // gate". It reaches `evaluateEnablementPredicate`, which reports the fault // and returns the evaluator's fail-soft `true` — on this key, DISABLED, as @@ -219,10 +211,6 @@ describe('SchemaRenderer `disabled` — an empty predicate is not a declared gat expect(disabledProp()).toBe('absent'); }); - it('an expression-valued `disabledOn` keeps its verdict', () => { - renderNode({ disabledOn: '${data.readOnly}' }); - expect(disabledProp()).toBe('true'); - }); }); describe('SchemaRenderer `disabled` chain precedence (objectui#3862)', () => { @@ -233,21 +221,16 @@ describe('SchemaRenderer `disabled` chain precedence (objectui#3862)', () => { ComponentRegistry.unregister?.('probe-3862'); }); - it('an EMPTY `disabled` no longer short-circuits — a declared `disabledOn` is consulted', () => { + it('an EMPTY `disabled` is "no gate" — the control stays usable, and nothing else decides', () => { // Before: `'' !== undefined` won the chain, `evaluateCondition('')` was - // `true`, and the node was disabled for a reason no key stated. Now the - // empty leg is not a gate, so the declared alias decides. - renderNode({ disabled: '', disabledOn: '${data.readOnly}' }); - expect(disabledProp()).toBe('true'); - }); - - it('… and the alias verdict is honoured in the other direction too', () => { - renderNode({ disabled: { dialect: 'cel', source: '' }, disabledOn: '${data.unlocked}' }); + // `true`, and the node was disabled for a reason no key stated. The + // `disabledOn` dialect this fall-through once reached is RETIRED (a + // `?: never` tombstone, refused by name on the zod mirror), so an empty + // `disabled` is simply not a gate. + renderNode({ disabled: '' }); expect(disabledProp()).toBe('absent'); - }); - - it('a DECLARED `disabled` still wins over `disabledOn`', () => { - renderNode({ disabled: false, disabledOn: true }); + cleanup(); + renderNode({ disabled: { dialect: 'cel', source: '' } }); expect(disabledProp()).toBe('absent'); }); }); diff --git a/ui/packages/react/src/__tests__/SchemaRenderer.disabledGateFaultDiagnostic.test.tsx b/ui/packages/react/src/__tests__/SchemaRenderer.disabledGateFaultDiagnostic.test.tsx index 38226507b86..61b37c80f9e 100644 --- a/ui/packages/react/src/__tests__/SchemaRenderer.disabledGateFaultDiagnostic.test.tsx +++ b/ui/packages/react/src/__tests__/SchemaRenderer.disabledGateFaultDiagnostic.test.tsx @@ -7,7 +7,8 @@ */ /** - * objectui#6445 — the `disabled` / `disabledOn` node gate reports a faulting + * objectui#6445 — the `disabled` node gate (then a `disabled` / `disabledOn` + * pair; the second spelling is retired) reports a faulting * predicate, in BOTH builds, and says something TRUE about what its fail-soft * default did. * @@ -45,7 +46,7 @@ * printed. Neither pin alone can tell a working dedupe from a broken one, so * every dedupe case here pins both halves, and two cases pin the direction this * new call site made possible: the SAME predicate source authored on `disabled` - * and on `visibleWhen` (and on `disabled` and `disabledOn`) must produce TWO + * and on `visibleWhen` must produce TWO * lines. Without those, a key that collapsed the gates together would look * exactly like a working rate limit. * @@ -354,18 +355,6 @@ describe('#6445 group 1 — a faulting `disabled` gate is loud, and still disabl }); }); - it('the `disabledOn` alias reports too — the same wiring, not a copy of it', async () => { - await inProduction((mount) => { - const warn = spyWarn(); - mount([{ id: 'n1', disabledOn: FAULT_BARE }]); - expect(disabledProp()).toBe('true'); - const lines = reports(warn); - expect(lines).toHaveLength(1); - expect(lines[0]).toContain('disabledOn'); - expect(allWarnings(warn)).toHaveLength(1); - }); - }); - it('a `{ dialect: "cel" }` envelope fault warns ONCE — the engine\'s generic line is replaced, not joined', async () => { // The canonical engine already printed its own line here. The risk of this // wiring was two lines for one fault; the total-count pin refuses it. @@ -472,23 +461,6 @@ describe('#6445 group 2 — deduped per predicate SOURCE and per GATE', () => { }); }); - it('SEPARATING HALF, across KEYS: the same source on `disabled` and on `disabledOn` is two lines', async () => { - // `key` is in the dedupe key, so the two legs of this gate cannot silence - // each other. An author who wrote the same broken predicate on both hears - // about both. - await inProduction((mount) => { - const warn = spyWarn(); - mount([ - { id: 'n1', disabled: FAULT_BARE }, - { id: 'n2', disabledOn: FAULT_BARE }, - ]); - const lines = reports(warn); - expect(lines).toHaveLength(2); - expect(lines.some((l) => l.includes(' disabled: '))).toBe(true); - expect(lines.some((l) => l.includes(' disabledOn: '))).toBe(true); - }); - }); - it('SEPARATING HALF, across GATES: the same source on `disabled` and on `visibleWhen` is two lines, one per gate', async () => { // The direction this new call site made possible. Both gates share one // reporter and one `Set`; if the key ever stopped separating them, the @@ -553,9 +525,8 @@ describe('#6445 group 3 — nothing else became loud', () => { { id: 'e2', disabled: null }, { id: 'e3', disabled: ' ' }, { id: 'e4', disabled: { dialect: 'cel', source: '' } }, - { id: 'e5', disabledOn: '' }, ]); - expect(shownCount()).toBe(5); + expect(shownCount()).toBe(4); expect(disabledProp()).toBe('absent'); expect(reports(warn)).toHaveLength(0); const blank = allWarnings(warn).filter((m) => m.includes('[blank]')); @@ -605,7 +576,7 @@ describe('#6445 group 3 — nothing else became loud', () => { * wrong object), so none of it belongs on `reports()` / `UNRESOLVABLE_ENABLEMENT_PREFIX`. * -------------------------------------------------------------------------- */ -describe('#6504 group 6 — the adapter-only diagnostic, extended to `disabled` / `disabledOn`', () => { +describe('#6504 group 6 — the adapter-only diagnostic, extended to `disabled`', () => { it('THE acceptance criterion: the card\'s repro shape reports, dev-only, and the verdict is untouched (constant-FALSE direction)', async () => { await inDevelopment((mount) => { const warn = spyWarn(); @@ -708,14 +679,6 @@ describe('#6504 group 6 — the adapter-only diagnostic, extended to `disabled` }); }); - it('`disabledOn` reports too — the same wiring as `disabled`, not a copy of it', async () => { - await inDevelopment((mount) => { - const warn = spyWarn(); - mount([{ id: 'n1', disabledOn: "data.status == 'locked'" }]); - expect(adapterOnlyReports(warn)[0]).toContain('disabledOn'); - }); - }); - it('⛔ C excluded: a PRODUCTION build reaches the SAME verdict and stays silent — this leg is dev-only, exactly as its sibling is', async () => { await inProduction((mount) => { const warn = spyWarn(); diff --git a/ui/packages/react/src/__tests__/SchemaRenderer.enablementEnvelopeConfigBag.test.tsx b/ui/packages/react/src/__tests__/SchemaRenderer.enablementEnvelopeConfigBag.test.tsx index e5fe4649566..65558e5ecc5 100644 --- a/ui/packages/react/src/__tests__/SchemaRenderer.enablementEnvelopeConfigBag.test.tsx +++ b/ui/packages/react/src/__tests__/SchemaRenderer.enablementEnvelopeConfigBag.test.tsx @@ -12,7 +12,7 @@ * * ## The same two loops, a different chain * - * `disabled` / `disabledOn` / `enabled` sit in the same per-value `properties` / + * `disabled` / `enabled` (and the since-retired `disabledOn`) sit in the same per-value `properties` / * `props` loops objectui#9100 measured, and were handed to * `ExpressionEvaluator.evaluate`, whose FIRST action unwraps any * `{ source: string }` object down to its bare `source`. The envelope is the @@ -40,7 +40,7 @@ * whichever way it landed. The two halves land in opposite directions because * `evaluateCondition`'s fail-soft answer is `true` on every internal path, and * the renderers NEGATE the `enabled` leg (`disabled = !isEnabled`) while the - * node gate does not negate `disabled` / `disabledOn`. + * node gate does not negate `disabled`. * * ⇒ ⚠️ neither polarity alone detects this. A suite asserting only the DISABLED * case is green on the two broken `disabled` legs; a suite asserting only the @@ -86,7 +86,7 @@ const FAULTS = { dialect: 'cel', source: 'nope(((' }; * A control, in both senses of the word: a real `