From f43d856ca462eb33142f0d0b2d684acb27299944 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:58:30 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(spec):=20a=20page=20component's=20visib?= =?UTF-8?q?leWhen=20names=20no=20`data`=20root=20=E2=80=94=20the=20shippin?= =?UTF-8?q?g=20renderer=20mounts=20none?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `visibleWhen` describe and its "Ambient roots" docblock said the renderer "binds `data` to the data-source ADAPTER here". objectui's SchemaRenderer stopped binding `data` on this surface (ruling B on the renderer seam's data scope), so a `data.*` gate faults and the component renders in both polarities. Both faces now name what the renderer mounts (`features`, `os.user`), state that no `data` root is mounted, tie the one declared `data` read to the PLANNED API binding, and keep the `page:tabs` item evaluator's row binding. A new pin holds the claim; the `app`-root pin drops its two `data` survival controls. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf --- .../ui/expression-scope-app-root.pin.test.ts | 4 +- .../ui/expression-scope-data-root.pin.test.ts | 208 ++++++++++++++++++ packages/spec/src/ui/page.zod.ts | 51 +++-- 3 files changed, 246 insertions(+), 17 deletions(-) create mode 100644 packages/spec/src/ui/expression-scope-data-root.pin.test.ts diff --git a/packages/spec/src/ui/expression-scope-app-root.pin.test.ts b/packages/spec/src/ui/expression-scope-app-root.pin.test.ts index 0448ed75206..5941f10270d 100644 --- a/packages/spec/src/ui/expression-scope-app-root.pin.test.ts +++ b/packages/spec/src/ui/expression-scope-app-root.pin.test.ts @@ -101,8 +101,9 @@ describe('no UI prose face advertises `app` as an expression-scope root — the expect(mounts).not.toMatch(/`app`/); // Survival controls — deleting the token must not have taken the sentence with it. + // (`data` is no longer one of them: the renderer stopped mounting it too, and + // `expression-scope-data-root.pin.test.ts` holds that half.) for (const root of SURVIVING_ROOTS) expect(mounts).toContain(root); - expect(mounts).toContain('`data`'); expect(mounts).toContain('NOT contract-guaranteed'); // Contract-bound roots are a different clause and are untouched. @@ -117,7 +118,6 @@ describe('no UI prose face advertises `app` as an expression-scope root — the expect(s).not.toMatch(/`app`/); for (const root of SURVIVING_ROOTS) expect(s).toContain(root); - expect(s).toContain('binds `data`'); }); it('action.zod.ts — the param-level `visible` scope list', () => { diff --git a/packages/spec/src/ui/expression-scope-data-root.pin.test.ts b/packages/spec/src/ui/expression-scope-data-root.pin.test.ts new file mode 100644 index 00000000000..11da96ae2cc --- /dev/null +++ b/packages/spec/src/ui/expression-scope-data-root.pin.test.ts @@ -0,0 +1,208 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23036] A page component's `visibleWhen` has no `data` root, and neither of + * its two prose faces may say the shipping renderer mounts one. + * + * ## The fact being pinned + * + * objectui#9308 ruling B (2026-09-13) removed `data` as a runtime expression + * root: `SchemaRenderer` no longer publishes its injected data source as + * `data`, and the one scope channel left (`PredicateScopeProvider`) is fed in + * the shipping console only by app-shell's `ExpressionProvider`, which + * publishes `current_user`, `user`, `ctx.user`, `os.user` and `features`. So a + * `data.*` read in this predicate faults, and this surface answers a faulting + * predicate by rendering the component: `data.total > 0` and `data.total == 0` + * both show it. The `.describe()` said the renderer "binds `data` to the + * data-source ADAPTER here" — a gate written from it never gates, and nothing + * refuses it. #17203 corrected the same sentence for `app` + * (`expression-scope-app-root.pin.test.ts`); this file holds the `data` half. + * + * ## What is pinned: the claim, not the wording + * + * 1. The mounted-roots sentence names exactly `features` and `os.user`, on both + * faces (the published `.describe()` and the TSDoc an AI author reads). + * 2. On the published face, every sentence that names `data` says it is NOT + * mounted here, ties it to the PLANNED API binding, or is about the + * `page:tabs` item evaluator — the three readings that are true today. + * 3. The two texts in this file agree. The `visibleWhen` face names the + * PLANNED API binding as the only `data` carrier, which holds exactly while + * `ElementApiDataSourceSchema`'s own describe still carries + * `[PLANNED — not yet rendered]`. When that binding's renderer ships and its + * describe drops the marker, this pin goes red so the `visibleWhen` face is + * rewritten in the same change, not left behind. + * + * Rewording any of these sentences is free; a sentence that tells an author + * the renderer binds `data` on this surface is not. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; + +import { describe, it, expect } from 'vitest'; + +import { ElementApiDataSourceSchema, PageComponentSchema } from './page.zod'; + +const HERE = path.dirname(url.fileURLToPath(import.meta.url)); +const pageSource = fs.readFileSync(path.resolve(HERE, 'page.zod.ts'), 'utf8'); + +/** + * The published description of `PageComponentSchema.visibleWhen`. + * + * ⚠️ NOT `.shape` — ADR-0089 D3a made this schema a `.strict().transform(…)` + * pipe, so the object carrying the property descriptions is its INPUT side. + */ +const visibleWhenDescription = (): string => { + const shape = (PageComponentSchema as unknown as { + def: { in: { shape: Record } }; + }).def.in.shape; + const d = shape.visibleWhen.description; + expect(d, 'the describe() must exist — this pin is about its content').toBeTruthy(); + return d!; +}; + +/** + * The TSDoc block directly above the `visibleWhen:` property, with the comment + * gutter stripped and its paragraphs (headings included) kept apart by a blank + * line. Anchored on its first line and on the property itself, so a block that + * moves or loses either fails here instead of asserting over nothing. + */ +const visibleWhenDocblock = (): string => { + const start = pageSource.indexOf('Conditional-visibility predicate (CEL) — the component is rendered only when'); + const end = pageSource.indexOf('visibleWhen: EvaluatedExpressionInputSchema', start); + expect(start, 'docblock anchor not found — the pin has drifted off its site').toBeGreaterThan(-1); + expect(end, 'property anchor not found after the docblock').toBeGreaterThan(start); + return pageSource.slice(start, end) + .split('\n') + .map((line) => line.replace(/^\s*\*\/?\s?/, '').trimEnd()) + .join('\n') + .split(/\n\s*\n/) + .map((paragraph) => paragraph.replace(/\s*\n\s*/g, ' ').trim()) + .filter(Boolean) + .join('\n\n'); +}; + +/** + * Sentences of a prose face. A paragraph break ends one; a period, `!` or `?` + * ends one only when white space and a capital, a backtick or `⚠` follow — so + * dotted tokens (`os.user`, `page.`, `data.*`) and "e.g." stay inside + * their sentence. + */ +const sentences = (text: string): string[] => + text.split(/\n\n|(?<=[.!?])\s+(?=[A-Z`⚠])/).map((s) => s.trim()).filter(Boolean); + +/** The sentence a mounted-roots claim lives in, located by its anchor. */ +const sentenceAt = (text: string, anchor: string): string => { + const hit = sentences(text).find((s) => s.includes(anchor)); + expect(hit, `anchor not found — the pin has drifted off its site: ${anchor}`).toBeTruthy(); + return hit!; +}; + +/** Every backticked token in a sentence: the roots it names. */ +const backticked = (s: string): string[] => [...s.matchAll(/`([^`]+)`/g)].map((m) => m[1]); + +/** + * The published-face rule (2): sentences naming `data` that are none of the + * three true readings. Returned rather than asserted, so the controls below can + * run it on a text that MUST fail. + */ +const dataClaimsOutsideTheTrueReadings = (text: string): string[] => + sentences(text) + .filter((s) => s.includes('`data`')) + .filter((s) => !/\bNO `data`/.test(s) && !s.includes('PLANNED') && !s.includes('`page:tabs`')); + +/** The sentence shapes the retired claim was written in, on either face. */ +const RETIRED_CLAIM_SHAPES = [/binds `data` to the data-source/i, /`data` is the data-source/i] as const; + +const MOUNTED_ROOTS = ['features', 'os.user']; + +describe('a page component `visibleWhen` names no `data` root — the shipping renderer mounts none', () => { + describe('published face — `PageComponentSchema.visibleWhen`.describe()', () => { + it('the mounted-roots sentence names exactly `features` and `os.user`', () => { + const s = sentenceAt(visibleWhenDescription(), 'additionally mounts'); + expect(backticked(s)).toEqual(MOUNTED_ROOTS); + expect(s).toContain('NOT contract-guaranteed'); + }); + + it('states the absence, and no sentence claims `data` is bound here', () => { + const d = visibleWhenDescription(); + expect(d).toContain('mounts NO `data` root'); + expect(dataClaimsOutsideTheTrueReadings(d)).toEqual([]); + for (const shape of RETIRED_CLAIM_SHAPES) expect(d).not.toMatch(shape); + }); + + it('keeps the `page:tabs` item reading, which is still true: that evaluator binds `data` to the row', () => { + const s = sentenceAt(visibleWhenDescription(), '`page:tabs`'); + expect(s).toContain('`data`'); + expect(s).toContain('ROW'); + }); + + it('keeps the contract-bound roots, a different clause this card does not touch', () => { + const s = sentenceAt(visibleWhenDescription(), 'Contract-bound roots'); + for (const root of ['record', 'current_user', 'user', 'ctx.user']) expect(backticked(s)).toContain(root); + }); + }); + + describe('TSDoc face — the docblock above `visibleWhen` (no generator reads it; an AI author opening the file does)', () => { + it('the "Ambient roots" sentence names exactly `features` and `os.user`', () => { + const s = sentenceAt(visibleWhenDocblock(), 'The shipping renderer additionally mounts'); + expect(backticked(s)).toEqual([...MOUNTED_ROOTS, 'ExpressionProvider']); + }); + + it('states the absence, and carries neither shape the retired claim was written in', () => { + const doc = visibleWhenDocblock(); + expect(doc).toContain('mounts NO `data` root'); + for (const shape of RETIRED_CLAIM_SHAPES) expect(doc).not.toMatch(shape); + }); + }); + + describe('the two texts in this file agree — the PLANNED API binding is the only `data` carrier', () => { + it('`visibleWhen` names the PLANNED API binding as where `data` is declared', () => { + const s = sentences(visibleWhenDescription()).find((x) => x.includes('PLANNED')); + expect(s, 'the describe no longer names the planned binding').toBeTruthy(); + expect(s).toContain('`data`'); + expect(s).toContain("provider: 'api'"); + }); + + it('…which holds only while the API binding is still PLANNED and still declares a `data` read in `visibleWhen`', () => { + const api = ElementApiDataSourceSchema.description ?? ''; + expect( + api.startsWith('[PLANNED — not yet rendered]'), + 'The API binding is no longer marked PLANNED, so its renderer has shipped: on an element carrying ' + + 'that binding `data` is now mounted. Rewrite `PageComponentSchema.visibleWhen`\'s `data` sentence ' + + '(describe and docblock) in the same change, then this assertion.', + ).toBe(true); + expect(api).toContain('`data`'); + expect(api).toContain('`visibleWhen`'); + }); + }); + + describe('probe controls — a pass above is only a reading if these hold', () => { + /** + * DARK. The describe sentence this card replaced, verbatim. Rule (2) and + * the retired-shape list must both catch it, or their `toEqual([])` / + * `not.toMatch` arms above could be passing on a matcher that matches + * nothing. + */ + it('DARK: the replaced describe sentence fails rule (2) and the retired-shape list', () => { + const replaced = 'Contract-bound roots: `record`, `current_user`, and page state as `page.`. ' + + 'The shipping renderer additionally mounts `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.'; + expect(dataClaimsOutsideTheTrueReadings(replaced)).toHaveLength(1); + expect(backticked(sentenceAt(replaced, 'additionally mounts'))).toContain('data'); + expect(RETIRED_CLAIM_SHAPES.some((shape) => shape.test(replaced))).toBe(true); + }); + + /** DARK. The sentence splitter keeps dotted tokens and "e.g." inside one sentence. */ + it('DARK: dotted tokens do not split a sentence', () => { + expect(sentences('Mounts `os.user` and `page.`; e.g. "page.x != \'\'". Next one.')).toHaveLength(2); + }); + + /** DARK. A site renamed out from under this pin fails loudly, never vacuously. */ + it('DARK: a missing anchor fails the pin rather than passing over nothing', () => { + expect(() => sentenceAt(visibleWhenDescription(), 'no such anchor exists in this describe')).toThrow(); + }); + }); +}); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 7519cc64ca5..62efbb6f9da 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -632,29 +632,50 @@ export const PageComponentSchema = lazySchema(() => strictObject({ * ## Ambient roots — renderer behaviour, NOT contract-guaranteed * * The shipping renderer additionally mounts `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 + * from app-shell's `ExpressionProvider`. **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. + * Measured at the `.objectui-sha` pin `1fb1ced36243` (the four files below + * are identical at objectui `origin/main` `f228d710`): + * `app-shell/src/providers/ExpressionProvider.tsx:181-190` + * (`buildExpressionScope`) builds the ambient scope — `current_user`, + * `user`, `ctx.user`, `os.user`, `features`, and nothing else; + * `react/src/SchemaRenderer.tsx:665-676` (`configEvaluationScope`) composes + * this surface's evaluator from it, adding `record` (only when the page has + * a row) and `page`. * - * ## ⚠️ `data` is surface-dependent + * ## No `data` root on this surface * - * 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. + * The shipping renderer mounts NO `data` root here. objectui#9308 (ruling + * B, 2026-09-13) removed `data` as a runtime expression root: + * `SchemaRenderer` stopped publishing its injected data source under that + * name (`SchemaRenderer.tsx:1194`), and a data scope reaches a predicate + * only through the predicate-scope channel (`PredicateScopeProvider`), + * whose one shipping feeder, `ExpressionProvider`, publishes no `data`. So a + * `data.*` read here faults, and a faulting `visibleWhen` renders the + * component: `data.total > 0` and `data.total == 0` both show it, a gate + * that never gates. The row the page is about is `record`. + * + * The one `data` this surface declares is the PLANNED API binding's (see + * {@link ElementApiDataSourceSchema}): an element whose `dataSource` is + * `{ provider: 'api', … }` reads its bound value as `data` once that + * binding's renderer ships. No renderer reads that binding yet, so no + * element has a `data` to read today. + * + * ## ⚠️ A `page:tabs` item is a different evaluator + * + * On a `page:tabs` **item-level** `visibleWhen`, a separate evaluator + * (`components/src/renderers/layout/containers.tsx:642-649`) binds `data` to + * the record **ROW** and spreads the row's bare fields as well — a binding + * this surface does not have. See that key's own describe in + * `component.zod.ts` rather than assuming either environment carries over. */ - visibleWhen: EvaluatedExpressionInputSchema.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 `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 != ''\""), + visibleWhen: EvaluatedExpressionInputSchema.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 `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root here: a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record`. `data` is declared only for an element whose `dataSource` is the PLANNED API binding (`provider: 'api'`), which reads its bound value as `data` once that binding's renderer ships. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. \"page.selectedProjectId != ''\""), /** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */ visibility: EvaluatedExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse.'), From 96b07ccb9fae81ea921ab74278cb5c15179129ee Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 02:12:49 +0000 Subject: [PATCH 2/2] chore(spec): regenerate the page reference and add the patch changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gen:docs` republishes the corrected `PageComponent.visibleWhen` describe into content/docs/references/ui/page.mdx (both tables that carry it); the changeset declares `@objectstack/spec` patch, Clause-② no. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf --- .changeset/23036-visiblewhen-no-data-root.md | 13 +++++++++++++ content/docs/references/ui/page.mdx | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 .changeset/23036-visiblewhen-no-data-root.md diff --git a/.changeset/23036-visiblewhen-no-data-root.md b/.changeset/23036-visiblewhen-no-data-root.md new file mode 100644 index 00000000000..6a7716780c0 --- /dev/null +++ b/.changeset/23036-visiblewhen-no-data-root.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): a page component's `visibleWhen` describe no longer says the renderer binds `data` — the shipping renderer mounts no `data` root there (#23036) + +Clause-②: no + +The `.describe()` on `PageComponentSchema.visibleWhen` said "The shipping renderer additionally mounts `features`, `os.user` and binds `data` to the data-source ADAPTER here", and the TSDoc above it said the same. The console's renderer stopped binding `data` on this surface (objectui ruling on the renderer seam's data scope, 2026-09-13): a `data.*` read there now faults, and a faulting `visibleWhen` renders the component, so a gate written from the old text, such as `data.total > 0`, shows the component whether it is true or false. + +The describe now names what the renderer mounts beyond the contract-bound roots (`features` and `os.user`), states that no `data` root is mounted, and says the one declared `data` read on this surface is the PLANNED API binding's (`dataSource: { provider: 'api' }`), once that binding's renderer ships. The `page:tabs` item-level `visibleWhen`, a different evaluator that binds `data` to the record row, is still called out. Nothing an author can write changes: the accept set is the same, and only the description text and the generated reference page move. + +**If you wrote a page component gate on `data.*`:** gate on the page's row as `record.*`, or on page state as `page.`. diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 67f508ca6ce..ae7581fe574 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -301,7 +301,7 @@ View filter rule | **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. 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 `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 != ''" | +| **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 `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root here: a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record`. `data` is declared only for an element whose `dataSource` is the PLANNED API binding (`provider: 'api'`), which reads its bound value as `data` once that binding's renderer ships. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. 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?: object[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint | | **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -440,7 +440,7 @@ Print declaration: paper size, orientation, margins, the running header and foot | **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. 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 `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 != ''" | +| **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 `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root here: a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record`. `data` is declared only for an element whose `dataSource` is the PLANNED API binding (`provider: 'api'`), which reads its bound value as `data` once that binding's renderer ships. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. 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?: object[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint | | **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |