From 294bb56357b5d0b4f45bbb8f0ae6143dd14d0be9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 13:47:05 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(spec):=20conditional=20tabs=20?= =?UTF-8?q?=E2=80=94=20page:tabs=20items=20accept=20a=20visibleWhen=20CEL?= =?UTF-8?q?=20predicate=20(#2606)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-evaluated #2606 under ADR-0089: the proposal predates the ADR, which made visibleWhen the single canonical conditional-visibility key and demoted visibility/visibleOn to deprecated aliases. The new item-level key therefore ships as visibleWhen from day one; the aliases are NOT accepted on this new surface (no legacy metadata to alias for — Prime Directive #12, one strict contract over N dialects). Semantics: when the predicate evaluates FALSE the whole tab (header + panel) is omitted, unlike a child component's own visibleWhen which hides only the panel content and leaves an empty tab header. Binds the same environment as page-component visibleWhen: record + current_user + page.. Additive, back-compatible (optional field) → minor. Renderer lands in objectui (item filtering + active-tab fallback to the first visible tab). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FmuuXkuW3JH9LYXHHRnoQV --- .changeset/page-tabs-item-visible-when.md | 9 +++++ packages/spec/src/ui/component.test.ts | 40 +++++++++++++++++++++++ packages/spec/src/ui/component.zod.ts | 13 ++++++++ 3 files changed, 62 insertions(+) create mode 100644 .changeset/page-tabs-item-visible-when.md diff --git a/.changeset/page-tabs-item-visible-when.md b/.changeset/page-tabs-item-visible-when.md new file mode 100644 index 0000000000..29566832f5 --- /dev/null +++ b/.changeset/page-tabs-item-visible-when.md @@ -0,0 +1,9 @@ +--- +"@objectstack/spec": minor +--- + +Conditional tabs (#2606): `page:tabs` items accept an optional `visibleWhen` CEL predicate. When it evaluates FALSE the whole tab — header **and** panel — is omitted from the tab strip, unlike a child component's own `visibleWhen`, which hides only the panel content and leaves an empty tab header behind. The predicate binds the same environment as page-component `visibleWhen` (`record` + `current_user`, plus page state as `page.`) and is re-evaluated live when page variables change. + +Per ADR-0089 the key uses the canonical `*When` name from day one — the deprecated `visibility` / `visibleOn` aliases are **not** accepted on tab items (this surface is new; there is no legacy metadata to alias for). + +Additive and back-compatible: items without `visibleWhen` behave exactly as before. diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index a2adbde26e..a4e9ccdecb 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -75,6 +75,46 @@ describe('PageTabsProps', () => { it('should reject tabs without items', () => { expect(() => PageTabsProps.parse({})).toThrow(); }); + + // Conditional tabs (#2606) — item-level `visibleWhen` (ADR-0089 canonical name). + it('should accept an item-level visibleWhen predicate (bare CEL string → envelope)', () => { + const result = PageTabsProps.parse({ + items: [ + { label: 'Contracts', visibleWhen: 'record.status == "customer"', children: [] }, + { label: 'Details', children: [] }, + ], + }); + expect(result.items[0].visibleWhen).toEqual({ + dialect: 'cel', + source: 'record.status == "customer"', + }); + // Items without the predicate are untouched — additive, back-compatible. + expect(result.items[1].visibleWhen).toBeUndefined(); + }); + + it('should accept an item-level visibleWhen Expression envelope', () => { + const result = PageTabsProps.parse({ + items: [ + { + label: 'Contracts', + visibleWhen: { dialect: 'cel', source: "page.mode != ''" }, + children: [], + }, + ], + }); + expect(result.items[0].visibleWhen).toEqual({ dialect: 'cel', source: "page.mode != ''" }); + }); + + it('does NOT accept the deprecated `visibility` alias on tab items (new surface, canonical key only)', () => { + // ADR-0089 D2 aliases exist for keys with legacy metadata; tab items never + // had a visibility key, so only canonical `visibleWhen` is declared. The + // alias is not folded — it is dropped by parse like any unknown key. + const result = PageTabsProps.parse({ + items: [{ label: 'Contracts', visibility: 'record.status == "customer"', children: [] }], + }); + expect(result.items[0].visibleWhen).toBeUndefined(); + expect('visibility' in result.items[0]).toBe(false); + }); }); describe('PageCardProps', () => { diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index b2d05780fd..8233c5e550 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -35,6 +35,19 @@ export const PageTabsProps = z.object({ items: z.array(z.object({ label: I18nLabelSchema, icon: z.string().optional(), + /** + * Conditional tab (CEL, #2606): when the predicate evaluates FALSE the + * 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). + * Canonical `*When` name per ADR-0089 — this key is new, so the deprecated + * `visibility` / `visibleOn` aliases are NOT accepted on tab items. + */ + 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 (`visibility`/`visibleOn` aliases are not accepted here).', + ), children: z.array(z.unknown()).describe('Child components') })), /** ARIA accessibility */ From fdc345e0373e2fbe2bfff8b707a9370482d75069 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 14:46:19 +0000 Subject: [PATCH 2/2] test(dogfood): classify page:tabs item visibleWhen in the expression-surface ledger (#2606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADR-0058 D7 / ADR-0060 conformance ratchet re-discovers every ExpressionInputSchema field in packages/spec/src and fails the build if any is unclassified. The new tab-item visibleWhen surface (ui/component.zod.ts) needs a ledger row: it belongs to cel-ui (interpret, fail-soft-log) alongside the page/view visibility predicates — evaluated by the objectui page:tabs renderer. Fixes the Test Core / Dogfood Regression Gate failures on this branch. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FmuuXkuW3JH9LYXHHRnoQV --- packages/dogfood/test/expression-conformance.ledger.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/dogfood/test/expression-conformance.ledger.ts b/packages/dogfood/test/expression-conformance.ledger.ts index 9832bf765a..0fc9357a8c 100644 --- a/packages/dogfood/test/expression-conformance.ledger.ts +++ b/packages/dogfood/test/expression-conformance.ledger.ts @@ -125,6 +125,11 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ 'ui/view.zod.ts:visibleWhen', 'ui/view.zod.ts:visibleOn', 'ui/component.zod.ts:onSubmit', + // Conditional tabs (framework#2606): `page:tabs` item-level visibility. + // Canonical ADR-0089 `visibleWhen` from day one (no deprecated alias on + // this new surface). Interpreted by the objectui page:tabs renderer to + // omit the whole tab (header + panel) when FALSE. + 'ui/component.zod.ts:visibleWhen', 'system/settings-manifest.zod.ts:visible', ], },