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
15 changes: 15 additions & 0 deletions .changeset/visiblewhen-describe-bound-roots.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
---
'@objectstack/spec': patch
---

`visibleWhen`'s describe now states the roots it actually binds, split by what is contract and what is renderer behaviour.

`PageComponentSchema.visibleWhen` named three roots (`record`, `current_user`, `page.<var>`) while the shipping renderer binds nine. The describe now separates the two claims instead of widening one into the other:

- **Contract-bound**: `record`, `current_user` — with ADR-0068's aliases `user` and `ctx.user`, one object under three spellings — and `page.<var>`. These are transcribed from a ruling, not from the renderer: ADR-0068 D1 rules a predicate "evaluates identically in a formula, an RLS policy, and a client `visible` gate", and `EvalUser`'s docblock already states the same alias set for "client UI gates". The platform's own `sys_user` alert gate uses `ctx.user`, which the old describe implied was unavailable.
- **Renderer-provided, not guaranteed**: `app`, `features`, `os.user` and `data`. ADR-0068's Non-goals fence its ruling to the user object, so nothing rules these on this surface; they are recorded as measured behaviour rather than promised.
- **`data` is surface-dependent** and is now called out as such: the data-source **adapter** on a component node, the record **row** on a `page:tabs` item-level `visibleWhen`.

The `page:tabs` item `visibleWhen` (`ComponentSchema`) carried the identical three-root sentence plus a "binds the same environment as page-component `visibleWhen`" claim that measurement disproves; it is corrected the same way and now names its two real divergences (row-bound `data`, bare-field spread).

Describe/prose only — no accept/reject change, no shape change, no new keys.
2 changes: 1 addition & 1 deletion content/docs/references/ui/page.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,7 +113,7 @@ Interface-level page configuration (Airtable parity)
| **style** | `Record<string, string>` | optional | Inline styles or utility classes |
| **className** | `string` | optional | CSS class names |
| **responsiveStyles** | `{ large?: Record<string, string \| number>; medium?: Record<string, string \| number>; small?: Record<string, string \| number>; xsmall?: Record<string, string \| number> }` | optional | Per-breakpoint scoped style maps (ADR-0065) |
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Binds `record`, `current_user`, `page.<var>`. e.g. "page.selectedProjectId != ''" |
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.<var>`. The shipping renderer additionally mounts `app`, `features`, `os.user` and binds `data` to the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). ⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead. e.g. "page.selectedProjectId != ''" |
| **visibility** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. |
| **dataSource** | `{ object: string; view?: string; filter?: any; sort?: object[]; … }` | optional | Per-element data binding for multi-object pages |
| **responsive** | `{ breakpoint?: Enum<'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl'>; hiddenOn?: Enum<'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl'>[]; columns?: object; order?: object }` | optional | Responsive layout configuration |
Expand Down
29 changes: 26 additions & 3 deletions packages/spec/src/ui/component.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -662,8 +662,31 @@ export const PageTabsProps = strictObject({
* whole tab — header *and* panel — is omitted from the strip. This is the
* item-level complement to a child component's own `visibleWhen`, which
* hides only the panel content and would leave an empty tab header behind.
* Binds the same environment as page-component `visibleWhen`: `record` +
* `current_user`, plus page state as `page.<var>` (re-evaluated live).
*
* **Contract-bound roots**: `record`, `current_user` (ADR-0068 aliases
* `user` / `ctx.user` — one object, three spellings; see the reasoning on
* `PageComponentSchema.visibleWhen`), plus page state as `page.<var>`
* (re-evaluated live).
*
* ⚠️ **This surface is NOT the same environment as page-component
* `visibleWhen`, despite sharing the key name.** It is rendered by its own
* evaluator, and that evaluator differs on two points — both renderer
* behaviour, NOT contract-guaranteed:
*
* * **`data` is the record ROW here**, where the component-node evaluator
* binds it to the data-source ADAPTER. Same key, two meanings.
* * **The row's bare fields are spread flat**, so `status` resolves as
* well as `record.status`. The ambient scope is spread AFTER the row,
* so an ambient root (`app`, `features`, `user`, …) wins over a record
* field of the same name.
*
* Like the component-node surface it also mounts the ambient `app` /
* `features` / `os.user` roots, which no ADR rules for a UI predicate
* (ADR-0068's Non-goals: "only the user object is in scope here").
*
* Measured at the `.objectui-sha` pin `190fbd01d061`:
* `components/src/renderers/layout/containers.tsx:450-457`.
*
* Canonical `*When` name per ADR-0089 — this key is new, so the deprecated
* `visibility` / `visibleOn` aliases are NOT ACCEPTED on tab items: unlike
* the view/page surfaces that fold them into `visibleWhen` via
Expand All@@ -673,7 +696,7 @@ export const PageTabsProps = strictObject({
* being pointed AT `visibleWhen` is not the same as being accepted).
*/
visibleWhen: ExpressionInputSchema.optional().describe(
'Visibility predicate (CEL) — the whole tab (header + panel) is omitted when FALSE; the renderer falls back to the first visible tab when the active one is hidden. Binds `record`, `current_user`, `page.<var>`. ADR-0089 canonical name — `visible`/`showWhen`/`visibility`/`visibleOn` are all rejected here (not folded in), each with a pointer at this key.',
'Visibility predicate (CEL) — the whole tab (header + panel) is omitted when FALSE; the renderer falls back to the first visible tab when the active one is hidden. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user`), `page.<var>`. ⚠️ NOT the same environment as page-component `visibleWhen`: this surface\'s own evaluator binds `data` to the record ROW (not the data-source adapter) and also spreads the row\'s bare fields — renderer behaviour, NOT contract-guaranteed. ADR-0089 canonical name — `visible`/`showWhen`/`visibility`/`visibleOn` are all rejected here (not folded in), each with a pointer at this key.',
),
/**
* Stable URL token for this tab — the value `?tab=` carries and the
Expand Down
44 changes: 41 additions & 3 deletions packages/spec/src/ui/page.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -186,10 +186,48 @@ export const PageComponentSchema = lazySchema(() => strictObject({

/**
* Conditional-visibility predicate (CEL) — the component is rendered only when
* TRUE (ADR-0089, canonical `*When` name). Page predicates bind the live page
* surface: `record` + `current_user` plus page state as `page.<var>`.
* TRUE (ADR-0089, canonical `*When` name).
*
* ## Contract-bound roots
*
* `record`, `current_user`, and page state as `page.<var>`.
*
* `current_user` carries ADR-0068's aliases **`user`** and **`ctx.user`** —
* one object under three spellings. These are transcribed here as CONTRACT,
* not observed from the renderer: ADR-0068 D1 rules that a predicate
* "evaluates identically in a formula, an RLS policy, and a client `visible`
* gate", and `EvalUser`'s own docblock (`identity/eval-user.zod.ts`) states
* the same alias set for "every predicate surface (server formula, server
* RLS, **client UI gates**)". A page-component `visibleWhen` is a client UI
* gate, so naming only `current_user` here under-stated a ruling this
* package already makes elsewhere.
*
* ## Ambient roots — renderer behaviour, NOT contract-guaranteed
*
* The shipping renderer additionally mounts `app`, `features` and `os.user`
* from app-shell's `ExpressionProvider`, and binds `data`. **No ADR rules
* those on this surface**: ADR-0068's Non-goals fence its ruling to the user
* object ("only the user object is in scope here"), and ADR-0058 governs
* dialects/backends/fail-policy, not the UI root set. They are recorded as
* measured behaviour so an author is not misled into thinking they are
* unavailable — they are NOT promises, and widening the contract to cover
* them is an open question, deliberately not decided here.
*
* Measured at the `.objectui-sha` pin `190fbd01d061` (identical at objectui
* `origin/main` f2732afe): `app-shell/src/providers/ExpressionProvider.tsx:59`
* builds the ambient scope; `react/src/SchemaRenderer.tsx:463-471` composes
* this surface's evaluator.
*
* ## ⚠️ `data` is surface-dependent
*
* HERE `data` is the data-source **ADAPTER** — what `${data.total}` in a
* `properties` / `props` / `content` value interpolates against. On a
* `page:tabs` **item-level** `visibleWhen` a separate evaluator
* (`components/src/renderers/layout/containers.tsx:450-457`) binds `data` to
* the record **ROW** instead. Same key name, two bindings; see that key's own
* describe in `component.zod.ts` rather than assuming this one carries over.
*/
visibleWhen: ExpressionInputSchema.optional().describe("Visibility predicate (CEL) — component rendered only when TRUE. Binds `record`, `current_user`, `page.<var>`. e.g. \"page.selectedProjectId != ''\""),
visibleWhen: ExpressionInputSchema.optional().describe("Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.<var>`. The shipping renderer additionally mounts `app`, `features`, `os.user` and binds `data` to the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). ⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead. e.g. \"page.selectedProjectId != ''\""),
/** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */
visibility: ExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse.'),

Expand Down
Loading