Skip to content

Commit 0358d5a

Browse files
committed
feat(spec): unify conditional-visibility predicate under visibleWhen (ADR-0089)
Make `visibleWhen` the single canonical conditional-visibility key across all layers (data field, view form section/field, page component), aligning with the existing `readonlyWhen` / `requiredWhen` family and the resolved `conditionalRequired → requiredWhen` precedent. D1 — canonical key: accept `visibleWhen` on `FormFieldSchema` / `FormSectionSchema` (view.zod.ts) and `PageComponentSchema` (page.zod.ts), documenting the per-layer binding root (runtime surfaces bind `record` + `current_user` + `page.<var>`; metadata-editing forms bind `data`). D2 — aliases + normalization: mark the view `visibleOn` and page `visibility` keys `@deprecated`; fold them into `visibleWhen` once at the schema boundary via a shared `normalizeVisibleWhen` zod `.transform()` (canonical wins when both are present), so no consumer re-implements the fallback. Codemod first-party sources to canonical `visibleWhen`: metadata-editing forms (`*.form.ts`), the showcase view/pages, and the affected tests + docs + the objectstack-formula skill + the expression-conformance ledger (both spellings kept as live CEL surfaces during the deprecation window). Out of scope, unchanged: boolean `visible` (Tab on/off), field `hidden`, gallery `visibleFields`, and unrelated `visibility` enums (feed / package / environment / agent). The `.strict()` flip (D3a), the lint rule (D3b), and the ObjectUI renderer reads (sibling repo) are staged follow-ups per the ADR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017fU25GeYSPKq9eEURsQffF
1 parent e46169c commit 0358d5a

23 files changed

Lines changed: 269 additions & 87 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
ADR-0089: unify the conditional-visibility predicate under one canonical key, `visibleWhen`, across every layer (data field, view form section/field, page component). This aligns visibility with the existing `readonlyWhen` / `requiredWhen` family and the `conditionalRequired → requiredWhen` precedent.
6+
7+
**Canonical key:** `visibleWhen` — a CEL predicate; the element is shown only when it is TRUE. The binding *root* is still set by the layer: runtime record forms and pages bind `record` + `current_user` (pages also expose `page.<var>`); metadata-editing forms (`*.form.ts`) bind `data`.
8+
9+
**Deprecated aliases (still accepted):** the view key `visibleOn` and the page key `visibility` are now `@deprecated`. Both are folded into `visibleWhen` **once, at the schema boundary** (a zod `.transform()`), so consumers only ever read `visibleWhen`. When both a canonical and an alias key are present, the canonical wins.
10+
11+
Migration (L1 — no consumer action required; existing metadata keeps working):
12+
13+
- View form section/field: `visibleOn: "<cel>"``visibleWhen: "<cel>"`
14+
- Page component: `visibility: "<cel>"``visibleWhen: "<cel>"`
15+
- Data field / field option: already `visibleWhen` — unchanged.
16+
17+
Out of scope (unchanged): the boolean `visible` (Tab on/off), field `hidden`, gallery `visibleFields`, and unrelated `visibility` *enums* (feed / package / environment / agent). Aliases remain for the standard deprecation window and are removed in a future major.

content/docs/data-modeling/formulas.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ piece of metadata needs to compute a value or evaluate a condition:
1111
- **Formula fields** (`type: 'formula'`)
1212
- **Predicates** — validation `condition`, sharing `condition`, field
1313
conditional rules (`visibleWhen`, `readonlyWhen`, `requiredWhen`),
14-
view section/column visibility (`visibleOn`), action `disabled`,
14+
view section/column visibility (`visibleWhen`), action `disabled`,
1515
hook `condition`, flow decisions
1616
- **Dynamic seed values** — fixtures whose value depends on the install-time
1717
clock or identity context

content/docs/protocol/objectui/layout-dsl.mdx

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -752,7 +752,7 @@ interface FormSection {
752752
collapsible?: boolean; // default false
753753
collapsed?: boolean; // default false
754754
columns?: 1 | 2 | 3 | 4; // default 1
755-
visibleOn?: string; // CEL visibility predicate; hides section when false
755+
visibleWhen?: string; // CEL visibility predicate; hides section when false
756756
fields: (string | FormField)[];
757757
}
758758
```
@@ -772,7 +772,7 @@ interface FormField {
772772
readonly?: boolean;
773773
hidden?: boolean;
774774
widget?: string; // custom widget override
775-
visibleOn?: string; // CEL visibility predicate
775+
visibleWhen?: string; // CEL visibility predicate
776776
}
777777
```
778778

@@ -794,18 +794,30 @@ interface ResponsiveColumns {
794794
### Visibility Rule
795795

796796
Visibility is a single **CEL expression** string, not a structured rule object.
797-
Page components expose it as `visibility` (`packages/spec/src/ui/page.zod.ts`);
798-
form sections and fields expose it as `visibleOn` (`packages/spec/src/ui/view.zod.ts`).
799-
The component is shown only when the expression evaluates truthy.
797+
Since [ADR-0089](/docs/adr) the one canonical key is **`visibleWhen`** across every
798+
layer — data fields, view form sections/fields, and page components — aligning with
799+
the `readonlyWhen` / `requiredWhen` family. The element is shown only when the
800+
expression evaluates truthy.
800801

801802
```typescript
802803
// e.g. on a PageComponent:
803-
visibility: "record.account_type == 'premium'"
804+
visibleWhen: "record.account_type == 'premium'"
804805

805806
// e.g. on a FormSection / FormField:
806-
visibleOn: "record.status != 'closed' && user.hasRole('admin')"
807+
visibleWhen: "record.status != 'closed' && user.hasRole('admin')"
807808
```
808809

810+
The predicate's **binding root** is set by the layer, not the key:
811+
812+
| Layer | Predicate binds |
813+
|---|---|
814+
| Runtime record forms & pages (`*.view.ts`, `*.page.ts`) | `record` + `current_user` (pages also expose `page.<var>`) |
815+
| Metadata-editing forms (`*.form.ts`) | `data` — the row under edit |
816+
817+
The legacy spellings `visibleOn` (view) and `visibility` (page) are `@deprecated`
818+
aliases: still accepted and folded into `visibleWhen` at parse time, so existing
819+
metadata keeps working. Author new metadata with `visibleWhen`.
820+
809821
Breakpoint-based show/hide is handled separately via the component's
810822
`responsive.hiddenOn` array (e.g. `hiddenOn: ['xs', 'sm']`), see
811823
`packages/spec/src/ui/responsive.zod.ts`.

content/docs/ui/views.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ fields: [
233233
required: true, // Override required
234234
colSpan: 2, // Span 2 grid columns
235235
widget: 'custom-editor', // Custom widget component
236-
visibleOn: "status != 'cancelled'",
236+
visibleWhen: "status != 'cancelled'",
237237
},
238238
]
239239
```
@@ -250,7 +250,7 @@ fields: [
250250
| `colSpan` | `1-4` | Column span in grid layout |
251251
| `widget` | `string` | Custom widget/component name |
252252
| `dependsOn` | `string` | Parent field for cascading |
253-
| `visibleOn` | `string` | Visibility condition expression |
253+
| `visibleWhen` | `string` | Visibility condition expression (was `visibleOn`, ADR-0089) |
254254

255255
### Default Sort for Related Lists
256256

docs/adr/0089-unify-visibility-predicate-naming.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0089: Unify the conditional-visibility predicate under one name (`visibleWhen`), alias the rest
22

3-
**Status**: Proposed (2026-07-05)
3+
**Status**: Accepted (2026-07-14) — D1 + D2 + codemod + docs implemented in #2642. D3a (`.strict()` flip) and D3b (lint rule) deferred to follow-up per the ADR's staged rollout (sweep first, then flip).
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove — a declared-but-unchecked visibility key is exactly this class), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently-inert metadata — a mis-layered visibility key that zod strips is inert by accident), [ADR-0085](./0085-object-semantic-roles-over-surface-hint-blocks.md) (the enforce-or-remove pass that already deleted a consumer-less `visibleOn` from `fieldGroups`), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) (the AI-authoring population this ADR optimizes for), [ADR-0058](./0058-expression-and-predicate-surface.md) (the CEL predicate surface these keys all belong to), [ADR-0087](./0087-metadata-protocol-upgrade-contract.md) (conversion-over-notification; this rename ships as an L1 invisible break via the alias mechanism)
66
**Consumers**: `@objectstack/spec` (the field / view / page zod schemas + the alias normalization), `@objectstack/objectql` (`rule-validator` — the server-side enforcer), `@objectstack/lint` (the new mis-layer / wrong-root rule), ObjectUI form + page renderers, and every AI author of `*.object.ts` / `*.view.ts` / `*.page.ts`

examples/app-showcase/src/ui/pages/contact-form.page.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export const ContactFormPage = definePage({
9494
{
9595
type: 'element:text',
9696
id: 'ready_hint',
97-
visibility: "page.inquiryEmail != ''",
97+
visibleWhen: "page.inquiryEmail != ''",
9898
properties: {
9999
content: '✓ Looks good — hit Submit to send your inquiry.',
100100
variant: 'caption',

examples/app-showcase/src/ui/pages/page-variables.page.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { definePage } from '@objectstack/spec/ui';
1010
* is `project_picker` (PageVariableSchema.source = that component id).
1111
* 2. `element:record_picker` (id: project_picker) writes the chosen project's
1212
* id into that variable on selection.
13-
* 3. Sibling components gate on `page.selectedProjectId` via `visibility`:
13+
* 3. Sibling components gate on `page.selectedProjectId` via `visibleWhen`:
1414
* the empty-state hint shows while nothing is picked; the detail panel
1515
* appears the moment a project is chosen — re-evaluated live, no reload.
1616
*
@@ -69,7 +69,7 @@ export const PageVariablesPage = definePage({
6969
{
7070
type: 'element:text',
7171
id: 'empty_hint',
72-
visibility: "page.selectedProjectId == ''",
72+
visibleWhen: "page.selectedProjectId == ''",
7373
properties: {
7474
content: '↑ Select a project above to reveal its detail panel.',
7575
variant: 'caption',
@@ -79,12 +79,12 @@ export const PageVariablesPage = definePage({
7979
{
8080
type: 'element:divider',
8181
id: 'detail_divider',
82-
visibility: "page.selectedProjectId != ''",
82+
visibleWhen: "page.selectedProjectId != ''",
8383
},
8484
{
8585
type: 'element:text',
8686
id: 'detail_heading',
87-
visibility: "page.selectedProjectId != ''",
87+
visibleWhen: "page.selectedProjectId != ''",
8888
properties: {
8989
content: '✓ Project selected',
9090
variant: 'subheading',
@@ -93,7 +93,7 @@ export const PageVariablesPage = definePage({
9393
{
9494
type: 'element:text',
9595
id: 'detail_body',
96-
visibility: "page.selectedProjectId != ''",
96+
visibleWhen: "page.selectedProjectId != ''",
9797
properties: {
9898
content:
9999
'This panel is gated on `page.selectedProjectId != ""`. It became visible the instant the picker wrote the variable — the same page-local state any other component (or its data filter) can read.',

examples/app-showcase/src/ui/views/task.view.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,13 +239,13 @@ export const TaskViews = defineView({
239239
{ field: 'status', required: true },
240240
{ field: 'priority' },
241241
{ field: 'due_date' },
242-
// View-level conditional visibility (FormField.visibleOn, CEL):
242+
// View-level conditional visibility (FormField.visibleWhen, CEL):
243243
// the notes box only appears while the task is Urgent. Data-level
244244
// counterpart is `visibleWhen` on invoice.paid_on.
245245
// Width via the semantic `span` (#2578): 'full' = whole row at any
246246
// derived column count — the primary primitive; absolute colSpan
247247
// is legacy and lint-discouraged.
248-
{ field: 'notes', visibleOn: P`record.priority == 'urgent'`, span: 'full' },
248+
{ field: 'notes', visibleWhen: P`record.priority == 'urgent'`, span: 'full' },
249249
],
250250
},
251251
],

examples/app-showcase/test/page-variables.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { ShowcaseApp } from '../src/ui/apps/index.js';
2121
type AnyComponent = {
2222
type: string;
2323
id?: string;
24-
visibility?: unknown;
24+
visibleWhen?: unknown;
2525
[k: string]: unknown;
2626
};
2727

@@ -76,7 +76,7 @@ describe('Page Variables showcase — page-local state (ADR-0049)', () => {
7676

7777
it('gates its detail panel on the variable — hidden until a project is picked, shown after', () => {
7878
const comps = allComponents(PageVariablesPage);
79-
const gated = comps.filter((c) => c.visibility !== undefined);
79+
const gated = comps.filter((c) => c.visibleWhen !== undefined);
8080
// Empty-hint + divider + heading + body — every gated node references the variable.
8181
expect(gated.length).toBeGreaterThanOrEqual(2);
8282

@@ -86,7 +86,7 @@ describe('Page Variables showcase — page-local state (ADR-0049)', () => {
8686
let shownWhenEmpty = 0;
8787
let shownWhenPicked = 0;
8888
for (const c of gated) {
89-
const src = predicateSource(c.visibility);
89+
const src = predicateSource(c.visibleWhen);
9090
expect(src, `gated component ${c.id} must carry a predicate`).toBeTruthy();
9191
// Every gating predicate is about the page variable.
9292
expect(src).toContain('page.selectedProjectId');
@@ -102,7 +102,7 @@ describe('Page Variables showcase — page-local state (ADR-0049)', () => {
102102
// The empty-state predicate and the detail predicates are mutually exclusive:
103103
// no gated node is visible in BOTH states.
104104
for (const c of gated) {
105-
const src = predicateSource(c.visibility)!;
105+
const src = predicateSource(c.visibleWhen)!;
106106
const inEmpty = evalPredicate(src, empty.page);
107107
const inPicked = evalPredicate(src, picked.page);
108108
expect(inEmpty && inPicked, `component ${c.id} should not be visible in both states`).toBe(false);

packages/dogfood/test/expression-conformance.ledger.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,15 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [
114114
// WITH an enforcement path.
115115
'ui/action.zod.ts:visible',
116116
'ui/app.zod.ts:visible',
117+
// ADR-0089: `visibleWhen` is the canonical conditional-visibility key on
118+
// page components and view form sections/fields; `visibility` (page) and
119+
// `visibleOn` (view) stay accepted as deprecated aliases, normalized to
120+
// `visibleWhen` at parse. Both spellings are live CEL surfaces until the
121+
// aliases are removed in a future major, so both carry a ledger row.
122+
'ui/page.zod.ts:visibleWhen',
117123
'ui/page.zod.ts:visibility',
118124
'ui/view.zod.ts:condition',
125+
'ui/view.zod.ts:visibleWhen',
119126
'ui/view.zod.ts:visibleOn',
120127
'ui/component.zod.ts:onSubmit',
121128
'system/settings-manifest.zod.ts:visible',

0 commit comments

Comments
 (0)