diff --git a/.changeset/visiblewhen-describe-bound-roots.md b/.changeset/visiblewhen-describe-bound-roots.md new file mode 100644 index 0000000000..d726d79b90 --- /dev/null +++ b/.changeset/visiblewhen-describe-bound-roots.md @@ -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.`) 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.`. 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. diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 766d0a69b5..09e5b6967e 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -113,7 +113,7 @@ Interface-level page configuration (Airtable parity) | **style** | `Record` | optional | Inline styles or utility classes | | **className** | `string` | optional | CSS class names | | **responsiveStyles** | `{ large?: Record; medium?: Record; small?: Record; xsmall?: Record }` | 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.`. 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.`. 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 | diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index e21e1612a5..47e1000242 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -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.` (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.` + * (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 @@ -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.`. 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.`. ⚠️ 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 diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index e0c02000c8..0adac316af 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -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.`. + * TRUE (ADR-0089, canonical `*When` name). + * + * ## Contract-bound roots + * + * `record`, `current_user`, and page state as `page.`. + * + * `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.`. 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.`. 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.'),