Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/adr-0089-visibility-lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/lint": minor
"@objectstack/cli": minor
---

ADR-0089 D3b: add the `validateVisibilityPredicates` lint rule for conditional-visibility keys, wired into `os validate` and `os compile` as advisory warnings.

Two rules, both `warning` (never fail the build):

- `visibility-alias-deprecated` — a `visibleOn` (view form section/field) or `visibility` (page component) key in authored source. It still works — the schema normalizes it to `visibleWhen` at parse — but the canonical key is `visibleWhen`. Fix: rename the key (same CEL value).
- `visibility-root-mislayered` — a runtime view/page visibility predicate rooted at `data.` (the metadata-editing-form root). Runtime record surfaces bind `record` + `current_user` (pages also expose `page.<var>`), so a `data.`-rooted predicate here never matches and the element renders unconditionally. Fix: use `record.`/`page.`.

The rule runs on the **pre-parse** stack (like `validate-list-view-mode`) so it can see the deprecated alias the author actually wrote before the schema folds it into `visibleWhen`.
17 changes: 17 additions & 0 deletions .changeset/adr-0089-visible-when.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@objectstack/spec": minor
---

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.

**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`.

**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.

Migration (L1 — no consumer action required; existing metadata keeps working):

- View form section/field: `visibleOn: "<cel>"` → `visibleWhen: "<cel>"`
- Page component: `visibility: "<cel>"` → `visibleWhen: "<cel>"`
- Data field / field option: already `visibleWhen` — unchanged.

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.
2 changes: 1 addition & 1 deletion content/docs/data-modeling/formulas.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ piece of metadata needs to compute a value or evaluate a condition:
- **Formula fields** (`type: 'formula'`)
- **Predicates** — validation `condition`, sharing `condition`, field
conditional rules (`visibleWhen`, `readonlyWhen`, `requiredWhen`),
view section/column visibility (`visibleOn`), action `disabled`,
view section/column visibility (`visibleWhen`), action `disabled`,
hook `condition`, flow decisions
- **Dynamic seed values** — fixtures whose value depends on the install-time
clock or identity context
Expand Down
26 changes: 19 additions & 7 deletions content/docs/protocol/objectui/layout-dsl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -752,7 +752,7 @@ interface FormSection {
collapsible?: boolean; // default false
collapsed?: boolean; // default false
columns?: 1 | 2 | 3 | 4; // default 1
visibleOn?: string; // CEL visibility predicate; hides section when false
visibleWhen?: string; // CEL visibility predicate; hides section when false
fields: (string | FormField)[];
}
```
Expand All @@ -772,7 +772,7 @@ interface FormField {
readonly?: boolean;
hidden?: boolean;
widget?: string; // custom widget override
visibleOn?: string; // CEL visibility predicate
visibleWhen?: string; // CEL visibility predicate
}
```

Expand All @@ -794,18 +794,30 @@ interface ResponsiveColumns {
### Visibility Rule

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

```typescript
// e.g. on a PageComponent:
visibility: "record.account_type == 'premium'"
visibleWhen: "record.account_type == 'premium'"

// e.g. on a FormSection / FormField:
visibleOn: "record.status != 'closed' && user.hasRole('admin')"
visibleWhen: "record.status != 'closed' && user.hasRole('admin')"
```

The predicate's **binding root** is set by the layer, not the key:

| Layer | Predicate binds |
|---|---|
| Runtime record forms & pages (`*.view.ts`, `*.page.ts`) | `record` + `current_user` (pages also expose `page.<var>`) |
| Metadata-editing forms (`*.form.ts`) | `data` — the row under edit |

The legacy spellings `visibleOn` (view) and `visibility` (page) are `@deprecated`
aliases: still accepted and folded into `visibleWhen` at parse time, so existing
metadata keeps working. Author new metadata with `visibleWhen`.

Breakpoint-based show/hide is handled separately via the component's
`responsive.hiddenOn` array (e.g. `hiddenOn: ['xs', 'sm']`), see
`packages/spec/src/ui/responsive.zod.ts`.
Expand Down
4 changes: 2 additions & 2 deletions content/docs/ui/views.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ fields: [
required: true, // Override required
colSpan: 2, // Span 2 grid columns
widget: 'custom-editor', // Custom widget component
visibleOn: "status != 'cancelled'",
visibleWhen: "status != 'cancelled'",
},
]
```
Expand All @@ -250,7 +250,7 @@ fields: [
| `colSpan` | `1-4` | Column span in grid layout |
| `widget` | `string` | Custom widget/component name |
| `dependsOn` | `string` | Parent field for cascading |
| `visibleOn` | `string` | Visibility condition expression |
| `visibleWhen` | `string` | Visibility condition expression (was `visibleOn`, ADR-0089) |

### Default Sort for Related Lists

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0089-unify-visibility-predicate-naming.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-0089: Unify the conditional-visibility predicate under one name (`visibleWhen`), alias the rest

**Status**: Proposed (2026-07-05)
**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).
**Deciders**: ObjectStack Protocol Architects
**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)
**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`
Expand Down
2 changes: 1 addition & 1 deletion examples/app-showcase/src/ui/pages/contact-form.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export const ContactFormPage = definePage({
{
type: 'element:text',
id: 'ready_hint',
visibility: "page.inquiryEmail != ''",
visibleWhen: "page.inquiryEmail != ''",
properties: {
content: '✓ Looks good — hit Submit to send your inquiry.',
variant: 'caption',
Expand Down
10 changes: 5 additions & 5 deletions examples/app-showcase/src/ui/pages/page-variables.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { definePage } from '@objectstack/spec/ui';
* is `project_picker` (PageVariableSchema.source = that component id).
* 2. `element:record_picker` (id: project_picker) writes the chosen project's
* id into that variable on selection.
* 3. Sibling components gate on `page.selectedProjectId` via `visibility`:
* 3. Sibling components gate on `page.selectedProjectId` via `visibleWhen`:
* the empty-state hint shows while nothing is picked; the detail panel
* appears the moment a project is chosen — re-evaluated live, no reload.
*
Expand Down Expand Up @@ -69,7 +69,7 @@ export const PageVariablesPage = definePage({
{
type: 'element:text',
id: 'empty_hint',
visibility: "page.selectedProjectId == ''",
visibleWhen: "page.selectedProjectId == ''",
properties: {
content: '↑ Select a project above to reveal its detail panel.',
variant: 'caption',
Expand All @@ -79,12 +79,12 @@ export const PageVariablesPage = definePage({
{
type: 'element:divider',
id: 'detail_divider',
visibility: "page.selectedProjectId != ''",
visibleWhen: "page.selectedProjectId != ''",
},
{
type: 'element:text',
id: 'detail_heading',
visibility: "page.selectedProjectId != ''",
visibleWhen: "page.selectedProjectId != ''",
properties: {
content: '✓ Project selected',
variant: 'subheading',
Expand All @@ -93,7 +93,7 @@ export const PageVariablesPage = definePage({
{
type: 'element:text',
id: 'detail_body',
visibility: "page.selectedProjectId != ''",
visibleWhen: "page.selectedProjectId != ''",
properties: {
content:
'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.',
Expand Down
4 changes: 2 additions & 2 deletions examples/app-showcase/src/ui/views/task.view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,13 +239,13 @@ export const TaskViews = defineView({
{ field: 'status', required: true },
{ field: 'priority' },
{ field: 'due_date' },
// View-level conditional visibility (FormField.visibleOn, CEL):
// View-level conditional visibility (FormField.visibleWhen, CEL):
// the notes box only appears while the task is Urgent. Data-level
// counterpart is `visibleWhen` on invoice.paid_on.
// Width via the semantic `span` (#2578): 'full' = whole row at any
// derived column count — the primary primitive; absolute colSpan
// is legacy and lint-discouraged.
{ field: 'notes', visibleOn: P`record.priority == 'urgent'`, span: 'full' },
{ field: 'notes', visibleWhen: P`record.priority == 'urgent'`, span: 'full' },
],
},
],
Expand Down
8 changes: 4 additions & 4 deletions examples/app-showcase/test/page-variables.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { ShowcaseApp } from '../src/ui/apps/index.js';
type AnyComponent = {
type: string;
id?: string;
visibility?: unknown;
visibleWhen?: unknown;
[k: string]: unknown;
};

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

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

Expand All @@ -86,7 +86,7 @@ describe('Page Variables showcase — page-local state (ADR-0049)', () => {
let shownWhenEmpty = 0;
let shownWhenPicked = 0;
for (const c of gated) {
const src = predicateSource(c.visibility);
const src = predicateSource(c.visibleWhen);
expect(src, `gated component ${c.id} must carry a predicate`).toBeTruthy();
// Every gating predicate is about the page variable.
expect(src).toContain('page.selectedProjectId');
Expand All @@ -102,7 +102,7 @@ describe('Page Variables showcase — page-local state (ADR-0049)', () => {
// The empty-state predicate and the detail predicates are mutually exclusive:
// no gated node is visible in BOTH states.
for (const c of gated) {
const src = predicateSource(c.visibility)!;
const src = predicateSource(c.visibleWhen)!;
const inEmpty = evalPredicate(src, empty.page);
const inPicked = evalPredicate(src, picked.page);
expect(inEmpty && inPicked, `component ${c.id} should not be visible in both states`).toBe(false);
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/s
import { loadConfig } from '../utils/config.js';
import { lowerCallables } from '../utils/lower-callables.js';
import { validateStackExpressions } from '@objectstack/lint';
import { validateVisibilityPredicates } from '@objectstack/lint';
import { validateWidgetBindings } from '@objectstack/lint';
import { validateResponsiveStyles } from '@objectstack/lint';
import { validateSecurityPosture, buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
Expand Down Expand Up @@ -168,6 +169,20 @@ export default class Compile extends Command {
}
}

// 3b-bis. ADR-0089 D3b — deprecated visibility aliases + mis-layered
// binding root. Checked on `normalized` (PRE-parse): the schema folds
// `visibleOn`/`visibility` into `visibleWhen` at parse, so `result.data`
// no longer carries the alias the author wrote. Advisory, never fatal.
const visibilityFindings = validateVisibilityPredicates(normalized as Record<string, unknown>);
if (visibilityFindings.length > 0 && !flags.json) {
printWarning(`Visibility warnings (${visibilityFindings.length}) — ADR-0089`);
for (const f of visibilityFindings.slice(0, 50)) {
console.log(` • ${f.where}: ${f.message}`);
console.log(` ${f.hint}`);
console.log(` rule: ${f.rule} at ${f.path}`);
}
}

// 3c. Widget-binding diagnostics (issues #1719/#1721) — semantic checks
// that need the widget's `dataset` reference resolved to its dataset
// and `dimensions`/`values` resolved to declared names. Errors are
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { validateWidgetBindings } from '@objectstack/lint';
import { validateResponsiveStyles } from '@objectstack/lint';
import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint';
import { validateCapabilityReferences } from '@objectstack/lint';
import { validateVisibilityPredicates } from '@objectstack/lint';
import { validateSecurityPosture } from '@objectstack/lint';
import {
printHeader,
Expand Down Expand Up @@ -392,6 +393,15 @@ export default class Validate extends Command {
// 5. Warnings (non-blocking)
const warnings: string[] = [];

// ADR-0089 D3b — deprecated visibility aliases + mis-layered binding root.
// Checked on `normalized` (PRE-parse): the schema folds `visibleOn`/
// `visibility` into `visibleWhen` during parse, so `result.data` no longer
// carries the alias the author actually wrote.
const visibilityFindings = validateVisibilityPredicates(normalized as Record<string, unknown>);
for (const f of visibilityFindings) {
warnings.push(`${f.where}: ${f.message} — ${f.hint}`);
}

// ADR-0087 D2 conversion notices: the source used a deprecated shape that
// was auto-converted at load. No action is required to keep loading, but
// the notice steers the author to the canonical key before it retires.
Expand Down
7 changes: 7 additions & 0 deletions packages/dogfood/test/expression-conformance.ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,15 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [
// WITH an enforcement path.
'ui/action.zod.ts:visible',
'ui/app.zod.ts:visible',
// ADR-0089: `visibleWhen` is the canonical conditional-visibility key on
// page components and view form sections/fields; `visibility` (page) and
// `visibleOn` (view) stay accepted as deprecated aliases, normalized to
// `visibleWhen` at parse. Both spellings are live CEL surfaces until the
// aliases are removed in a future major, so both carry a ledger row.
'ui/page.zod.ts:visibleWhen',
'ui/page.zod.ts:visibility',
'ui/view.zod.ts:condition',
'ui/view.zod.ts:visibleWhen',
'ui/view.zod.ts:visibleOn',
'ui/component.zod.ts:onSubmit',
'system/settings-manifest.zod.ts:visible',
Expand Down
7 changes: 7 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ export {
} from './validate-form-layout.js';
export type { FormLayoutFinding, FormLayoutSeverity } from './validate-form-layout.js';

export {
validateVisibilityPredicates,
VISIBILITY_ALIAS_DEPRECATED,
VISIBILITY_ROOT_MISLAYERED,
} from './validate-visibility-predicates.js';
export type { VisibilityFinding, VisibilitySeverity } from './validate-visibility-predicates.js';

export {
validateCapabilityReferences,
CAPABILITY_REFERENCE_UNKNOWN,
Expand Down
Loading