From ebf513e0240de112fd2bf297e9d44d0169c1a2c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:17:45 +0000 Subject: [PATCH 01/11] =?UTF-8?q?wip(spec):=20retire=20page=20slots.tabs?= =?UTF-8?q?=20=E2=80=94=20tombstone,=20alias=20refusals,=20D2/D3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzff3QiP6kmtajVnKnq --- packages/spec/src/conversions/registry.ts | 91 ++++ packages/spec/src/conversions/walk.ts | 10 +- .../retired-keys/18.ui__Page__slots.tabs.ts | 15 + ....page-slots-details-beside-tabs-refused.ts | 58 --- .../semantic/18.page-slots-tabs-retired.ts | 51 ++ .../spec/src/migrations/registry.ts.template | 26 +- .../object-refinement-check-exports.test.ts | 38 +- packages/spec/src/ui/page-slot-pair.test.ts | 152 ------ .../src/ui/page-slots-tabs-retirement.test.ts | 449 ++++++++++++++++++ packages/spec/src/ui/page.zod.ts | 135 +++--- 10 files changed, 699 insertions(+), 326 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__Page__slots.tabs.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/18.page-slots-details-beside-tabs-refused.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.page-slots-tabs-retired.ts delete mode 100644 packages/spec/src/ui/page-slot-pair.test.ts create mode 100644 packages/spec/src/ui/page-slots-tabs-retirement.test.ts diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 831ca79e7f8..6ace9397810 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -7895,6 +7895,96 @@ const pageRequiresNonCompiledKindRemoved: MetadataConversion = { }, }; +/** + * `page.slots.tabs` removed (protocol 18, ADR-0085; #27053, the record-page v3 + * definition-surface ruling of 2026-10-28: keep the object's roles, its + * relationship flags, six slotted slots and `kind: 'full'`; retire the `tabs` + * slot). + * + * The slot replaced the WHOLE synthesized tab strip — the related lists and the + * approvals, attachments and history tabs the platform composes from the + * object were gone unless the author re-added each one — and it opted the page + * out of the record page's column tiers, so the page stayed tabbed at every + * width. The parse refuses it (a `retiredKey()` tombstone on the slot map, + * `ui/page.zod.ts`); this is the data-at-rest half. + * + * **A strip, not a rewrite.** The mechanical half deletes the slot, so a stored + * page that carried it renders the synthesized strip (and the columns) again, + * its other slots unchanged; a page whose ONLY slot was `tabs` is left an empty + * slotted page, which is exactly the synthesized default. Whether a page whose + * strip was curated — a Security tab, a custom first tab, a list the object's + * relationship flags do not produce — should instead become a `kind: 'full'` + * page that draws that strip as authored is a judgement no walker can make: the + * D3 semantic entry `page-slots-tabs-retired` names it. + * + * **Retired from the load path, from day one**: the authoring door keeps the + * tombstone's prescription; stored rows, artifacts and `os migrate meta` replay + * this. Idempotent by construction (`stripKeys` skips an absent key). + * + * ⚠️ Coverage boundary: this walks `stack.pages[]` ({@link mapPages}). `slots` + * is a top-level `PageSchema` key with no nested spelling, and the slot map is + * the only place a `tabs` slot was authorable. + */ +const pageSlotsTabsRemoved: MetadataConversion = { + id: 'page-slots-tabs-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '18.2.0', + surface: 'page.slots.tabs', + summary: + "page slot 'tabs' removed (it replaced the whole synthesized tab strip and kept the page tabbed " + + "at every width; the record page composes its strip and columns from the object's roles and " + + "relationship flags, ADR-0085, and a page that needs its own strip is kind: 'full')", + apply(stack, emit) { + return mapPages(stack, (page, path) => { + const slots = page.slots; + if (!isDict(slots)) return page; + const next = stripKeys(slots, ['tabs'], emit, `${path}.slots`); + return next === slots ? page : { ...page, slots: next }; + }); + }, + fixture: { + before: { + pages: [ + // A slotted page whose only slot was the strip: left an empty slotted + // page, the synthesized default. + { + name: 'project_detail', + type: 'record', + object: 'project', + kind: 'slotted', + slots: { tabs: { type: 'page:tabs', properties: { items: [{ label: 'Related', children: [] }] } } }, + }, + // The strip beside other slots: only the strip goes. + { + name: 'user_detail', + type: 'record', + object: 'person', + kind: 'slotted', + slots: { highlights: { type: 'record:highlights' }, tabs: [{ type: 'page:tabs' }], discussion: [] }, + }, + // A page with no strip keeps its identity (copy-on-write). + { name: 'account_detail', type: 'record', object: 'account', kind: 'slotted', slots: { discussion: [] } }, + ], + }, + after: { + pages: [ + { name: 'project_detail', type: 'record', object: 'project', kind: 'slotted', slots: {} }, + { + name: 'user_detail', + type: 'record', + object: 'person', + kind: 'slotted', + slots: { highlights: { type: 'record:highlights' }, discussion: [] }, + }, + { name: 'account_detail', type: 'record', object: 'account', kind: 'slotted', slots: { discussion: [] } }, + ], + }, + // One per stripped slot: the two pages that carried `tabs`. + expectedNotices: 2, + }, +}; + /** * `page.template: 'full-width'` → `'default'` (protocol 18). The #23610 ruling * (A on Q2, record 6190536588) closed the template set and retired `full-width` @@ -14339,6 +14429,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: pageListViewObjectNameViewTypeToDataType, order: 68 }, { conversion: pageMainRegionWidthRemoved, order: 69 }, { conversion: pageRequiresNonCompiledKindRemoved, order: 58 }, + { conversion: pageSlotsTabsRemoved, order: 78 }, { conversion: pageTemplateFullWidthToDefault, order: 67 }, { conversion: permissionAdminScopeBindingAuthoringRemoved, order: 65 }, { conversion: permissionAllowRestorePurgeRemoved, order: 16 }, diff --git a/packages/spec/src/conversions/walk.ts b/packages/spec/src/conversions/walk.ts index 8eb1d2ede23..9ee89145e68 100644 --- a/packages/spec/src/conversions/walk.ts +++ b/packages/spec/src/conversions/walk.ts @@ -343,8 +343,8 @@ function mapComponentTree( * component's `properties` containers**, to any depth. * * `mapper` receives each component dict and its path - * (`pages[i].regions[j].components[k]`, `pages[i].slots.tabs`, - * `pages[i].slots.tabs[0]`, + * (`pages[i].regions[j].components[k]`, `pages[i].slots.details`, + * `pages[i].slots.details[0]`, * `pages[i].regions[j].components[k].properties.items[0].children[1]`) and * returns the same reference (no change) or a new dict. Every container on the * way — the stack, `pages`, a page, its `regions`, a region, its `components`, @@ -358,10 +358,12 @@ function mapComponentTree( * the parse) covers instead. Both halves failed where it counted: * * - **`slots` (#6776)** is as typed as a region component — `PageSchema.slots` - * is a closed map of seven named slots, each + * is a closed map of named slots, each * `z.union([PageComponentSchema, z.array(PageComponentSchema)])` — and is * the canonical authoring shape for a `kind: 'slotted'` record page. All - * four in-repo `page:tabs` sites are `slots.tabs`, so a region-only rewrite + * four in-repo `page:tabs` sites were then `slots.tabs` (a slot retired + * since, #27053, whose stored copies are still walked before the + * `page-slots-tabs-removed` strip runs), so a region-only rewrite * left `os migrate meta` unable to touch the only shape that key is written * in, while the tombstone's prescription promised it would. * - **Container nesting (#6775)** is where "the tombstone covers it" fails: a diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__Page__slots.tabs.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__Page__slots.tabs.ts new file mode 100644 index 00000000000..bb16f9fd294 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__Page__slots.tabs.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// ADR-0085 (the record page is composed from the object's roles and +// relationship flags); the record-page v3 definition-surface ruling of +// 2026-10-28 (card #27053): keep six slotted slots and `kind: 'full'`, retire +// the `tabs` slot. `page.slots.tabs` replaced the whole synthesized tab strip — +// the related lists, approvals, attachments and history the platform composes +// from the object were dropped unless re-added by hand — and opted the page out +// of the record page's column tiers. `PageSchema` is reachable from the `page` +// metadata-type root, so the slot is not deleted from the shape: it stays as a +// `retiredKey()` tombstone that carries the prescription. The two alias entries +// that steered an authored `related:` / `relatedLists:` INTO this slot became +// refusals with the same remedy in the same change. D2: `page-slots-tabs-removed`; +// D3 semantic: `page-slots-tabs-retired`. +export const entry = 'ui/Page:slots.tabs'; diff --git a/packages/spec/src/migrations/entries/semantic/18.page-slots-details-beside-tabs-refused.ts b/packages/spec/src/migrations/entries/semantic/18.page-slots-details-beside-tabs-refused.ts deleted file mode 100644 index 853be1894b3..00000000000 --- a/packages/spec/src/migrations/entries/semantic/18.page-slots-details-beside-tabs-refused.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -// #22568 — page `slots.details` refused beside `slots.tabs`. A narrowing of -// the slot map, not a key removal: both slots stay live on their own, so there -// is no tombstone and no RETIRED_KEYS_BY_MAJOR row — the parse refuses the -// pair through `checkPageSlotPair` (ui/page.zod.ts). No D2 conversion: where -// the details body goes inside an authored tab strip (which item, under which -// label) is the author's call, and the move makes visible a body that never -// rendered. -// -// No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it -// inside a code span and a table cell. -export const entry: SemanticMigration = { - id: 'page-slots-details-beside-tabs-refused', - surface: - 'page.slots.details authored beside page.slots.tabs on one page — either slot a single component ' - + 'or an array, an empty details array included, whatever the page kind', - replacement: - 'One `tabs` slot whose items carry the details body: the `record:details` component (its ' - + '`sections` and `hideFields` unchanged) as the `children` of a `tabs` item, the first one by ' - + 'convention — e.g. `{ label: \'Details\', children: [{ type: \'record:details\', properties: { … } }] }` ' - + '— and no `details` slot. To keep the synthesized tab strip with the authored details body in ' - + 'its Details tab instead, delete `slots.tabs`.', - reason: - 'The slot map declared `details` and `tabs` as two independent optional slots, and they are not: ' - + 'on a slotted record page `details` replaces the body of the Details tab, that tab lives inside ' - + 'the synthesized `page:tabs` strip, and `tabs` replaces the whole strip. The console\'s ' - + 'default-page synthesizer therefore reads `tabs` and never reads `details` when both are ' - + 'authored, so the pair passed `PageSchema.parse`, `objectstack validate` and the metadata save ' - + 'door while the authored details body — its sections, its hidden fields — silently never ' - + 'applied. The platform\'s own `sys_user_detail` page authored both, so its Identity and Audit ' - + 'sections never showed and the ban columns it hides were never hidden by it; it now carries its ' - + '`record:details` as the first `tabs` item. The parse now refuses the pair at `slots.details`, ' - + 'naming both slots and the fix: `definePage`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), ' - + '`objectstack validate` and the metadata save door (`422 INVALID_METADATA`). ' - + 'Measured reach before the narrowing: in this repository only `sys_user_detail` authored the ' - + 'pair (no example app page does), and hotcrm\'s one slotted page authors `header` and ' - + '`discussion` only. ' - + '⚠️ No D2 conversion: which tab item carries the details body, and under which label, is the ' - + 'author\'s decision, and moving it makes visible a body that never rendered — a change to the ' - + 'page, not a respelling. ' - + '⚠️ A page row already stored with the pair is replayed unchanged at load (no conversion ' - + 'touches it), so it renders as before — the authored tabs, without the details body — while ' - + 'its read diagnostics name the pair and saving it again is refused until the details body ' - + 'moves. ADR-0087.', - acceptanceCriteria: - 'Grep every page in `defineStack` pages sources, exported stacks and every page row in ' - + '`sys_metadata` for a `slots` map carrying both a `details` and a `tabs` key. For each, move the ' - + '`details` component(s) into the `tabs` items — as the `children` of a tab item, the first one by ' - + 'convention, with its `sections` and `hideFields` unchanged — and delete `slots.details`, or ' - + 'delete `slots.tabs` to keep the synthesized tabs. Then `objectstack validate` reports nothing ' - + 'at `pages.N.slots.details`, saving each formerly affected page through the metadata API ' - + 'succeeds instead of answering a 422 that names `slots.details`, and the record page shows the ' - + 'authored details body (its sections, without its hidden fields) in the tab that now carries it. ' - + 'A page authoring only one of the two slots parses and renders byte-identically to before.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/18.page-slots-tabs-retired.ts b/packages/spec/src/migrations/entries/semantic/18.page-slots-tabs-retired.ts new file mode 100644 index 00000000000..7388a5a9ddb --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.page-slots-tabs-retired.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #27053 — page `slots.tabs` retired (ADR-0085; the record-page v3 +// definition-surface ruling of 2026-10-28). It absorbs the protocol-18 entry +// `page-slots-details-beside-tabs-refused` (#22568): with no `tabs` slot there +// is no pair to refuse, and that entry's prescription (move the details body +// INTO the `tabs` items) pointed at the key this one retires. +// +// No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it +// inside a code span and a table cell. +export const entry: SemanticMigration = { + id: 'page-slots-tabs-retired', + surface: + 'page.slots.tabs — the slot that replaced the record page tab strip, a single component or an ' + + 'array, whatever the page kind; and the slot map spellings related and relatedLists', + replacement: + 'Nothing on the slot map: the record page composes its tabs, and its columns at wider widths, from ' + + 'the object\'s roles and relationship flags (ADR-0085). To change which related lists appear or ' + + 'their order, set `relatedList` / `relatedList: \'primary\'` on the child\'s reference field; to ' + + 'replace the Details body, author `slots.details`. A page that needs its own strip — a curated ' + + 'tab set, a tab the object does not produce — is a `kind: \'full\'` page whose regions carry the ' + + 'header, the highlights, the `page:tabs` strip and the discussion, drawn as authored.', + reason: + 'An authored `tabs` slot replaced the whole synthesized strip, so the related lists, approvals, ' + + 'attachments and history the platform composes from the object were dropped unless the author ' + + 're-added each one, and it opted the page out of the record page\'s column tiers, so the page ' + + 'stayed tabbed at every width. The maintainer\'s keep / narrow / retire ruling on the record-page ' + + 'definition surfaces kept the object roles, the relationship flags, six slotted slots and ' + + '`kind: \'full\'`, and retired this slot. The D2 conversion `page-slots-tabs-removed` STRIPS the ' + + 'slot mechanically, so a stored page renders the synthesized strip again; but a strip that was ' + + 'curated (a custom first tab, a tab of controls, related lists the relationship flags do not ' + + 'produce) is lost by the strip, and whether such a page should become `kind: \'full\'` instead is ' + + 'a judgement no walker can make. Measured reach before the narrowing: four pages in this ' + + 'repository authored the slot (the platform\'s organization, user and approval-request record ' + + 'pages and one example page), each moved to `kind: \'full\'` with its strip unchanged; hotcrm\'s ' + + 'one slotted page authors `header` and `discussion` only. This entry also absorbs ' + + '`page-slots-details-beside-tabs-refused`: with no `tabs` slot the `details` + `tabs` pair cannot ' + + 'be authored, and its refusal left with it. ADR-0087.', + acceptanceCriteria: + 'Grep every page in `defineStack` pages sources, exported stacks and every page row in ' + + '`sys_metadata` for a `slots` map carrying a `tabs`, `related` or `relatedLists` key. For each, ' + + 'either delete the key (the D2 conversion does this for `tabs`) and, where a related list must ' + + 'appear, be promoted or be hidden, set `relatedList` on the child\'s reference field; or, where the ' + + 'strip was curated, rewrite the page as `kind: \'full\'` with `regions` carrying the header, the ' + + 'highlights, the same `page:tabs` strip and the discussion. Then `objectstack validate` reports ' + + 'nothing at `pages.N.slots.tabs`, saving each formerly affected page through the metadata API ' + + 'succeeds, and each record page shows either the synthesized strip (and the columns at wider ' + + 'widths) or, for a `kind: \'full\'` page, the same tabs it showed before.', +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index 9645c1ad7c4..c1f38ac6054 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -2587,19 +2587,19 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'record is the semantic entry `page-requires-non-compiled-kind-refused`.', }, { - id: 'page-slots-details-beside-tabs-refused', - order: 92, - text: - 'It also refuses a page that authors both `slots.details` and `slots.tabs`. The two slots read ' - + 'as independent and are not: `details` is the body of the Details tab, that tab lives inside ' - + 'the synthesized `page:tabs` strip, and `tabs` replaces the whole strip, so the console\'s ' - + 'synthesizer never read a `details` authored beside it and its sections and hidden fields ' - + 'silently never applied. `PageSchema` now refuses the pair at `slots.details`, naming both ' - + 'slots and the fix — the `record:details` component as the `children` of a `tabs` item — so ' - + '`objectstack validate` and the metadata save door agree; the platform\'s own `sys_user_detail` ' - + 'page moved its details body into its first tab. No key is removed, so there is no tombstone, ' - + 'and no D2 conversion exists: which tab carries the body is the author\'s decision. Its D3 ' - + 'record is the semantic entry `page-slots-details-beside-tabs-refused`.', + id: 'page-slots-tabs-retired', + order: 112, + text: + 'It also retires the record page\'s `slots.tabs` (ADR-0085; maintainer ruling on the record-page ' + + 'definition surfaces): the slot replaced the whole synthesized tab strip, dropping the related ' + + 'lists, approvals, attachments and history the platform composes from the object, and kept the ' + + 'page tabbed at every width instead of taking the record page\'s columns. The slot map keeps ' + + 'six slots; `slots.tabs` is a tombstone carrying the prescription, and `related` / ' + + '`relatedLists`, which were aliases into it, are refused with the same remedy: set ' + + '`relatedList` on the child\'s reference field, author `slots.details` for the Details body, and ' + + 'make a page that needs its own strip `kind: \'full\'`. The `details` + `tabs` pair refusal left ' + + 'with the slot. The D2 conversion `page-slots-tabs-removed` strips the slot from stored pages, ' + + 'retired from the load path; its D3 record is the semantic entry `page-slots-tabs-retired`.', }, { id: 'permission-admin-scope-binding-authoring-retired', diff --git a/packages/spec/src/ui/object-refinement-check-exports.test.ts b/packages/spec/src/ui/object-refinement-check-exports.test.ts index b8240404056..46310b4e017 100644 --- a/packages/spec/src/ui/object-refinement-check-exports.test.ts +++ b/packages/spec/src/ui/object-refinement-check-exports.test.ts @@ -62,7 +62,6 @@ import { checkPageSourceCompleteness, checkPageRequiresKind, checkPagePrintComposition, - checkPageSlotPair, checkPageDefaultPlacement, checkPageRegionPlacement, checkPageRecordBindingPlacement, @@ -280,28 +279,6 @@ const pagePrintFixtures: Fixture[] = [ { label: 'a `slotted` page with no `print`', value: { ...PAGE_BASE, kind: 'slotted' }, refusesAt: [] }, ]; -// [#22568] `slots.details` beside `slots.tabs` is refused at `slots.details`: -// the `tabs` slot replaces the strip the Details tab lives in, so the -// `details` body never rendered. Every fixture is a `record` page with no -// `print`, `source` or `requires`, so the three siblings stay silent and each -// row exercises THIS check alone; the kind-less row is the one whose direct -// call sees no `kind` while the parse sees the applied `full` default. -const SLOT_DETAILS = { type: 'record:details', properties: { hideFields: ['id'] } } as const; -const SLOT_TABS = { - type: 'page:tabs', - properties: { items: [{ label: 'Related', children: [{ type: 'record:related_list' }] }] }, -} as const; -const SLOT_PAGE = { ...PAGE_BASE, type: 'record', object: 'account', kind: 'slotted' } as const; -const pageSlotPairFixtures: Fixture[] = [ - { label: '`details` beside `tabs`, one component each', value: { ...SLOT_PAGE, slots: { details: SLOT_DETAILS, tabs: SLOT_TABS } }, refusesAt: ['slots.details'] }, - { label: '`details` beside `tabs`, each an array', value: { ...SLOT_PAGE, slots: { details: [SLOT_DETAILS], tabs: [SLOT_TABS] } }, refusesAt: ['slots.details'] }, - { label: 'an EMPTY `details: []` beside `tabs` — a present slot is refused, not its contents', value: { ...SLOT_PAGE, slots: { details: [], tabs: SLOT_TABS } }, refusesAt: ['slots.details'] }, - { label: '`details` beside `tabs` on a page with no `kind` (the `full` default)', value: { ...PAGE_BASE, type: 'record', object: 'account', slots: { details: SLOT_DETAILS, tabs: SLOT_TABS } }, refusesAt: ['slots.details'] }, - { label: '`details` alone', value: { ...SLOT_PAGE, slots: { details: SLOT_DETAILS } }, refusesAt: [] }, - { label: '`tabs` alone, carrying the `record:details` in its first item', value: { ...SLOT_PAGE, slots: { tabs: { type: 'page:tabs', properties: { items: [{ label: 'Details', children: [SLOT_DETAILS] }] } } } }, refusesAt: [] }, - { label: 'a slot map with neither', value: { ...SLOT_PAGE, slots: { discussion: [] } }, refusesAt: [] }, -]; - // [#23610] `isDefault: true` off a record page bound to an object, and a // region the page's template does not place. No fixture here carries `print`, // `source`, `requires`, a `details` + `tabs` pair or a component `data`, so the @@ -571,7 +548,6 @@ const MIRRORED: MirroredSchema[] = [ { name: 'checkPageSourceCompleteness', check: checkPageSourceCompleteness, fixtures: pageSourceFixtures }, { name: 'checkPageRequiresKind', check: checkPageRequiresKind, fixtures: pageRequiresFixtures }, { name: 'checkPagePrintComposition', check: checkPagePrintComposition, fixtures: pagePrintFixtures }, - { name: 'checkPageSlotPair', check: checkPageSlotPair, fixtures: pageSlotPairFixtures }, { name: 'checkPageDefaultPlacement', check: checkPageDefaultPlacement, fixtures: pageDefaultPlacementFixtures }, { name: 'checkPageRegionPlacement', check: checkPageRegionPlacement, fixtures: pageRegionPlacementFixtures }, { name: 'checkPageRecordBindingPlacement', check: checkPageRecordBindingPlacement, fixtures: pageRecordBindingPlacementFixtures }, @@ -730,9 +706,9 @@ describe('each schema attaches its export BY IDENTIFIER — no inline copy', () expect(attachments(src, 'checkListViewPageMount')).toBe(0); }); - it('page.zod.ts declares all eight exports and attaches each to PageSchema', () => { + it('page.zod.ts declares all seven exports and attaches each to PageSchema', () => { const src = read('page.zod.ts'); - for (const name of ['checkPageSourceCompleteness', 'checkPageRequiresKind', 'checkPagePrintComposition', 'checkPageSlotPair', 'checkPageDefaultPlacement', 'checkPageRegionPlacement', 'checkPageRecordBindingPlacement', 'checkPageMainRegionWidth']) { + for (const name of ['checkPageSourceCompleteness', 'checkPageRequiresKind', 'checkPagePrintComposition', 'checkPageDefaultPlacement', 'checkPageRegionPlacement', 'checkPageRecordBindingPlacement', 'checkPageMainRegionWidth']) { expect(src).toContain(`export function ${name}(`); expect(declarations(src, name)).toBe(1); expect(attachments(src, name)).toBe(1); @@ -743,6 +719,11 @@ describe('each schema attaches its export BY IDENTIFIER — no inline copy', () // a second judge of the same bag. expect(declarations(src, 'checkPageComponentViewData')).toBe(0); expect(attachments(src, 'checkPageComponentViewData')).toBe(0); + // `checkPageSlotPair` left with the `tabs` slot it paired with `details` + // (#27053): with no `tabs` slot there is no pair to refuse, and a + // re-attachment would be a check with no rule left to enforce. + expect(declarations(src, 'checkPageSlotPair')).toBe(0); + expect(attachments(src, 'checkPageSlotPair')).toBe(0); }); it('dashboard.zod.ts declares the export and attaches it to GlobalFilterSchema', () => { @@ -772,7 +753,6 @@ describe('`./index` (the `@objectstack/spec/ui` surface) exports the same functi ['checkPageSourceCompleteness', checkPageSourceCompleteness], ['checkPageRequiresKind', checkPageRequiresKind], ['checkPagePrintComposition', checkPagePrintComposition], - ['checkPageSlotPair', checkPageSlotPair], ['checkPageDefaultPlacement', checkPageDefaultPlacement], ['checkPageRegionPlacement', checkPageRegionPlacement], ['checkPageRecordBindingPlacement', checkPageRecordBindingPlacement], @@ -796,4 +776,8 @@ describe('`./index` (the `@objectstack/spec/ui` surface) exports the same functi it('no longer exports `checkPageComponentViewData` — the props rule judges the component bag', () => { expect('checkPageComponentViewData' in (ui as Record)).toBe(false); }); + + it('no longer exports `checkPageSlotPair` — retired with the `tabs` slot', () => { + expect('checkPageSlotPair' in (ui as Record)).toBe(false); + }); }); diff --git a/packages/spec/src/ui/page-slot-pair.test.ts b/packages/spec/src/ui/page-slot-pair.test.ts deleted file mode 100644 index 4fc4816ea81..00000000000 --- a/packages/spec/src/ui/page-slot-pair.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * `slots.details` beside `slots.tabs` is refused (#22568, triage direction b). - * - * The slot menu reads as seven independent slots, and two of them are not: - * `details` is the BODY of the Details tab, which lives inside the synthesized - * `page:tabs` strip, and `tabs` replaces that whole strip. The console's - * default-page synthesizer therefore reads `tabs` and never reads `details` - * when both are authored, so the pair parsed, validated and saved while the - * authored details body — its `sections`, its `hideFields` — never applied. - * The platform's own `sys_user_detail` page was the measured instance. - * - * Bookkeeping shapes, pinned below: - * - * 1. `checkPageSlotPair`, an exported object-level check attached to - * `PageSchema` beside the three page checks before it, refuses the pair - * at `slots.details`, naming both slots and the fix. Its parity with the - * schema is pinned in `object-refinement-check-exports.test.ts`. - * 2. No D2 conversion: where the details body goes inside an authored strip - * is the author's decision (which item, which label), and moving it makes - * visible a body that never rendered. A stored row replays unchanged. - * 3. The D3 entry is `page-slots-details-beside-tabs-refused`. - * - * On the assertion set: a bare `PageSchema` parse raises zod issues, pinned by - * `code`, `path` and the message's named subjects; the stack door wraps them - * in the ADR-0112 envelope, pinned by `code` and `status`. - */ - -import { describe, expect, it } from 'vitest'; - -import { applyConversionsToStoredItem } from '../conversions/stored'; -import type { ConversionNotice } from '../conversions/types'; -import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; -import { defineStack } from '../stack.zod'; -import { PageSchema, definePage } from './page.zod'; - -const D3_ID = 'page-slots-details-beside-tabs-refused'; - -const DETAILS = { - type: 'record:details', - properties: { - hideFields: ['id', 'internal_notes'], - sections: [{ label: 'Identity', fields: ['name'] }], - }, -}; -const TABS = { - type: 'page:tabs', - properties: { - items: [{ label: 'Related', children: [{ type: 'record:related_list', properties: { objectName: 'contact', relationshipField: 'account_id' } }] }], - }, -}; -const page = (slots: Record, extra: Record = {}) => ({ - name: 'account_detail', - label: 'Account', - type: 'record', - object: 'account', - kind: 'slotted', - regions: [], - slots, - ...extra, -}); - -function refusalOf(value: unknown) { - const r = PageSchema.safeParse(value); - expect(r.success, 'expected a refusal').toBe(false); - return r.success ? [] : r.error.issues; -} - -describe('`slots.details` beside `slots.tabs` — the parse refuses the pair', () => { - it.each([ - ['one component each', { details: DETAILS, tabs: TABS }], - ['each an array', { details: [DETAILS], tabs: [TABS] }], - ['an EMPTY `details: []` — a present slot is refused, not its contents', { details: [], tabs: TABS }], - ])('refuses the pair (%s), at `slots.details`, naming both slots and the fix', (_label, slots) => { - const issues = refusalOf(page(slots)); - expect(issues).toHaveLength(1); - const [issue] = issues; - expect(issue!.code).toBe('custom'); - expect(issue!.path).toEqual(['slots', 'details']); - expect(issue!.message.startsWith('`slots.details` and `slots.tabs` are refused together')).toBe(true); - // The remedy names the component to move and where it goes. - expect(issue!.message).toContain('`record:details`'); - expect(issue!.message).toContain('`tabs` items'); - // Runtime prose carries no tracker number (`check:doc-authoring`). - expect(issue!.message).not.toMatch(/#\d/); - }); - - it('refuses the pair whatever the page\'s `kind` — a page that omits it (the `full` default) too', () => { - const { kind: _kind, ...kindless } = page({ details: DETAILS, tabs: TABS }); - expect(PageSchema.parse({ ...kindless, slots: {} }).kind).toBe('full'); - const issues = refusalOf(kindless); - expect(issues.map((i) => i.path.join('.'))).toEqual(['slots.details']); - }); - - it('`definePage` — the authoring helper — throws on the pair', () => { - expect(() => definePage(page({ details: DETAILS, tabs: TABS }) as never)).toThrow(/`slots\.details` and `slots\.tabs`/); - }); - - it('the stack door wraps the same refusal in its ADR-0112 envelope, at `pages.N.slots.details`', () => { - const stack = { - manifest: { id: 'com.example.pages', name: 'pages', version: '1.0.0', type: 'app', namespace: 'pgs' }, - pages: [ - page({ tabs: { type: 'page:tabs', properties: { items: [{ label: 'Details', children: [DETAILS] }] } } }, { name: 'pgs_moved' }), - page({ details: DETAILS, tabs: TABS }, { name: 'pgs_pair' }), - ], - }; - let refusal: { code?: unknown; status?: unknown; issues?: Array<{ path: unknown[]; code: string }> } | undefined; - try { - defineStack(stack as never); - } catch (e) { - refusal = e as typeof refusal; - } - expect(refusal, 'defineStack must refuse the pair page').toBeDefined(); - expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: 'STACK_SCHEMA_INVALID', status: 422 }); - expect(refusal!.issues!.map((i) => ({ path: i.path.join('.'), code: i.code }))).toEqual([ - { path: 'pages.1.slots.details', code: 'custom' }, - ]); - }); -}); - -describe('CONTROL — a page authoring only one of the two slots is unchanged', () => { - it.each([ - ['only `details`', { details: DETAILS }], - ['only `tabs`', { tabs: TABS }], - ['only `tabs`, carrying the `record:details` in its first item (the fix)', { - tabs: { type: 'page:tabs', properties: { items: [{ label: 'Details', children: [DETAILS] }, ...TABS.properties.items] } }, - }], - ['neither', { discussion: [] }], - ])('%s — parses, and the slot map comes back as authored', (_label, slots) => { - const r = PageSchema.safeParse(page(slots)); - expect(r.success, JSON.stringify(r.error?.issues ?? [])).toBe(true); - if (r.success) expect(Object.keys(r.data.slots ?? {}).sort()).toEqual(Object.keys(slots).sort()); - }); -}); - -describe('the ledger — a D3 entry and no D2 conversion', () => { - it('step 18 carries the D3 entry, judging no mechanical edit', () => { - const entry = MIGRATIONS_BY_MAJOR[18]!.semantic.find((e) => e.id === D3_ID); - expect(entry, D3_ID).toBeDefined(); - expect(entry!.conversionIds ?? []).toEqual([]); - }); - - it('a STORED pair row replays unchanged — no conversion moves the details body — and the parse still refuses it', () => { - const notices: ConversionNotice[] = []; - const row = page({ details: DETAILS, tabs: TABS }); - const rehydrated = applyConversionsToStoredItem('page', row, { onNotice: (n) => notices.push(n) }); - expect(rehydrated).toEqual(row); - expect(notices.map((n) => n.conversionId)).toEqual([]); - expect(refusalOf(rehydrated).map((i) => i.path.join('.'))).toEqual(['slots.details']); - }); -}); diff --git a/packages/spec/src/ui/page-slots-tabs-retirement.test.ts b/packages/spec/src/ui/page-slots-tabs-retirement.test.ts new file mode 100644 index 00000000000..60f4546ec46 --- /dev/null +++ b/packages/spec/src/ui/page-slots-tabs-retirement.test.ts @@ -0,0 +1,449 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The record page's `slots.tabs` RETIRED (#27053) — ADR-0085, under the + * maintainer's keep / narrow / retire ruling on the record-page definition + * surfaces (epic #26676): keep the object roles, the relationship flags, six + * slotted slots and `kind: 'full'`; retire the `tabs` slot. + * + * The slot replaced the whole synthesized tab strip — the related lists, the + * approvals, attachments and history tabs were dropped unless re-added by hand — + * and opted the page out of the record page's column tiers. A page that needs + * its own strip is a `kind: 'full'` page, drawn as authored. + * + * Bookkeeping shapes, pinned below: + * 1. A `retiredKey()` tombstone on the slot map, so the parse refuses the slot + * with the prescription and `tsc` refuses it at the call site. + * 2. `related` / `relatedLists`, which were ALIASES into the slot, are + * `guidance` refusals with the same remedy, not suggestions into a + * tombstone. + * 3. `checkPageSlotPair` (the `details` + `tabs` pair refusal) left with the + * slot; `details` is live on its own. + * 4. D2 conversion `page-slots-tabs-removed` (step 18) over `stack.pages[]`, + * retired from the load path; `RETIRED_KEYS_BY_MAJOR[18]` carries + * `ui/Page:slots.tabs`; the D3 entry `page-slots-tabs-retired` rides + * beside it and absorbs `page-slots-details-beside-tabs-refused`. + * 5. Tree-scoped absence: nothing inside the declared radius authors a `tabs` + * slot. + * + * On the assertion set (the #13823 precedent): a schema refusal raises a + * `ZodError` whose issues carry `code` and `path` but no ADR-0112 `status` — + * that envelope belongs to the API error surface, where the save door answers + * `422 INVALID_METADATA` with these same issues. So these pins assert the + * strongest set this surface has: refusal, the issue `code`, the `path` naming + * the key, and the prescription text. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { collectConversionNotices } from '../conversions/apply'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import * as ui from './index'; +import { PageSchema, definePage } from './page.zod'; + +// Unanchored, because a thrown `ZodError`'s message is the JSON of its issues; +// the key-first house convention is asserted on the issue message itself below. +const PRESCRIPTION = + /`page\.slots\.tabs` was removed in @objectstack\/spec 18\.3\.0 \(ADR-0085\).*Delete the key\..*`relatedList: 'primary'`.*`kind: 'full'`.*`os migrate meta --from 17`/s; + +const STRIP = { type: 'page:tabs', properties: { items: [{ label: 'Related', children: [{ type: 'record:related_list' }] }] } }; +const RECORD_PAGE = { name: 'account_detail', label: 'Account', type: 'record', object: 'account', kind: 'slotted', regions: [] } as const; + +type Issue = { code: string; path: PropertyKey[]; message: string; expected?: string }; +type Parse = { success: boolean; error?: { issues: readonly Issue[] } }; + +describe('page slots.tabs retirement — the tombstone', () => { + it.each([ + ['one component', STRIP], + ['an array', [STRIP]], + ['an empty array — the slot is gone, not merely emptied', []], + ])('`slots.tabs` as %s is refused at `slots.tabs`, with the prescription', (_label, tabs) => { + const r = PageSchema.safeParse({ ...RECORD_PAGE, slots: { tabs } }) as Parse; + expect(r.success).toBe(false); + const issue = r.error!.issues.find((i) => i.path.join('.') === 'slots.tabs'); + expect(issue, JSON.stringify(r.error?.issues)).toBeDefined(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.expected).toBe('never'); + expect(issue!.message).toMatch(PRESCRIPTION); + // House convention 1: the fully-qualified key, in backticks, opens it. + expect(issue!.message.startsWith('`page.slots.tabs` was removed')).toBe(true); + }); + + it('is refused whatever the page kind — a page with no `kind` (the `full` default) included', () => { + const { kind: _kind, ...kindless } = RECORD_PAGE; + const r = PageSchema.safeParse({ ...kindless, slots: { tabs: STRIP } }) as Parse; + expect(r.success).toBe(false); + expect(r.error!.issues.map((i) => i.path.join('.'))).toContain('slots.tabs'); + }); + + it.each(['related', 'relatedLists'])('`slots.%s` — once an alias INTO the slot — is refused with the same remedy', (key) => { + const r = PageSchema.safeParse({ ...RECORD_PAGE, slots: { [key]: STRIP } }) as Parse; + expect(r.success).toBe(false); + const message = r.error!.issues.map((i) => i.message).join('\n'); + expect(message).toContain('`related` / `relatedLists` are not slots'); + expect(message).toContain("`relatedList: 'primary'`"); + expect(message).toContain("`kind: 'full'`"); + // Not steered into the tombstone: no "did you mean `tabs`". + expect(message).not.toMatch(/did you mean[^\n]*`tabs`/i); + }); + + it('CONTROL: the six live slots parse together, `details` included', () => { + const r = PageSchema.safeParse({ + ...RECORD_PAGE, + slots: { + header: { type: 'page:header', properties: { title: 'Account' } }, + actions: [], + alerts: [], + highlights: { type: 'record:highlights', properties: { fields: ['name'] } }, + details: { type: 'record:details', properties: { hideFields: ['id'] } }, + discussion: [], + }, + }) as Parse; + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + }); + + it('a `kind: \'full\'` page carrying its own `page:tabs` strip in its regions parses — the prescribed route', () => { + const r = PageSchema.safeParse({ + name: 'account_detail', + label: 'Account', + type: 'record', + object: 'account', + kind: 'full', + template: 'default', + regions: [{ name: 'main', components: [STRIP] }], + }) as Parse; + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + }); + + it('the prescription carries no tracker number and closes with the house migrate sentence', () => { + const r = PageSchema.safeParse({ ...RECORD_PAGE, slots: { tabs: STRIP } }) as Parse; + const message = r.error!.issues.map((i) => i.message).join('\n'); + expect(message).toContain('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.'); + expect(message).not.toMatch(/#\d/); + }); + + it('fails tsc at the authoring site: the input type of `slots.tabs` is `never`', () => { + const attempt = () => + definePage({ + ...RECORD_PAGE, + slots: { + // @ts-expect-error — `tabs` is a retiredKey() tombstone: its input type is `never`. + tabs: STRIP, + }, + }); + // The parse channel agrees with the type channel on the same literal. + expect(attempt).toThrow(PRESCRIPTION); + }); + + it('`checkPageSlotPair` is gone from the `@objectstack/spec/ui` surface — the pair it refused cannot be authored', () => { + expect('checkPageSlotPair' in (ui as Record)).toBe(false); + }); +}); + +describe('page slots.tabs retirement — the D2 conversion', () => { + it('a STORED slotted page carrying the slot replays clean through the rehydration seam', () => { + const stored = { ...RECORD_PAGE, slots: { highlights: { type: 'record:highlights' }, tabs: STRIP } }; + const notices: { conversionId?: string; path?: string }[] = []; + const rehydrated = applyConversionsToStoredItem('page', stored, { + onNotice: (n) => notices.push(n as { conversionId?: string; path?: string }), + }) as Record; + const own = notices.filter((n) => n.conversionId === 'page-slots-tabs-removed'); + expect(own.map((n) => n.path)).toEqual(['pages[0].slots.tabs']); + expect(rehydrated.slots).toEqual({ highlights: { type: 'record:highlights' } }); + // And the rehydrated row is exactly what the write door accepts now. + expect(PageSchema.safeParse(rehydrated).success).toBe(true); + }); + + it('a page whose ONLY slot was `tabs` is left an empty slotted page — the synthesized default', () => { + const { stack } = collectConversionNotices({ pages: [{ ...RECORD_PAGE, slots: { tabs: [STRIP] } }] }, { includeRetired: true }); + expect((stack.pages as Record[])[0]!.slots).toEqual({}); + // Idempotence, measured: a second replay converts nothing. + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices.filter((n) => n.conversionId === 'page-slots-tabs-removed')).toHaveLength(0); + }); + + it('is retired from the load path — a live author is refused at parse, never silently rewritten', () => { + const { stack, notices } = collectConversionNotices({ pages: [{ ...RECORD_PAGE, slots: { tabs: STRIP } }] }); + expect(notices.filter((n) => n.conversionId === 'page-slots-tabs-removed')).toHaveLength(0); + expect((stack.pages as Record[])[0]!.slots.tabs).toEqual(STRIP); + }); +}); + +describe('page slots.tabs retirement — ADR-0087 registration', () => { + it('declares the nested key under major 18, with the D2 conversion in the step-18 chain', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('ui/Page:slots.tabs'); + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain('page-slots-tabs-removed'); + }); + + it('carries its D3 entry, which absorbs the retired pair refusal, with no tracker number in any author-shown field', () => { + const semantic = MIGRATIONS_BY_MAJOR[18]!.semantic; + const entry = semantic.find((s) => s.id === 'page-slots-tabs-retired'); + expect(entry, 'the family owes one D3 entry: the strip cannot decide whether a page becomes kind full').toBeDefined(); + for (const field of ['surface', 'replacement', 'reason', 'acceptanceCriteria'] as const) { + expect(entry![field], field).not.toMatch(/#\d/); + } + expect(entry!.replacement).toContain("`kind: 'full'`"); + expect(semantic.some((s) => s.id === 'page-slots-details-beside-tabs-refused')).toBe(false); + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// `tsc` is the primary sweeper — `retiredKey()` types the slot `never` on every +// `Page` input, so every typed authoring site fails to compile. The residue is +// what `tsc` never judges: JSON, YAML, MD/MDX code fences, untyped `.js`, and +// TS literals typed `unknown` / `any` (a test body handed to a raw walker). +// This walk covers that residue across the five roots already declared for +// `@objectstack/spec#test` in `scripts/cross-package-test-inputs.mjs` and +// mirrored in `turbo.json`, the same roots and extensions the list-view `tabs` +// pin walks. +// +// ⭐ `tabs` is a common key (a list view's preset bar, Studio preview config, +// a `page:tabs` node's own items), so the matcher is STRUCTURAL: an offender +// is a `tabs` (or `related` / `relatedLists`) key that is an OWN key of an +// object literal (or YAML mapping) which is itself the value of a `slots` key. +// +// The bound, stated: a slot map assembled by SPREAD or computed keys is +// invisible to a text walk; `ui/**`, `docs/**`, `.claude/**`, `.github/**` and +// the repo-root files are outside the radius — under `ui/` a typed `Page` +// literal fails `tsc` in its own package, and the untyped residue there was +// swept by hand in the same change. +describe('tree-scoped absence: no slot map inside the declared radius authors `tabs`', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` only the non-code extensions are scanned AND declared. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + const RETIRED_SLOT_KEYS = new Set(['tabs', 'related', 'relatedLists']); + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell the retired slot. + */ + const EXCLUDED = new Set([ + // This pin authors the slot at the parse to assert the refusal. + THIS_FILE, + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement page on purpose. + 'packages/spec/src/conversions/', + // GITIGNORED build output reached only because this is a FILESYSTEM walk: + // `retiredKey()` emits the tombstone into the generated JSON Schema. + 'packages/spec/json-schema/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + const isOffendingFrame = (parentKey: string | undefined, keys: Set): boolean => + parentKey === 'slots' && [...keys].some((k) => RETIRED_SLOT_KEYS.has(k)); + + /** + * One pass over JS/TS/JSON text: a stack of bracket frames, each `{` frame + * collecting its OWN keys — an identifier or quoted string in key position + * (after `{` or `,`) followed by `:` — and remembering the key whose value it + * is. Strings and comments are skipped; a single- or double-quoted string + * never spans a line, so a mis-lexed quote (a regex literal) costs at most + * that line. Returns the 1-based line of each closing brace whose frame is an + * offender. (The list-view `tabs` pin's lexer, keyed by parent.) + */ + const lexOffenders = (text: string): number[] => { + const out: number[] = []; + const stack: { kind: string; parentKey?: string; keys: Set }[] = []; + let lastSig = ''; + let lastKey: string | undefined; + let colonKey: string | undefined; + let line = 1; + let i = 0; + const n = text.length; + const addKey = (token: string, j: number) => { + const top = stack[stack.length - 1]; + if (text[j] === ':' && top?.kind === '{' && (lastSig === '{' || lastSig === ',')) { + top.keys.add(token); + return true; + } + return false; + }; + while (i < n) { + const c = text[i]!; + if (c === '\n') { line += 1; i += 1; continue; } + if (c === '/' && text[i + 1] === '/') { while (i < n && text[i] !== '\n') i += 1; continue; } + if (c === '/' && text[i + 1] === '*') { + i += 2; + while (i < n && !(text[i] === '*' && text[i + 1] === '/')) { if (text[i] === '\n') line += 1; i += 1; } + i += 2; + continue; + } + if (c === '"' || c === "'" || c === '`') { + const start = i; + i += 1; + while (i < n && text[i] !== c) { + if (text[i] === '\\') i += 1; + else if (text[i] === '\n') { if (c !== '`') break; line += 1; } + i += 1; + } + const token = text.slice(start + 1, i); + i += 1; + let j = i; + while (j < n && (text[j] === ' ' || text[j] === '\t')) j += 1; + lastKey = c !== '`' && addKey(token, j) ? token : undefined; + lastSig = 'str'; + continue; + } + if (/[A-Za-z_$]/.test(c)) { + const start = i; + while (i < n && /[\w$]/.test(text[i]!)) i += 1; + const token = text.slice(start, i); + let j = i; + while (j < n && (text[j] === ' ' || text[j] === '\t')) j += 1; + lastKey = addKey(token, j) ? token : undefined; + lastSig = 'id'; + continue; + } + if (c === ':') colonKey = lastKey; + if (c === '{' || c === '[' || c === '(') { + stack.push({ kind: c, parentKey: c === '{' && lastSig === ':' ? colonKey : undefined, keys: new Set() }); + } else if (c === '}' || c === ']' || c === ')') { + const frame = stack.pop(); + if (frame?.kind === '{' && c === '}' && isOffendingFrame(frame.parentKey, frame.keys)) out.push(line); + } + if (!/\s/.test(c)) lastSig = c; + i += 1; + } + return out; + }; + + /** + * YAML: a retired key is an offender when the nearest key line above it at a + * smaller column — its parent mapping's key — is `slots`. Returns the 1-based + * line of each offending key. + */ + const yamlOffenders = (text: string): number[] => { + const rows = text.split('\n').map((raw, idx) => { + const m = /^(\s*)(-\s+)?([A-Za-z_][\w]*)\s*:/.exec(raw); + return m ? { idx, col: m[1]!.length + (m[2]?.length ?? 0), key: m[3]! } : null; + }); + const out: number[] = []; + rows.forEach((row, at) => { + if (!row || !RETIRED_SLOT_KEYS.has(row.key)) return; + for (let k = at - 1; k >= 0; k -= 1) { + const r = rows[k]; + if (!r || r.col >= row.col) continue; + if (r.key === 'slots') out.push(row.idx + 1); + break; + } + }); + return out; + }; + + /** MD/MDX: only fenced code is judged — prose mentions are not authorings. */ + const markdownOffenders = (text: string): number[] => { + const out: number[] = []; + const fence = /^```([\w-]*)[^\n]*\n([\s\S]*?)^```/gm; + for (let m = fence.exec(text); m; m = fence.exec(text)) { + const lang = m[1]!.toLowerCase(); + const body = m[2]!; + const offset = text.slice(0, m.index).split('\n').length; + const hits = lang === 'yaml' || lang === 'yml' ? yamlOffenders(body) : lexOffenders(body); + for (const h of hits) out.push(offset + h); + } + return out; + }; + + const offendersIn = (ext: string, text: string): number[] => { + if (!/\bslots\b/.test(text)) return []; + if (ext === '.yaml' || ext === '.yml') return yamlOffenders(text); + if (ext === '.md' || ext === '.mdx') return markdownOffenders(text); + return lexOffenders(text); + }; + + const vanished: string[] = []; + /** Tolerates ONLY a path's disappearance mid-walk; every other fault is re-raised. */ + const readIfPresent = (full: string, rel: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + vanished.push(rel); + return undefined; + } + }; + + it('the matcher finds a slot-map authoring and ignores every neighbouring shape (anti-vacuity)', () => { + // Offenders — the slot-map spelling, in each syntax the walk reads. + expect(offendersIn('.ts', "definePage({ kind: 'slotted', slots: { tabs: { type: 'page:tabs' } } })")).toEqual([1]); + expect(offendersIn('.ts', "const p = {\n slots: {\n highlights: {},\n tabs: [],\n },\n};")).toEqual([5]); + expect(offendersIn('.ts', "({ slots: { relatedLists: [] } })")).toEqual([1]); + expect(offendersIn('.json', '{ "slots": { "discussion": [], "tabs": { "type": "page:tabs" } } }')).toEqual([1]); + expect(offendersIn('.yaml', 'kind: slotted\nslots:\n header: {}\n tabs:\n type: page:tabs\n')).toEqual([4]); + expect(offendersIn('.md', "Prose.\n\n```ts\nconst page = { slots: { tabs: [] } };\n```\n")).toEqual([4]); + // Neighbours that must NOT match. + // A `page:tabs` node placed in a full page's regions — the prescribed route. + expect(offendersIn('.ts', "({ kind: 'full', regions: [{ name: 'main', components: [{ type: 'page:tabs', properties: { items: [] } }] }] })")).toEqual([]); + // The list view's preset bar and Studio preview config. + expect(offendersIn('.ts', "({ slots: { header: {} }, userFilters: { element: 'tabs', tabs: [] } })")).toEqual([]); + expect(offendersIn('.ts', "({ slots: { details: {} }, objectPreview: { tabs: [] } })")).toEqual([]); + // A YAML `tabs` whose parent is not the slot map. + expect(offendersIn('.yaml', 'slots:\n details:\n tabs: []\n')).toEqual([]); + // Prose and quoted strings are not authorings. + expect(offendersIn('.md', 'A slotted page once took `slots: { tabs }`.')).toEqual([]); + expect(offendersIn('.ts', "const s = \"{ slots: { tabs: [] } }\";")).toEqual([]); + }); + + it('a path that VANISHES mid-walk is not a finding, and every other read fault still is', () => { + const before = vanished.length; + const gone = path.join(REPO_ROOT, 'packages/spec/does-not-exist.bundled_probe.mjs'); + expect(fs.existsSync(gone)).toBe(false); + expect(readIfPresent(gone, 'probe/gone')).toBeUndefined(); + expect(vanished.slice(before)).toEqual(['probe/gone']); + expect(readIfPresent(fileURLToPath(import.meta.url), THIS_FILE)).toContain('tree-scoped absence'); + expect(() => readIfPresent(path.join(REPO_ROOT, 'packages/spec'), 'probe/dir')).toThrow(); + expect(vanished.length).toBe(before + 1); + }); + + it('no slot map authoring `tabs`, `related` or `relatedLists` survives inside the declared radius', () => { + const offenders: string[] = []; + let visited = 0; + let slotsBearing = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + if (!(rel.startsWith('examples/') ? EXAMPLES_EXT : SCANNED_EXT).has(ext)) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + const text = readIfPresent(full, rel); + if (text === undefined) continue; + if (/\bslots\b/.test(text)) slotsBearing += 1; + for (const lineNo of offendersIn(ext, text)) offenders.push(`${rel}:${lineNo}`); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk covered the tree, and the files that CAN hold the + // slot map were really judged. + expect(visited).toBeGreaterThan(1000); + expect(slotsBearing).toBeGreaterThan(50); + expect(offenders, 'a slot map authoring `tabs` means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 2769a9356a1..12d1dfbfa36 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -1231,55 +1231,6 @@ export function checkPageRequiresKind( }); } -/** - * The slot-pair check attached to {@link PageSchema}: a page that authors - * both `slots.details` and `slots.tabs` is refused at `slots.details`, naming - * the pair and the fix. - * - * The two slots are not independent, whatever the slot menu's layout - * suggests. On a slotted record page the `details` slot is the BODY of the - * Details tab, and that tab lives inside the synthesized `page:tabs` strip; - * the `tabs` slot replaces that whole strip. So a `details` authored beside a - * `tabs` has nowhere to render: the console's default-page synthesizer reads - * `tabs` and never reads `details` (its own doc states the precedence — "wins - * over `details` when both are provided"). Before this check the pair parsed, - * validated and saved, and the authored details body — its `sections`, its - * `hideFields` — silently never applied. The fix is the shape the platform's - * own `sys_user_detail` page uses: the `record:details` component as a child - * of a `tabs` item (the first one, by convention), and no `details` slot. - * - * Judged on the slot map whatever the page's `kind`: the pair is never a - * coherent declaration, and a page that is not slotted reads no slot at all. - * A slot is present when its value is not `undefined` — the synthesizer's own - * test — so an empty `details: []` beside `tabs` is refused like a full one. - * - * Exported for the reason {@link checkPageSourceCompleteness} is: a downstream - * mirror that derives its schema from `PageSchema.shape` drops every - * object-level check, and re-attaches this one with - * `.superRefine(checkPageSlotPair)`. `PageSchema` attaches this same binding — - * pinned in `object-refinement-check-exports.test.ts`. - */ -export function checkPageSlotPair( - page: { slots?: unknown }, - ctx: z.RefinementCtx, -): void { - const slots = page.slots; - if (slots === undefined || slots === null || typeof slots !== 'object') return; - const { details, tabs } = slots as { details?: unknown; tabs?: unknown }; - if (details === undefined || tabs === undefined) return; - ctx.addIssue({ - code: 'custom', - path: ['slots', 'details'], - message: - '`slots.details` and `slots.tabs` are refused together: the `details` slot is the body of the ' - + 'Details tab inside the synthesized `page:tabs` strip, and an authored `tabs` slot replaces that ' - + 'whole strip, so the `details` beside it is never rendered. Move the `record:details` component ' - + 'into the `tabs` items — as the `children` of the first item, e.g. ' - + "`{ label: 'Details', children: [{ type: 'record:details', properties: { … } }] }` — and delete " - + '`slots.details`. To keep the synthesized tabs instead, delete `slots.tabs`.', - }); -} - /** * Page Schema * Defines a composition of components for a specific context. @@ -1331,6 +1282,44 @@ const PAGE_AUDIENCE_WRONG_LAYER = + 'permission sets, and bind those sets to people through positions ' + "(named in a position's `permissionSets`), never on the page itself."; +/** + * The `page.slots.tabs` tombstone prescription (ADR-0085; maintainer ruling + * of 2026-10-28 on the record-page definition surfaces, card #27053). + * + * The slot replaced the WHOLE synthesized tab strip — the related lists, the + * approvals, attachments and history tabs the platform composes from the + * object were dropped unless the author re-added each one — and it opted the + * page out of the record page's column tiers, so it stayed tabbed at every + * width. The record page is composed from the object's roles and relationship + * flags (ADR-0085), so a page that needs its own strip is a `kind: 'full'` + * page, which is drawn as authored. Carried by a `retiredKey()` tombstone on + * the slot map, the `page.assignedProfiles` precedent above. + */ +const PAGE_SLOTS_TABS_REMEDY = + "The record page composes its tabs and columns from the object's roles and relationship " + + "flags: to change which related lists appear or their order, set `relatedList` / " + + "`relatedList: 'primary'` on the child's reference field; to replace the Details body, " + + "author `slots.details`; a page that needs its own strip is `kind: 'full'`, drawn as " + + 'authored. '; + +const PAGE_SLOTS_TABS_RETIRED = + '`page.slots.tabs` was removed in @objectstack/spec 18.3.0 (ADR-0085) — an authored strip ' + + 'replaced the whole synthesized one, so the related lists, approvals, attachments and history ' + + 'the platform composes from the object were dropped unless re-added by hand, and the page stayed ' + + 'tabbed at every width instead of taking the record page\'s columns. Delete the key. ' + + PAGE_SLOTS_TABS_REMEDY + + '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.'; + +/** + * The answer `related` / `relatedLists` now carry on the slot map. They were + * ALIASES into the retired `tabs` slot — an author writing `related:` was + * steered INTO the removed key — so they are refusals that point where the + * related lists really come from, with the same remedy as the tombstone. + */ +const PAGE_SLOTS_RELATED_NOT_A_SLOT = + '`related` / `relatedLists` are not slots: the related lists are not a slot of the record page. ' + + PAGE_SLOTS_TABS_REMEDY.trimEnd(); + // ─── The print page (#22158: card ① of the #8346 ruling) ─────────────────── // // The ruling of record is 6051470224 on #8346 (letter B′, the maintainer's @@ -2101,20 +2090,18 @@ export const PageSchema = lazySchema(() => strictObject({ * Each slot accepts a single PageComponent or an array. Slots not * provided fall through to the synthesized default. * - * Slot menu (v1): header | actions | alerts | highlights | details | - * tabs | discussion. Each slot is a full replacement at the slot + * Slot menu: header | actions | alerts | highlights | details | + * discussion. Each slot is a full replacement at the slot * boundary — no deep merge, no patch operations. To compose default + * custom, call the corresponding `buildDefault*` sub-builder from the * renderer runtime (e.g. @object-ui/plugin-detail). * - * Precedence — `details` and `tabs` are NOT independent. `details` - * replaces the body of the Details tab, and that tab lives inside the - * synthesized `page:tabs` strip; `tabs` replaces the whole strip, so it - * wins and a `details` beside it is never rendered. A page authoring both - * is therefore refused ({@link checkPageSlotPair}): to replace the tabs - * AND the details body, put the `record:details` component in the `tabs` - * items (the first item's `children`, by convention) and author no - * `details` slot. + * There is no `tabs` slot. The tab strip — and, at wider widths, the + * record page's columns — is composed from the object's roles and + * relationship flags (ADR-0085); `details` replaces the body of the Details + * tab inside it. The `tabs` slot that replaced the whole strip is a + * `retiredKey()` tombstone ({@link PAGE_SLOTS_TABS_RETIRED}); a page that + * needs its own strip is a `kind: 'full'` page, drawn as authored. * * Only honored when `kind === 'slotted'`. */ @@ -2124,14 +2111,22 @@ export const PageSchema = lazySchema(() => strictObject({ // The slot menu is a CLOSED v1 set (each slot has a `buildDefault*` // sub-builder behind it), so an unknown slot name is not a slot that will // start working later — it is content that never renders. - aliases: { activity: 'discussion', chatter: 'discussion', comments: 'discussion', related: 'tabs', relatedLists: 'tabs', fields: 'details', body: 'details', banner: 'alerts', toolbar: 'actions', buttons: 'actions', summary: 'highlights' }, + aliases: { activity: 'discussion', chatter: 'discussion', comments: 'discussion', fields: 'details', body: 'details', banner: 'alerts', toolbar: 'actions', buttons: 'actions', summary: 'highlights' }, + // `related` / `relatedLists` were aliases into the retired `tabs` slot; + // they now refuse with the remedy instead of steering into a tombstone. + guidance: { related: PAGE_SLOTS_RELATED_NOT_A_SLOT, relatedLists: PAGE_SLOTS_RELATED_NOT_A_SLOT }, }, { header: z.union([PageComponentSchema, z.array(PageComponentSchema)]).optional(), actions: z.union([PageComponentSchema, z.array(PageComponentSchema)]).optional(), alerts: z.union([PageComponentSchema, z.array(PageComponentSchema)]).optional(), highlights: z.union([PageComponentSchema, z.array(PageComponentSchema)]).optional(), details: z.union([PageComponentSchema, z.array(PageComponentSchema)]).optional(), - tabs: z.union([PageComponentSchema, z.array(PageComponentSchema)]).optional(), + // `tabs` REMOVED (ADR-0085, #27053): it replaced the whole synthesized + // strip and opted the page out of the column tiers. A `retiredKey()` + // tombstone, so authoring it is a `tsc` error and a parse error carrying + // the prescription; stored rows are stripped by the protocol-18 + // `page-slots-tabs-removed` conversion. + tabs: retiredKey(PAGE_SLOTS_TABS_RETIRED), discussion: z.union([PageComponentSchema, z.array(PageComponentSchema)]).optional(), }).optional().describe('Slot override map for slotted pages'), @@ -2187,10 +2182,6 @@ export const PageSchema = lazySchema(() => strictObject({ // Ruling B′ on #8346 (#22158): a print page prints exactly the blocks it // authors. Attached by identifier for the same reason as the checks above. .superRefine(checkPagePrintComposition) - // #22568: `slots.details` beside `slots.tabs` never renders — the `tabs` - // slot replaces the strip the Details tab lives in. Attached by identifier - // for the same reason as the checks above. - .superRefine(checkPageSlotPair) // #23610 (ruling A, record 6190536588): `isDefault: true` on a page that is // not a record page bound to an object, and a region the page's template // does not place, are refused. Attached by identifier for the same reason @@ -2205,14 +2196,14 @@ export const PageSchema = lazySchema(() => strictObject({ // other width there. Attached by identifier for the same reason as the // checks above. .superRefine(checkPageMainRegionWidth)); -// PageSchema's cross-field rules are the eight checks above: the +// PageSchema's cross-field rules are the seven checks above: the // ADR-0080/0081 source completeness check, the compiled-kind `requires` -// check, the print-page composition check, the `details` + `tabs` slot -// pair check, the `isDefault` placement check, the region placement -// check, the `record` binding placement check and the `main` region width -// check. A component's `properties` bag is judged by none of them: it is -// `@objectstack/lint`'s `validateComponentProps`, at the CLI doors and the -// runtime save door alike. It once also required +// check, the print-page composition check, the `isDefault` placement check, +// the region placement check, the `record` binding placement check and the +// `main` region width check. (The `details` + `tabs` slot pair check left +// with the `tabs` slot.) A component's `properties` bag is judged by none of +// them: it is `@objectstack/lint`'s `validateComponentProps`, at the CLI doors +// and the runtime save door alike. It once also required // `recordReview`/`blankLayout` and `slots` (all removed — unrendered roadmap / // "required-but-unauthorable" Studio traps). From 4fa94f83951d2d76855801b50a3cd6acd7484dc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:21:41 +0000 Subject: [PATCH 02/11] wip: move the four slots.tabs producers to kind: 'full'; test fixtures Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzff3QiP6kmtajVnKnq --- .../src/ui/pages/project-detail.page.ts | 251 ++-- .../src/ui/pages/task-detail.page.ts | 4 +- .../test/record-action-location-hosts.test.ts | 9 +- .../cli/test/i18n-section-coverage.test.ts | 4 +- packages/lint/src/page-envelope-audit.test.ts | 4 +- packages/lint/src/page-walk.test.ts | 6 +- .../src/validate-page-field-bindings.test.ts | 4 +- .../validate-visibility-predicates.test.ts | 4 +- .../src/pages/sys-organization.page.ts | 167 +-- .../src/pages/sys-user.page.ts | 1062 +++++++++-------- .../src/sys-approval-request.page.ts | 133 ++- .../spec/src/system/i18n-resolver.test.ts | 20 +- 12 files changed, 871 insertions(+), 797 deletions(-) diff --git a/examples/app-showcase/src/ui/pages/project-detail.page.ts b/examples/app-showcase/src/ui/pages/project-detail.page.ts index 7be1a6e487f..f7de8eb55fb 100644 --- a/examples/app-showcase/src/ui/pages/project-detail.page.ts +++ b/examples/app-showcase/src/ui/pages/project-detail.page.ts @@ -10,142 +10,157 @@ import { definePage } from '@objectstack/spec/ui'; * place, and Save persists the diff (create/update/delete) with the * `master_detail` FK maintained. * - * `kind: 'slotted'` with only the `tabs` slot overridden — the synthesizer - * fills in the header / highlights / details / discussion. Overriding `tabs` - * replaces the whole synthesized tab strip (a slot is a full replacement, with - * no merge), and that strip is where the synthesized related lists live — so - * the page puts the Tasks related list back itself, in a Related tab (below). + * `kind: 'full'` — the page owns its tab strip (a Tasks grid tab is not + * something the object's relationship flags produce), so it composes every + * node itself and is drawn as authored at every width: the header, the + * highlights strip, the tab strip and the discussion. It was a `kind: + * 'slotted'` page overriding `highlights` and `tabs` until the `tabs` slot + * retired (#27053); the rebuild renders the same nodes in the same order. An + * authored strip replaces the synthesized one, and that strip is where the + * synthesized related lists live — so the page puts the Tasks related list + * back itself, in a Related tab (below). */ export const ProjectDetailPage = definePage({ name: 'showcase_project_detail', label: 'Project', type: 'record', object: 'showcase_project', - kind: 'slotted', + kind: 'full', template: 'default', isDefault: true, - regions: [], - slots: { - // Explicit highlights strip — exercises the page editor's field-list picker - // (objectFrom:'page' resolves showcase_project's fields). - highlights: { - type: 'record:highlights', - properties: { - fields: ['account', 'status', 'health', 'budget', 'end_date'], - }, - }, - tabs: { - type: 'page:tabs', - properties: { - // `tabStyle`, not `type` (#6776): a props key named `type` collides - // with the component node's own dispatch key, so the old spelling - // could not be written in a flat or JSX page at all. - tabStyle: 'line', - items: [ - { - // Explicit details sections — each section's `fields` is a - // field-list bound to showcase_project in the page editor. - // - // `value` is the tab's stable `?tab=` URL token (#5776): the key - // `PageTabsProps.items[]` declares and objectui's tabs renderer - // reads. `key` was neither — an unknown prop nothing verifies and - // nothing reads, which left both tabs on the index-derived - // `tab-` fallback and their deep links non-durable. - value: 'details', - label: 'Details', - children: [ - { - type: 'record:details', - properties: { - sections: [ - { name: 'overview', label: 'Overview', columns: 2, fields: ['name', 'account', 'owner', 'status'] }, - { name: 'financials', label: 'Financials', columns: 2, fields: ['budget', 'spent'] }, - { name: 'timeline', label: 'Timeline', columns: 2, fields: ['start_date', 'end_date'] }, - ], - }, - }, - ], + regions: [ + { + name: 'main', + width: 'full', + components: [ + // The title bar. A full page renders exactly the nodes it declares; + // `showcase_project` declares no `record_header` / `record_more` + // action, so the header carries none, as the synthesized one did. + { type: 'page:header' }, + // Explicit highlights strip — exercises the page editor's field-list picker + // (objectFrom:'page' resolves showcase_project's fields). + { + type: 'record:highlights', + properties: { + fields: ['account', 'status', 'health', 'budget', 'end_date'], }, - { - value: 'tasks', - label: 'Tasks', - children: [ + }, + { + type: 'page:tabs', + properties: { + // `tabStyle`, not `type` (#6776): a props key named `type` collides + // with the component node's own dispatch key, so the old spelling + // could not be written in a flat or JSX page at all. + tabStyle: 'line', + items: [ { - type: 'record:line_items', - properties: { - childObject: 'showcase_task', - relationshipField: 'project', - amountField: 'estimate_hours', - title: 'Tasks', - columns: [ - { name: 'title', label: 'Title', type: 'text', required: true }, - { - name: 'status', - label: 'Status', - type: 'select', - options: [ - { label: 'Backlog', value: 'backlog' }, - { label: 'To Do', value: 'todo' }, - { label: 'In Progress', value: 'in_progress' }, - { label: 'In Review', value: 'in_review' }, - { label: 'Done', value: 'done' }, + // Explicit details sections — each section's `fields` is a + // field-list bound to showcase_project in the page editor. + // + // `value` is the tab's stable `?tab=` URL token (#5776): the key + // `PageTabsProps.items[]` declares and objectui's tabs renderer + // reads. `key` was neither — an unknown prop nothing verifies and + // nothing reads, which left both tabs on the index-derived + // `tab-` fallback and their deep links non-durable. + value: 'details', + label: 'Details', + children: [ + { + type: 'record:details', + properties: { + sections: [ + { name: 'overview', label: 'Overview', columns: 2, fields: ['name', 'account', 'owner', 'status'] }, + { name: 'financials', label: 'Financials', columns: 2, fields: ['budget', 'spent'] }, + { name: 'timeline', label: 'Timeline', columns: 2, fields: ['start_date', 'end_date'] }, ], }, - { - name: 'priority', - label: 'Priority', - type: 'select', - options: [ - { label: 'Low', value: 'low' }, - { label: 'Medium', value: 'medium' }, - { label: 'High', value: 'high' }, - { label: 'Urgent', value: 'urgent' }, + }, + ], + }, + { + value: 'tasks', + label: 'Tasks', + children: [ + { + type: 'record:line_items', + properties: { + childObject: 'showcase_task', + relationshipField: 'project', + amountField: 'estimate_hours', + title: 'Tasks', + columns: [ + { name: 'title', label: 'Title', type: 'text', required: true }, + { + name: 'status', + label: 'Status', + type: 'select', + options: [ + { label: 'Backlog', value: 'backlog' }, + { label: 'To Do', value: 'todo' }, + { label: 'In Progress', value: 'in_progress' }, + { label: 'In Review', value: 'in_review' }, + { label: 'Done', value: 'done' }, + ], + }, + { + name: 'priority', + label: 'Priority', + type: 'select', + options: [ + { label: 'Low', value: 'low' }, + { label: 'Medium', value: 'medium' }, + { label: 'High', value: 'high' }, + { label: 'Urgent', value: 'urgent' }, + ], + }, + { name: 'estimate_hours', label: 'Estimate (h)', type: 'number' }, + { name: 'due_date', label: 'Due Date', type: 'date' }, ], }, - { name: 'estimate_hours', label: 'Estimate (h)', type: 'number' }, - { name: 'due_date', label: 'Due Date', type: 'date' }, - ], - }, + }, + ], }, - ], - }, - { - // The read side of the same relationship: the Tasks related list, - // as the synthesized strip would draw it for a non-primary - // relationship (a `Related` tab, `?tab=related`). The `tabs` - // override above removed that strip, and `showcase_task.project` - // is the task's only parent relationship, so without this tab no - // record page in the showcase shows tasks as a related list. - // - // It is the same `record:related_list` node the synthesizer emits, - // under the same record page, so its rows carry what a synthesized - // list's rows carry: the task's `list_item` actions, plus its - // `record_related` ones, which only render on a related list - // inside a parent record. No `actions` key on purpose — authoring - // one would replace those host-supplied actions with the list - // named here. - // - // `title` and `columns` repeat what the FK declares - // (`relatedListTitle` / `relatedListColumns` on - // `showcase_task.project`): only the synthesizer reads those keys, - // and an authored node is not synthesized. - // `test/record-action-location-hosts.test.ts` holds the two equal. - value: 'related', - label: 'Related', - children: [ { - type: 'record:related_list', - properties: { - objectName: 'showcase_task', - relationshipField: 'project', - title: 'Tasks', - columns: ['title', 'status', 'priority', 'assignee', 'due_date'], - }, + // The read side of the same relationship: the Tasks related list, + // as the synthesized strip would draw it for a non-primary + // relationship (a `Related` tab, `?tab=related`). The `tabs` + // override above removed that strip, and `showcase_task.project` + // is the task's only parent relationship, so without this tab no + // record page in the showcase shows tasks as a related list. + // + // It is the same `record:related_list` node the synthesizer emits, + // under the same record page, so its rows carry what a synthesized + // list's rows carry: the task's `list_item` actions, plus its + // `record_related` ones, which only render on a related list + // inside a parent record. No `actions` key on purpose — authoring + // one would replace those host-supplied actions with the list + // named here. + // + // `title` and `columns` repeat what the FK declares + // (`relatedListTitle` / `relatedListColumns` on + // `showcase_task.project`): only the synthesizer reads those keys, + // and an authored node is not synthesized. + // `test/record-action-location-hosts.test.ts` holds the two equal. + value: 'related', + label: 'Related', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'showcase_task', + relationshipField: 'project', + title: 'Tasks', + columns: ['title', 'status', 'priority', 'assignee', 'due_date'], + }, + }, + ], }, ], }, - ], - }, + }, + // The synthesized discussion footer, kept. + { type: 'record:discussion' }, + ], }, - }, + ], }); diff --git a/examples/app-showcase/src/ui/pages/task-detail.page.ts b/examples/app-showcase/src/ui/pages/task-detail.page.ts index a8ac01f81de..dbc1f29f8cb 100644 --- a/examples/app-showcase/src/ui/pages/task-detail.page.ts +++ b/examples/app-showcase/src/ui/pages/task-detail.page.ts @@ -16,8 +16,8 @@ import { definePage } from '@objectstack/spec/ui'; * • `record:quick_actions` — object Actions surfaced as inline buttons. * • `record:highlights` + `record:details` — the standard compact + section * layout. - * `kind: 'full'` — this page fully owns the record layout (vs the slotted - * Project page which only overrides the tabs slot). + * `kind: 'full'` — this page fully owns the record layout, as the Project + * page does for its own tab strip. */ export const TaskDetailPage = definePage({ name: 'showcase_task_detail', diff --git a/examples/app-showcase/test/record-action-location-hosts.test.ts b/examples/app-showcase/test/record-action-location-hosts.test.ts index 27f8f8c90e3..bf10590092d 100644 --- a/examples/app-showcase/test/record-action-location-hosts.test.ts +++ b/examples/app-showcase/test/record-action-location-hosts.test.ts @@ -17,10 +17,11 @@ import { Task } from '../src/data/objects/task.object.js'; * - Task Detail is `kind: 'full'`. Such a page renders exactly the nodes it * declares, and it declared no `page:header`, so there was no title bar for * `record_header` actions and no ⋯ overflow for `record_more` ones. - * - Project Detail overrides the `tabs` slot with a `record:line_items` grid. - * That slot is a full replacement, so the synthesized Tasks related list - * went with it, and `record_related` actions render only on the rows of a - * related list inside a parent record. + * - Project Detail authors its own tab strip with a `record:line_items` grid + * (a `kind: 'full'` page; until #27053 a slotted page's `tabs` slot). An + * authored strip replaces the synthesized one, so the synthesized Tasks + * related list went with it, and `record_related` actions render only on + * the rows of a related list inside a parent record. * * The assertions pin each host on the page the console picks for the object, * and hold the authored lists equal to the declarations they copy, so a new diff --git a/packages/cli/test/i18n-section-coverage.test.ts b/packages/cli/test/i18n-section-coverage.test.ts index 5197166a59d..5d1c3903f36 100644 --- a/packages/cli/test/i18n-section-coverage.test.ts +++ b/packages/cli/test/i18n-section-coverage.test.ts @@ -204,7 +204,7 @@ describe('authored record-page sections', () => { kind: 'slotted', regions: [], slots: { - tabs: { + details: { type: 'page:tabs', properties: { items: [ @@ -250,7 +250,7 @@ describe('authored record-page sections', () => { // Asserting the key survives is what keeps this non-vacuous: the entry is // present and its `inline` is empty, not absent because nothing was walked. const titled = JSON.parse(JSON.stringify(slottedPage)); - const sections = titled.slots.tabs.properties.items[0].children[0].properties.sections; + const sections = titled.slots.details.properties.items[0].children[0].properties.sections; sections[1] = { name: 'timeline', title: 'Timeline', fields: ['close_date'] }; const entries = sectionEntries({ pages: [titled] }); diff --git a/packages/lint/src/page-envelope-audit.test.ts b/packages/lint/src/page-envelope-audit.test.ts index 1f23a259685..b24825af9c9 100644 --- a/packages/lint/src/page-envelope-audit.test.ts +++ b/packages/lint/src/page-envelope-audit.test.ts @@ -68,7 +68,7 @@ function nestedPredicatePage(visibleWhen: unknown): AnyRec { kind: 'slotted', regions: [], slots: { - tabs: { + details: { type: 'page:tabs', properties: { items: [ @@ -125,7 +125,7 @@ describe('negative control — the detector fires, and every door earns its plac const page = nestedPredicatePage(BARE); const audit = auditPageExpressionEnvelopes(page, 'nc (nc_nested)'); expect(audit.findings.map(f => f.path)).toEqual([ - 'slots.tabs.properties.items[0].children[0].visibleWhen', + 'slots.details.properties.items[0].children[0].visibleWhen', ]); expect(audit.findings[0]!.door).toBe('PageComponentSchema'); diff --git a/packages/lint/src/page-walk.test.ts b/packages/lint/src/page-walk.test.ts index 36f4ef09944..1f879f50b0a 100644 --- a/packages/lint/src/page-walk.test.ts +++ b/packages/lint/src/page-walk.test.ts @@ -28,13 +28,13 @@ describe('walkPageComponents — where components actually live', () => { kind: 'slotted', slots: { highlights: { type: 'a' }, - tabs: [{ type: 'b' }, { type: 'c' }], + details: [{ type: 'b' }, { type: 'c' }], }, }), ).toEqual([ 'pages[0].slots.highlights', - 'pages[0].slots.tabs[0]', - 'pages[0].slots.tabs[1]', + 'pages[0].slots.details[0]', + 'pages[0].slots.details[1]', ]); }); diff --git a/packages/lint/src/validate-page-field-bindings.test.ts b/packages/lint/src/validate-page-field-bindings.test.ts index b869f851f81..79c53d64773 100644 --- a/packages/lint/src/validate-page-field-bindings.test.ts +++ b/packages/lint/src/validate-page-field-bindings.test.ts @@ -569,7 +569,7 @@ describe('validatePageFieldBindings — binding precedence and traversal', () => kind: 'slotted', slots: { highlights: { type: 'record:highlights', properties: { fields: ['ghost_a'] } }, - tabs: { + details: { type: 'page:tabs', properties: { items: [ @@ -589,7 +589,7 @@ describe('validatePageFieldBindings — binding precedence and traversal', () => expect(findings).toHaveLength(2); expect(findings.map((f) => f.path).sort()).toEqual([ 'pages[0].slots.highlights.properties.fields[0]', - 'pages[0].slots.tabs.properties.items[0].children[0].properties.fields[0]', + 'pages[0].slots.details.properties.items[0].children[0].properties.fields[0]', ]); }); diff --git a/packages/lint/src/validate-visibility-predicates.test.ts b/packages/lint/src/validate-visibility-predicates.test.ts index 971d7457e53..bf762c4eb07 100644 --- a/packages/lint/src/validate-visibility-predicates.test.ts +++ b/packages/lint/src/validate-visibility-predicates.test.ts @@ -422,7 +422,7 @@ describe('visibility-bare-identifier (#6128 / #5149 requirement 3)', () => { kind: 'slotted', slots: { highlights: { type: 'element:text', visibleWhen: "stage == 'won'" }, - tabs: [ + details: [ { type: 'record:related', visibleWhen: 'record.has_children' }, { type: 'record:related', visibleWhen: 'has_children' }, ], @@ -432,7 +432,7 @@ describe('visibility-bare-identifier (#6128 / #5149 requirement 3)', () => { }; expect(bareFindings(stack).map((f) => f.path).sort()).toEqual([ 'pages[0].slots.highlights', - 'pages[0].slots.tabs[1]', + 'pages[0].slots.details[1]', ]); }); diff --git a/packages/platform-objects/src/pages/sys-organization.page.ts b/packages/platform-objects/src/pages/sys-organization.page.ts index 2998b6ec9e1..07c59a817f2 100644 --- a/packages/platform-objects/src/pages/sys-organization.page.ts +++ b/packages/platform-objects/src/pages/sys-organization.page.ts @@ -3,18 +3,29 @@ import type { Page } from '@objectstack/spec/ui'; /** - * sys_organization — Record Detail Page (slotted) + * sys_organization — Record Detail Page (`kind: 'full'`) * * Adds Members / Invitations / Teams tabs to the organization detail * page so an admin can manage the membership graph from a single place * instead of switching between three separate Setup list views. * - * The page is `kind: 'slotted'` and overrides only the `tabs` slot — - * header, actions, highlights, details and discussion fall through to - * the synthesized default, so the organization's own fields and the - * object's `record_header` actions (Invite User, Set Active and Rename - * Organization inline, Leave Organization in the overflow — Edit and Delete - * are row actions only) are preserved. + * The page is `kind: 'full'`: it owns its own tab strip, so it is drawn as + * authored at every width. It was a `kind: 'slotted'` page overriding only + * the `tabs` slot until that slot retired (#27053 — the record page now + * composes its strip from the object's roles and relationship flags, and a + * page that needs its own strip is `kind: 'full'`). The rebuild is faithful + * to what the slotted page rendered — the synthesized default with the strip + * swapped in — node for node: + * + * - `page:header` carrying the object's `record_header` actions (Set Active, + * Invite User, Leave Organization, Rename Organization — Edit and Delete are + * row actions only), the list the synthesized header carried. A full page + * renders exactly the nodes it declares, so the header is spelled out. + * - `record:highlights` over the object's `highlightFields` minus the title + * field (`slug`; `name` is the page H1). + * - the Members / Invitations / Teams strip, unchanged, Members at index 0 + * (ADR-0133). + * - `record:discussion`, the synthesized discussion footer. * * Each tab is a `record:related_list` over a child object that already * has `organization_id` as a `Field.lookup('sys_organization')` — the @@ -38,79 +49,91 @@ export const SysOrganizationDetailPage: Page = { type: 'record', object: 'sys_organization', template: 'default', - kind: 'slotted', + kind: 'full', isDefault: true, - // `regions` is required by the Page schema even for slotted pages — - // empty array lets the synthesizer fill in header/details/discussion - // while the `slots.tabs` override below replaces the synthesized - // tabs strip. - regions: [], - slots: { - tabs: { - type: 'page:tabs', - properties: { - // `tabStyle`, not `type` (#6776): the node's own dispatch key is - // already `type`, so the old spelling was unauthorable in the flat and - // JSX carriers and unvalidated everywhere. - tabStyle: 'line', - position: 'top', - items: [ - { - label: { en: 'Members', 'zh-CN': '成员', 'ja-JP': 'メンバー', 'es-ES': 'Miembros' }, - icon: 'users', - children: [ + regions: [ + { + name: 'main', + width: 'full', + components: [ + // The synthesized header's action list: every `sys_organization` + // action declaring `record_header` or `record_more`, in declaration + // order. The host appends its own system actions after these. + { + type: 'page:header', + properties: { + actions: ['set_active_organization', 'invite_user', 'leave_organization', 'rename_organization'], + }, + }, + { type: 'record:highlights', properties: { fields: ['slug'] } }, + { + type: 'page:tabs', + properties: { + // `tabStyle`, not `type` (#6776): the node's own dispatch key is + // already `type`, so the old spelling was unauthorable in the flat and + // JSX carriers and unvalidated everywhere. + tabStyle: 'line', + position: 'top', + items: [ { - type: 'record:related_list', - properties: { - objectName: 'sys_member', - relationshipField: 'organization_id', - columns: ['user_id', 'role', 'created_at'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 25, - showViewAll: true, - title: { en: 'Members', 'zh-CN': '成员', 'ja-JP': 'メンバー', 'es-ES': 'Miembros' }, - }, + label: { en: 'Members', 'zh-CN': '成员', 'ja-JP': 'メンバー', 'es-ES': 'Miembros' }, + icon: 'users', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_member', + relationshipField: 'organization_id', + columns: ['user_id', 'role', 'created_at'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 25, + showViewAll: true, + title: { en: 'Members', 'zh-CN': '成员', 'ja-JP': 'メンバー', 'es-ES': 'Miembros' }, + }, + }, + ], }, - ], - }, - { - label: { en: 'Invitations', 'zh-CN': '邀请', 'ja-JP': '招待', 'es-ES': 'Invitaciones' }, - icon: 'mail', - children: [ { - type: 'record:related_list', - properties: { - objectName: 'sys_invitation', - relationshipField: 'organization_id', - columns: ['email', 'role', 'status', 'expires_at', 'inviter_id'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 25, - showViewAll: true, - title: { en: 'Invitations', 'zh-CN': '邀请', 'ja-JP': '招待', 'es-ES': 'Invitaciones' }, - }, + label: { en: 'Invitations', 'zh-CN': '邀请', 'ja-JP': '招待', 'es-ES': 'Invitaciones' }, + icon: 'mail', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_invitation', + relationshipField: 'organization_id', + columns: ['email', 'role', 'status', 'expires_at', 'inviter_id'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 25, + showViewAll: true, + title: { en: 'Invitations', 'zh-CN': '邀请', 'ja-JP': '招待', 'es-ES': 'Invitaciones' }, + }, + }, + ], }, - ], - }, - { - label: { en: 'Teams', 'zh-CN': '团队', 'ja-JP': 'チーム', 'es-ES': 'Equipos' }, - icon: 'users-round', - children: [ { - type: 'record:related_list', - properties: { - objectName: 'sys_team', - relationshipField: 'organization_id', - columns: ['name', 'created_at', 'updated_at'], - sort: [{ field: 'name', order: 'asc' }], - limit: 25, - showViewAll: true, - title: { en: 'Teams', 'zh-CN': '团队', 'ja-JP': 'チーム', 'es-ES': 'Equipos' }, - }, + label: { en: 'Teams', 'zh-CN': '团队', 'ja-JP': 'チーム', 'es-ES': 'Equipos' }, + icon: 'users-round', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_team', + relationshipField: 'organization_id', + columns: ['name', 'created_at', 'updated_at'], + sort: [{ field: 'name', order: 'asc' }], + limit: 25, + showViewAll: true, + title: { en: 'Teams', 'zh-CN': '团队', 'ja-JP': 'チーム', 'es-ES': 'Equipos' }, + }, + }, + ], }, ], }, - ], - }, + }, + { type: 'record:discussion' }, + ], }, - }, + ], }; diff --git a/packages/platform-objects/src/pages/sys-user.page.ts b/packages/platform-objects/src/pages/sys-user.page.ts index 3b968571258..1067bd8bea1 100644 --- a/packages/platform-objects/src/pages/sys-user.page.ts +++ b/packages/platform-objects/src/pages/sys-user.page.ts @@ -23,7 +23,7 @@ import type { Page } from '@objectstack/spec/ui'; const TWO_FACTOR_OFFERED = P`has(features.twoFactor) && features.twoFactor == true`; /** - * sys_user — Record Detail Page (slotted, default for ALL sys_user records) + * sys_user — Record Detail Page (`kind: 'full'`, default for ALL sys_user records) * * **Audience**: admins browsing user records from Setup. * @@ -36,37 +36,37 @@ const TWO_FACTOR_OFFERED = P`has(features.twoFactor) && features.twoFactor == tr * * Strategy * -------- - * - `kind: 'slotted'` + `isDefault: true`: overrides `alerts`, - * `highlights`, `tabs` and `discussion`. Header / actions fall through - * to the synthesizer so the object's declared actions - * (`update_my_profile / change_my_password / resend_verification_email - * / ban_user / impersonate_user / …`) still appear - * in the header overflow menu automatically. - * - `highlights` promotes the signals worth scanning at the top: + * - `kind: 'full'` + `isDefault: true`: the page owns its curated tab strip, + * so it is drawn as authored at every width. It was a `kind: 'slotted'` + * page overriding `alerts`, `highlights`, `tabs` and `discussion` until the + * `tabs` slot retired (#27053); the rebuild renders the same nodes in the + * same order. The header the synthesizer used to supply is spelled out as + * a `page:header` carrying the object's declared header actions + * (`ban_user / impersonate_user / … / update_my_profile / + * change_my_password / resend_verification_email / …`), so they still + * appear in the header and its overflow menu. + * - `record:highlights` promotes the signals worth scanning at the top: * email, phone, verification state, platform role. Highlight fields * are auto-dropped from the details grid below. 2FA state is not one * of them: it is drawn only in the Security tab, behind the * deployment's `twoFactor` gate (see {@link TWO_FACTOR_OFFERED}). - * - The FIRST `tabs` item is the details grid: a `record:details` that + * - The FIRST tab is the details grid: a `record:details` that * re-groups remaining fields into sections and hides admin-internal * audit columns. Banned / ban metadata is still editable from the - * header actions — we just don't show it in every user's body. It is - * a tab item and not a `details` slot because the `tabs` slot replaces - * the whole tab strip the Details tab lives in: a `details` slot beside - * `tabs` never rendered, and `PageSchema` refuses the pair. - * - The other `tabs` items are **explicitly curated** to the related lists + * header actions — we just don't show it in every user's body. + * - The other tabs are **explicitly curated** to the related lists * that matter on a user profile (Positions / Permission Sets / Business * Units / Sessions / Linked Accounts / Organizations / OAuth Apps / API - * Keys / Preferences) plus a Security tab. Without this override, the - * synthesizer auto-generates a tab per object that has a FK to sys_user + * Keys / Preferences) plus a Security tab. The synthesized strip would + * carry a tab per object that has a FK to sys_user * (sys_position.created_by, sys_email.updated_by, - * sys_email_template.created_by, …) producing dozens of noisy - * "查看全部" cards on every profile. + * sys_email_template.created_by, …), dozens of noisy "查看全部" cards + * on every profile, and it has no Security tab of controls. * - Linked Accounts (`sys_account`) and Preferences (`sys_user_preference`) * are where an administrator reaches those two per-user stores: Setup * has no menu entry listing them across users (#23803). - * - `discussion: []` removes the Chatter feed — it has no business - * on a personal profile. + * - No `record:discussion` node: the Chatter feed has no business on a + * personal profile. */ export const SysUserDetailPage: Page = { name: 'sys_user_detail', @@ -74,547 +74,571 @@ export const SysUserDetailPage: Page = { type: 'record', object: 'sys_user', template: 'default', - kind: 'slotted', + kind: 'full', isDefault: true, - regions: [], - - slots: { - // ── Alert banners ───────────────────────────────────────────── - // Conditional notices rendered between the page header and the - // highlight strip. The unverified-email banner only shows for the - // current user viewing their own profile (admins looking at other - // users see nothing — they can use Setup actions instead). - alerts: [ - // The gate is the ADR-0089 canonical, component-NODE `visibleWhen` — a - // sibling of `properties`, never a key inside it. `record:alert` also - // DECLARES `properties.visible`, but `PageComponentSchema.properties` is - // an opaque record served verbatim, so a predicate there never reaches - // `ExpressionInputSchema` and is evaluated by the console's LEGACY JS - // evaluator, which has no `has()`. - // - // ⚠️ The envelope is load-bearing, and `P` is not decoration. This page is - // authored as a RAW `Page` object literal, so — unlike a page built with - // `definePage()` — nothing runs `ExpressionInputSchema`'s transform over - // it and whatever is written here reaches the wire verbatim. Measured in - // the real console at the pinned objectui SHA: a BARE string in - // `visibleWhen` also stays on that legacy evaluator ("bare strings and - // `${…}` templates stay on the legacy path … only an explicit - // `{ dialect: 'cel' }` envelope is rerouted"), so `has()` throws, the - // surface is fail-soft, and the banner shows on EVERY user — including - // other people's profiles. `P` emits the `{ dialect: 'cel', source }` - // envelope that routes to CEL, where `has()` is a real function. - // - // On CEL an absent key is a FAULT and that face is fail-soft too, hence - // the `has()` guards (the same shape the sibling - // `resend_verification_email` action predicate already carries). And - // hence no `visible` beside it: a node `visibleWhen` and - // `properties.visible` compose as AND, so leaving both would keep the - // legacy predicate load-bearing. - { - type: 'record:alert', - visibleWhen: P`has(record.id) && has(record.email_verified) && record.id == ctx.user.id && record.email_verified == false`, - properties: { - severity: 'warning', - icon: 'mail', - // Inline-i18n object labels: the renderer resolves them with - // pickLocalized (exact → base → default → en → first). - title: { - en: 'Email not verified', - 'zh-CN': '邮箱未验证', - 'ja-JP': 'メールが未認証です', - 'es-ES': 'Correo no verificado', - }, - body: { - en: 'Verify your email to receive password resets and important system notifications.', - 'zh-CN': '验证你的邮箱以接收密码重置和重要的系统通知。', - 'ja-JP': 'パスワードリセットや重要なシステム通知を受け取るには、メールアドレスを認証してください。', - 'es-ES': 'Verifica tu correo para recibir restablecimientos de contraseña y notificaciones importantes del sistema.', + regions: [ + { + name: 'main', + width: 'full', + components: [ + // ── The record header ───────────────────────────────────────── + // The synthesized header's action list, spelled out because a full + // page renders exactly the nodes it declares: every `sys_user` action + // declaring `record_header` or `record_more`, in declaration order + // (admin actions, then the self-service ones). The host appends its + // own system actions after these. + { + type: 'page:header', + properties: { + actions: [ + 'ban_user', + 'unban_user', + 'unlock_user', + 'set_user_password', + 'impersonate_user', + 'set_user_manager', + 'update_my_profile', + 'change_my_password', + 'change_my_email', + 'resend_verification_email', + 'delete_my_account', + ], }, - dismissible: false, - action: { - actionName: 'resend_verification_email', - label: { - en: 'Resend verification email', - 'zh-CN': '重新发送验证邮件', - 'ja-JP': '認証メールを再送信', - 'es-ES': 'Reenviar correo de verificación', + }, + // ── Alert banners ───────────────────────────────────────────── + // Conditional notices rendered between the page header and the + // highlight strip. The unverified-email banner only shows for the + // current user viewing their own profile (admins looking at other + // users see nothing — they can use Setup actions instead). + // The gate is the ADR-0089 canonical, component-NODE `visibleWhen` — a + // sibling of `properties`, never a key inside it. `record:alert` also + // DECLARES `properties.visible`, but `PageComponentSchema.properties` is + // an opaque record served verbatim, so a predicate there never reaches + // `ExpressionInputSchema` and is evaluated by the console's LEGACY JS + // evaluator, which has no `has()`. + // + // ⚠️ The envelope is load-bearing, and `P` is not decoration. This page is + // authored as a RAW `Page` object literal, so — unlike a page built with + // `definePage()` — nothing runs `ExpressionInputSchema`'s transform over + // it and whatever is written here reaches the wire verbatim. Measured in + // the real console at the pinned objectui SHA: a BARE string in + // `visibleWhen` also stays on that legacy evaluator ("bare strings and + // `${…}` templates stay on the legacy path … only an explicit + // `{ dialect: 'cel' }` envelope is rerouted"), so `has()` throws, the + // surface is fail-soft, and the banner shows on EVERY user — including + // other people's profiles. `P` emits the `{ dialect: 'cel', source }` + // envelope that routes to CEL, where `has()` is a real function. + // + // On CEL an absent key is a FAULT and that face is fail-soft too, hence + // the `has()` guards (the same shape the sibling + // `resend_verification_email` action predicate already carries). And + // hence no `visible` beside it: a node `visibleWhen` and + // `properties.visible` compose as AND, so leaving both would keep the + // legacy predicate load-bearing. + { + type: 'record:alert', + visibleWhen: P`has(record.id) && has(record.email_verified) && record.id == ctx.user.id && record.email_verified == false`, + properties: { + severity: 'warning', + icon: 'mail', + // Inline-i18n object labels: the renderer resolves them with + // pickLocalized (exact → base → default → en → first). + title: { + en: 'Email not verified', + 'zh-CN': '邮箱未验证', + 'ja-JP': 'メールが未認証です', + 'es-ES': 'Correo no verificado', + }, + body: { + en: 'Verify your email to receive password resets and important system notifications.', + 'zh-CN': '验证你的邮箱以接收密码重置和重要的系统通知。', + 'ja-JP': 'パスワードリセットや重要なシステム通知を受け取るには、メールアドレスを認証してください。', + 'es-ES': 'Verifica tu correo para recibir restablecimientos de contraseña y notificaciones importantes del sistema.', + }, + dismissible: false, + action: { + actionName: 'resend_verification_email', + label: { + en: 'Resend verification email', + 'zh-CN': '重新发送验证邮件', + 'ja-JP': '認証メールを再送信', + 'es-ES': 'Reenviar correo de verificación', + }, }, }, }, - }, - ], - // ── Highlight chips above the fold ──────────────────────────── - highlights: { - type: 'record:highlights', - properties: { - // No `two_factor_enabled` (#25287): a highlight field has no - // per-field gate, and on a deployment with 2FA off the chip read a - // dead "No". The state is drawn in the Security tab instead. - fields: ['email', 'phone_number', 'email_verified', 'role'], - }, - }, + // ── Highlight chips above the fold ──────────────────────────── + { + type: 'record:highlights', + properties: { + // No `two_factor_enabled` (#25287): a highlight field has no + // per-field gate, and on a deployment with 2FA off the chip read a + // dead "No". The state is drawn in the Security tab instead. + fields: ['email', 'phone_number', 'email_verified', 'role'], + }, + }, - // ── Tabs: the details grid, then curated related lists ──────── - // Only the lists that are semantically about THIS user account. - // Everything else (sys_position created_by, sys_email_template - // updated_by, …) is incidental authorship metadata and would only - // create noise. - // - // There is no `details` slot: the `tabs` slot replaces the whole tab - // strip the synthesized Details tab lives in, so a `details` slot beside - // it never rendered and `PageSchema` refuses the pair (#22568). The - // details grid is therefore the FIRST tab's body here. - tabs: { - type: 'page:tabs', - properties: { - // `tabStyle`, not `type` (#6776) — see sys-organization.page.ts. - tabStyle: 'line', - position: 'top', - items: [ - { - // ── Body / details grid ─────────────────────────────── - // Re-groups the remaining fields into sections and hides - // admin-internal columns. Banned / ban metadata is still - // editable from the header actions — it is just not shown in - // every user's body. - label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, - icon: 'file-text', - children: [ + // ── Tabs: the details grid, then curated related lists ──────── + // Only the lists that are semantically about THIS user account. + // Everything else (sys_position created_by, sys_email_template + // updated_by, …) is incidental authorship metadata and would only + // create noise. + // + // The details grid is the FIRST tab's body: this page owns its whole + // strip (`kind: 'full'`), so there is no synthesized Details tab for a + // `details` slot to fill. + { + type: 'page:tabs', + properties: { + // `tabStyle`, not `type` (#6776) — see sys-organization.page.ts. + tabStyle: 'line', + position: 'top', + items: [ { - type: 'record:details', - properties: { - hideFields: [ - 'id', - 'banned', - 'ban_reason', - 'ban_expires', - // already promoted to highlights: - 'email', - 'phone_number', - 'email_verified', - 'two_factor_enabled', // not a highlight: drawn in the Security tab, behind the 2FA gate - 'role', - ], - sections: [ - { - label: { en: 'Identity', 'zh-CN': '身份', 'ja-JP': 'アイデンティティ', 'es-ES': 'Identidad' }, - fields: ['name', 'image'], - }, - { - label: { en: 'Audit', 'zh-CN': '审计', 'ja-JP': '監査', 'es-ES': 'Auditoría' }, - fields: ['created_at', 'updated_at'], + // ── Body / details grid ─────────────────────────────── + // Re-groups the remaining fields into sections and hides + // admin-internal columns. Banned / ban metadata is still + // editable from the header actions — it is just not shown in + // every user's body. + label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, + icon: 'file-text', + children: [ + { + type: 'record:details', + properties: { + hideFields: [ + 'id', + 'banned', + 'ban_reason', + 'ban_expires', + // already promoted to highlights: + 'email', + 'phone_number', + 'email_verified', + 'two_factor_enabled', // not a highlight: drawn in the Security tab, behind the 2FA gate + 'role', + ], + sections: [ + { + label: { en: 'Identity', 'zh-CN': '身份', 'ja-JP': 'アイデンティティ', 'es-ES': 'Identidad' }, + fields: ['name', 'image'], + }, + { + label: { en: 'Audit', 'zh-CN': '审计', 'ja-JP': '監査', 'es-ES': 'Auditoría' }, + fields: ['created_at', 'updated_at'], + }, + ], }, - ], - }, - }, - ], - }, - { - label: { en: 'Positions', 'zh-CN': '岗位', 'ja-JP': 'ポジション', 'es-ES': 'Puestos' }, - icon: 'shield-check', - children: [ - { - // [ADR-0090 D3] Position assignments (岗位分派) — pure SDUI. - // [ADR-0131 D4, #15204 B2] No `add` picker: it could list - // `sys_position` ROWS only, and positions live in the security - // catalog. "New" opens the assignment form, whose `position` - // field picks a catalog position by name (`widget: - // 'position-ref'` on the object); Remove is the host's row - // delete. Every server-side rule (the D12 delegated-admin gate, - // audience-anchor rejection) surfaces its error in the form. - type: 'record:related_list', - properties: { - objectName: 'sys_user_position', - relationshipField: 'user_id', - columns: ['position', 'business_unit_id', 'granted_by', 'created_at'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 25, - showViewAll: true, - title: { en: 'Positions', 'zh-CN': '岗位', 'ja-JP': 'ポジション', 'es-ES': 'Puestos' }, - }, - }, - ], - }, - { - // [ADR-0091 D1] Direct permission-set grants (直接授权) — pure SDUI. - // The list keys on `relationshipField: 'user_id'` (a lookup keyed by - // record id, no relationshipValueField). [ADR-0131 D4, #15204 B2] A - // grant names its set BY NAME, and the set lives in the security - // catalog, not in a `sys_permission_set` row, so there is no `add` - // picker (it could list object rows only). "New" opens the grant - // form, whose `permission_set` field picks a catalog set by name - // (`widget: 'permission-ref'` on the object); Remove is the host's - // row delete. Server-side gates (the catalog name refusal, - // delegated-admin D12) surface their reason in the form. - // - // [ADR-0091 D1/D2] `valid_from` / `valid_until` are listed because - // they decide whether the grant confers anything: the resolver drops - // an assignment outside its half-open window at every evaluation, so - // a row showing only the set would read as a live grant when it is - // not. The same form sets the window, and the object refuses a - // window whose end is not after its start. - label: { en: 'Permission Sets', 'zh-CN': '权限集', 'ja-JP': '権限セット', 'es-ES': 'Conjuntos de permisos' }, - icon: 'lock', - children: [ - { - type: 'record:related_list', - properties: { - objectName: 'sys_user_permission_set', - relationshipField: 'user_id', - columns: ['permission_set', 'organization_id', 'valid_from', 'valid_until', 'granted_by', 'created_at'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 25, - showViewAll: true, - title: { en: 'Permission Sets', 'zh-CN': '权限集', 'ja-JP': '権限セット', 'es-ES': 'Conjuntos de permisos' }, - }, + }, + ], }, - ], - }, - { - // Business-unit membership (业务单元归属) — pure SDUI. - // sys_business_unit_member is an id-keyed junction: user_id / - // business_unit_id are lookups keyed by record id, so the list keys - // on `relationshipField: 'user_id'` (no relationshipValueField). - // The Add picker binds a BU by its id (linkField: 'business_unit_id'); - // sys_business_unit has no `label` field so the picker labels rows by - // its display field `name`. - label: { en: 'Business Units', 'zh-CN': '业务单元', 'ja-JP': 'ビジネスユニット', 'es-ES': 'Unidades de negocio' }, - icon: 'network', - children: [ { - type: 'record:related_list', - properties: { - objectName: 'sys_business_unit_member', - relationshipField: 'user_id', - columns: ['business_unit_id', 'function_in_business_unit', 'is_primary', 'created_at'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 25, - showViewAll: true, - title: { en: 'Business Units', 'zh-CN': '业务单元', 'ja-JP': 'ビジネスユニット', 'es-ES': 'Unidades de negocio' }, - add: { - picker: { - object: 'sys_business_unit', - labelField: 'name', - }, - linkField: 'business_unit_id', - label: { - en: 'Add to business unit', - 'zh-CN': '加入业务单元', - 'ja-JP': 'ビジネスユニットに追加', - 'es-ES': 'Añadir a unidad de negocio', + label: { en: 'Positions', 'zh-CN': '岗位', 'ja-JP': 'ポジション', 'es-ES': 'Puestos' }, + icon: 'shield-check', + children: [ + { + // [ADR-0090 D3] Position assignments (岗位分派) — pure SDUI. + // [ADR-0131 D4, #15204 B2] No `add` picker: it could list + // `sys_position` ROWS only, and positions live in the security + // catalog. "New" opens the assignment form, whose `position` + // field picks a catalog position by name (`widget: + // 'position-ref'` on the object); Remove is the host's row + // delete. Every server-side rule (the D12 delegated-admin gate, + // audience-anchor rejection) surfaces its error in the form. + type: 'record:related_list', + properties: { + objectName: 'sys_user_position', + relationshipField: 'user_id', + columns: ['position', 'business_unit_id', 'granted_by', 'created_at'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 25, + showViewAll: true, + title: { en: 'Positions', 'zh-CN': '岗位', 'ja-JP': 'ポジション', 'es-ES': 'Puestos' }, }, }, - }, + ], }, - ], - }, - { - label: { en: 'Sessions', 'zh-CN': '会话', 'ja-JP': 'セッション', 'es-ES': 'Sesiones' }, - icon: 'monitor', - children: [ - { - type: 'record:related_list', - properties: { - objectName: 'sys_session', - relationshipField: 'user_id', - columns: ['user_agent', 'ip_address', 'created_at', 'expires_at'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 25, - showViewAll: true, - title: { en: 'Sessions', 'zh-CN': '会话', 'ja-JP': 'セッション', 'es-ES': 'Sesiones' }, - }, - }, - ], - }, - { - label: { en: 'Linked Accounts', 'zh-CN': '关联账号', 'ja-JP': '連携アカウント', 'es-ES': 'Cuentas vinculadas' }, - icon: 'link', - children: [ - { - type: 'record:related_list', - properties: { - objectName: 'sys_account', - relationshipField: 'user_id', - columns: ['provider_id', 'account_id', 'created_at'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 25, - showViewAll: true, - title: { en: 'Linked Accounts', 'zh-CN': '关联账号', 'ja-JP': '連携アカウント', 'es-ES': 'Cuentas vinculadas' }, - }, - }, - ], - }, - { - label: { en: 'Organizations', 'zh-CN': '组织', 'ja-JP': '組織', 'es-ES': 'Organizaciones' }, - icon: 'building-2', - children: [ { - type: 'record:related_list', - properties: { - objectName: 'sys_member', - relationshipField: 'user_id', - columns: ['organization_id', 'role', 'created_at'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 25, - showViewAll: true, - title: { en: 'Organizations', 'zh-CN': '组织', 'ja-JP': '組織', 'es-ES': 'Organizaciones' }, - }, - }, - ], - }, - { - label: { en: 'OAuth Apps', 'zh-CN': 'OAuth 应用', 'ja-JP': 'OAuth アプリ', 'es-ES': 'Aplicaciones OAuth' }, - icon: 'key-square', - children: [ - { - type: 'record:related_list', - properties: { - objectName: 'sys_oauth_application', - relationshipField: 'user_id', - columns: ['name', 'client_id', 'created_at'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 25, - showViewAll: true, - title: { en: 'OAuth Apps', 'zh-CN': 'OAuth 应用', 'ja-JP': 'OAuth アプリ', 'es-ES': 'Aplicaciones OAuth' }, - }, - }, - ], - }, - // ── Security ────────────────────────────────────────────── - // Grouped self-service security controls. Each `record:quick_actions` - // pulls its actions by name from the sys_user object metadata - // (DRY — definitions stay on the object) and filters by - // `location: 'record_section'` so they only render here, not - // in the global header row. - // - // The whole tab is self-service, so the whole tab carries the - // own-record gate. Every action it names is gated to the viewer's - // own record on the object (`record.id == ctx.user.id`), so on - // another user's record each `record:quick_actions` draws nothing - // and only the "your account" headings would remain — speaking to - // an administrator about an account that is not theirs. An - // item-level `visibleWhen` omits the tab header AND panel (the - // renderer falls back to the first visible tab); a per-node gate - // would leave an empty Security tab behind. Same `P` envelope and - // `has()` guard as the unverified-email alert above, for the same - // reasons: this raw `Page` literal reaches the wire verbatim, and - // only the `{ dialect: 'cel' }` envelope routes to CEL. - { - label: { en: 'Security', 'zh-CN': '安全', 'ja-JP': 'セキュリティ', 'es-ES': 'Seguridad' }, - icon: 'shield', - visibleWhen: P`has(record.id) && record.id == ctx.user.id`, - children: [ - { - type: 'element:text', - properties: { - variant: 'h3', - content: { - en: 'Password & Sign-in', - 'zh-CN': '密码与登录', - 'ja-JP': 'パスワードとサインイン', - 'es-ES': 'Contraseña e inicio de sesión', + // [ADR-0091 D1] Direct permission-set grants (直接授权) — pure SDUI. + // The list keys on `relationshipField: 'user_id'` (a lookup keyed by + // record id, no relationshipValueField). [ADR-0131 D4, #15204 B2] A + // grant names its set BY NAME, and the set lives in the security + // catalog, not in a `sys_permission_set` row, so there is no `add` + // picker (it could list object rows only). "New" opens the grant + // form, whose `permission_set` field picks a catalog set by name + // (`widget: 'permission-ref'` on the object); Remove is the host's + // row delete. Server-side gates (the catalog name refusal, + // delegated-admin D12) surface their reason in the form. + // + // [ADR-0091 D1/D2] `valid_from` / `valid_until` are listed because + // they decide whether the grant confers anything: the resolver drops + // an assignment outside its half-open window at every evaluation, so + // a row showing only the set would read as a live grant when it is + // not. The same form sets the window, and the object refuses a + // window whose end is not after its start. + label: { en: 'Permission Sets', 'zh-CN': '权限集', 'ja-JP': '権限セット', 'es-ES': 'Conjuntos de permisos' }, + icon: 'lock', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_user_permission_set', + relationshipField: 'user_id', + columns: ['permission_set', 'organization_id', 'valid_from', 'valid_until', 'granted_by', 'created_at'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 25, + showViewAll: true, + title: { en: 'Permission Sets', 'zh-CN': '权限集', 'ja-JP': '権限セット', 'es-ES': 'Conjuntos de permisos' }, + }, }, - }, + ], }, { - type: 'element:text', - properties: { - variant: 'caption', - content: { - en: 'Change your password or the email address associated with this account.', - 'zh-CN': '修改密码或此账号绑定的邮箱地址。', - 'ja-JP': 'パスワード、またはこのアカウントに関連付けられたメールアドレスを変更します。', - 'es-ES': 'Cambia tu contraseña o la dirección de correo asociada a esta cuenta.', + // Business-unit membership (业务单元归属) — pure SDUI. + // sys_business_unit_member is an id-keyed junction: user_id / + // business_unit_id are lookups keyed by record id, so the list keys + // on `relationshipField: 'user_id'` (no relationshipValueField). + // The Add picker binds a BU by its id (linkField: 'business_unit_id'); + // sys_business_unit has no `label` field so the picker labels rows by + // its display field `name`. + label: { en: 'Business Units', 'zh-CN': '业务单元', 'ja-JP': 'ビジネスユニット', 'es-ES': 'Unidades de negocio' }, + icon: 'network', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_business_unit_member', + relationshipField: 'user_id', + columns: ['business_unit_id', 'function_in_business_unit', 'is_primary', 'created_at'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 25, + showViewAll: true, + title: { en: 'Business Units', 'zh-CN': '业务单元', 'ja-JP': 'ビジネスユニット', 'es-ES': 'Unidades de negocio' }, + add: { + picker: { + object: 'sys_business_unit', + labelField: 'name', + }, + linkField: 'business_unit_id', + label: { + en: 'Add to business unit', + 'zh-CN': '加入业务单元', + 'ja-JP': 'ビジネスユニットに追加', + 'es-ES': 'Añadir a unidad de negocio', + }, + }, + }, }, - }, - }, - { - type: 'record:quick_actions', - properties: { - location: 'record_section', - align: 'start', - actionNames: ['change_my_password', 'change_my_email'], - }, + ], }, - { type: 'element:divider' }, - // ── Two-factor block: every node carries TWO_FACTOR_OFFERED - // (#25287), so with 2FA off the block and its trailing divider - // are gone and Password flows straight into Email Verification. - // Per node rather than one wrapper, so the tab keeps its flat - // layout; a node gate that faults is withheld, not drawn. { - type: 'element:text', - visibleWhen: TWO_FACTOR_OFFERED, - properties: { - variant: 'h3', - content: { - en: 'Two-Factor Authentication', - 'zh-CN': '两步验证', - 'ja-JP': '二要素認証', - 'es-ES': 'Autenticación de dos factores', + label: { en: 'Sessions', 'zh-CN': '会话', 'ja-JP': 'セッション', 'es-ES': 'Sesiones' }, + icon: 'monitor', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_session', + relationshipField: 'user_id', + columns: ['user_agent', 'ip_address', 'created_at', 'expires_at'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 25, + showViewAll: true, + title: { en: 'Sessions', 'zh-CN': '会话', 'ja-JP': 'セッション', 'es-ES': 'Sesiones' }, + }, }, - }, + ], }, { - type: 'element:text', - visibleWhen: TWO_FACTOR_OFFERED, - properties: { - variant: 'caption', - content: { - en: 'Add a second layer of security using a TOTP authenticator app. Backup codes let you sign in if you lose your device.', - 'zh-CN': '使用 TOTP 验证器应用添加第二层安全防护。备用验证码可在设备丢失时用于登录。', - 'ja-JP': 'TOTP 認証アプリでセキュリティをもう一段強化します。バックアップコードがあれば、デバイスを紛失してもサインインできます。', - 'es-ES': 'Añade una segunda capa de seguridad con una app de autenticación TOTP. Los códigos de respaldo te permiten iniciar sesión si pierdes tu dispositivo.', + label: { en: 'Linked Accounts', 'zh-CN': '关联账号', 'ja-JP': '連携アカウント', 'es-ES': 'Cuentas vinculadas' }, + icon: 'link', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_account', + relationshipField: 'user_id', + columns: ['provider_id', 'account_id', 'created_at'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 25, + showViewAll: true, + title: { en: 'Linked Accounts', 'zh-CN': '关联账号', 'ja-JP': '連携アカウント', 'es-ES': 'Cuentas vinculadas' }, + }, }, - }, + ], }, { - // The 2FA state, moved here from the highlights strip: shown - // only where it can be acted on. - type: 'record:highlights', - visibleWhen: TWO_FACTOR_OFFERED, - properties: { - fields: ['two_factor_enabled'], - }, - }, - { - type: 'record:quick_actions', - visibleWhen: TWO_FACTOR_OFFERED, - properties: { - location: 'record_section', - align: 'start', - actionNames: ['enable_two_factor', 'disable_two_factor', 'generate_backup_codes'], - }, - }, - { type: 'element:divider', visibleWhen: TWO_FACTOR_OFFERED }, - { - type: 'element:text', - properties: { - variant: 'h3', - content: { - en: 'Email Verification', - 'zh-CN': '邮箱验证', - 'ja-JP': 'メール認証', - 'es-ES': 'Verificación de correo', + label: { en: 'Organizations', 'zh-CN': '组织', 'ja-JP': '組織', 'es-ES': 'Organizaciones' }, + icon: 'building-2', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_member', + relationshipField: 'user_id', + columns: ['organization_id', 'role', 'created_at'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 25, + showViewAll: true, + title: { en: 'Organizations', 'zh-CN': '组织', 'ja-JP': '組織', 'es-ES': 'Organizaciones' }, + }, }, - }, + ], }, { - type: 'element:text', - properties: { - variant: 'caption', - content: { - en: 'Verify your email so password resets and notifications reach you. The button appears only while verification is pending.', - 'zh-CN': '验证你的邮箱,以便接收密码重置和系统通知。按钮仅在邮箱待验证时显示。', - 'ja-JP': 'パスワードリセットや通知を受け取れるよう、メールアドレスを認証してください。ボタンは未認証の間のみ表示されます。', - 'es-ES': 'Verifica tu correo para recibir restablecimientos de contraseña y notificaciones. El botón solo aparece mientras la verificación está pendiente.', + label: { en: 'OAuth Apps', 'zh-CN': 'OAuth 应用', 'ja-JP': 'OAuth アプリ', 'es-ES': 'Aplicaciones OAuth' }, + icon: 'key-square', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_oauth_application', + relationshipField: 'user_id', + columns: ['name', 'client_id', 'created_at'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 25, + showViewAll: true, + title: { en: 'OAuth Apps', 'zh-CN': 'OAuth 应用', 'ja-JP': 'OAuth アプリ', 'es-ES': 'Aplicaciones OAuth' }, + }, }, - }, + ], }, + // ── Security ────────────────────────────────────────────── + // Grouped self-service security controls. Each `record:quick_actions` + // pulls its actions by name from the sys_user object metadata + // (DRY — definitions stay on the object) and filters by + // `location: 'record_section'` so they only render here, not + // in the global header row. + // + // The whole tab is self-service, so the whole tab carries the + // own-record gate. Every action it names is gated to the viewer's + // own record on the object (`record.id == ctx.user.id`), so on + // another user's record each `record:quick_actions` draws nothing + // and only the "your account" headings would remain — speaking to + // an administrator about an account that is not theirs. An + // item-level `visibleWhen` omits the tab header AND panel (the + // renderer falls back to the first visible tab); a per-node gate + // would leave an empty Security tab behind. Same `P` envelope and + // `has()` guard as the unverified-email alert above, for the same + // reasons: this raw `Page` literal reaches the wire verbatim, and + // only the `{ dialect: 'cel' }` envelope routes to CEL. { - type: 'record:quick_actions', - properties: { - location: 'record_section', - align: 'start', - actionNames: ['resend_verification_email'], - }, - }, - { type: 'element:divider' }, - { - type: 'element:text', - properties: { - variant: 'h3', - content: { - en: 'Danger Zone', - 'zh-CN': '危险操作', - 'ja-JP': '危険な操作', - 'es-ES': 'Zona de peligro', + label: { en: 'Security', 'zh-CN': '安全', 'ja-JP': 'セキュリティ', 'es-ES': 'Seguridad' }, + icon: 'shield', + visibleWhen: P`has(record.id) && record.id == ctx.user.id`, + children: [ + { + type: 'element:text', + properties: { + variant: 'h3', + content: { + en: 'Password & Sign-in', + 'zh-CN': '密码与登录', + 'ja-JP': 'パスワードとサインイン', + 'es-ES': 'Contraseña e inicio de sesión', + }, + }, }, - }, - className: 'text-destructive', - }, - { - type: 'element:text', - properties: { - variant: 'caption', - content: { - en: 'Permanent. Once deleted, your account cannot be recovered.', - 'zh-CN': '此操作不可逆,账号一经删除将无法恢复。', - 'ja-JP': 'この操作は取り消せません。削除されたアカウントは復元できません。', - 'es-ES': 'Permanente. Una vez eliminada, tu cuenta no se puede recuperar.', + { + type: 'element:text', + properties: { + variant: 'caption', + content: { + en: 'Change your password or the email address associated with this account.', + 'zh-CN': '修改密码或此账号绑定的邮箱地址。', + 'ja-JP': 'パスワード、またはこのアカウントに関連付けられたメールアドレスを変更します。', + 'es-ES': 'Cambia tu contraseña o la dirección de correo asociada a esta cuenta.', + }, + }, }, - }, - }, - { - type: 'record:quick_actions', - properties: { - location: 'record_section', - align: 'start', - actionNames: ['delete_my_account'], - }, + { + type: 'record:quick_actions', + properties: { + location: 'record_section', + align: 'start', + actionNames: ['change_my_password', 'change_my_email'], + }, + }, + { type: 'element:divider' }, + // ── Two-factor block: every node carries TWO_FACTOR_OFFERED + // (#25287), so with 2FA off the block and its trailing divider + // are gone and Password flows straight into Email Verification. + // Per node rather than one wrapper, so the tab keeps its flat + // layout; a node gate that faults is withheld, not drawn. + { + type: 'element:text', + visibleWhen: TWO_FACTOR_OFFERED, + properties: { + variant: 'h3', + content: { + en: 'Two-Factor Authentication', + 'zh-CN': '两步验证', + 'ja-JP': '二要素認証', + 'es-ES': 'Autenticación de dos factores', + }, + }, + }, + { + type: 'element:text', + visibleWhen: TWO_FACTOR_OFFERED, + properties: { + variant: 'caption', + content: { + en: 'Add a second layer of security using a TOTP authenticator app. Backup codes let you sign in if you lose your device.', + 'zh-CN': '使用 TOTP 验证器应用添加第二层安全防护。备用验证码可在设备丢失时用于登录。', + 'ja-JP': 'TOTP 認証アプリでセキュリティをもう一段強化します。バックアップコードがあれば、デバイスを紛失してもサインインできます。', + 'es-ES': 'Añade una segunda capa de seguridad con una app de autenticación TOTP. Los códigos de respaldo te permiten iniciar sesión si pierdes tu dispositivo.', + }, + }, + }, + { + // The 2FA state, moved here from the highlights strip: shown + // only where it can be acted on. + type: 'record:highlights', + visibleWhen: TWO_FACTOR_OFFERED, + properties: { + fields: ['two_factor_enabled'], + }, + }, + { + type: 'record:quick_actions', + visibleWhen: TWO_FACTOR_OFFERED, + properties: { + location: 'record_section', + align: 'start', + actionNames: ['enable_two_factor', 'disable_two_factor', 'generate_backup_codes'], + }, + }, + { type: 'element:divider', visibleWhen: TWO_FACTOR_OFFERED }, + { + type: 'element:text', + properties: { + variant: 'h3', + content: { + en: 'Email Verification', + 'zh-CN': '邮箱验证', + 'ja-JP': 'メール認証', + 'es-ES': 'Verificación de correo', + }, + }, + }, + { + type: 'element:text', + properties: { + variant: 'caption', + content: { + en: 'Verify your email so password resets and notifications reach you. The button appears only while verification is pending.', + 'zh-CN': '验证你的邮箱,以便接收密码重置和系统通知。按钮仅在邮箱待验证时显示。', + 'ja-JP': 'パスワードリセットや通知を受け取れるよう、メールアドレスを認証してください。ボタンは未認証の間のみ表示されます。', + 'es-ES': 'Verifica tu correo para recibir restablecimientos de contraseña y notificaciones. El botón solo aparece mientras la verificación está pendiente.', + }, + }, + }, + { + type: 'record:quick_actions', + properties: { + location: 'record_section', + align: 'start', + actionNames: ['resend_verification_email'], + }, + }, + { type: 'element:divider' }, + { + type: 'element:text', + properties: { + variant: 'h3', + content: { + en: 'Danger Zone', + 'zh-CN': '危险操作', + 'ja-JP': '危険な操作', + 'es-ES': 'Zona de peligro', + }, + }, + className: 'text-destructive', + }, + { + type: 'element:text', + properties: { + variant: 'caption', + content: { + en: 'Permanent. Once deleted, your account cannot be recovered.', + 'zh-CN': '此操作不可逆,账号一经删除将无法恢复。', + 'ja-JP': 'この操作は取り消せません。削除されたアカウントは復元できません。', + 'es-ES': 'Permanente. Una vez eliminada, tu cuenta no se puede recuperar.', + }, + }, + }, + { + type: 'record:quick_actions', + properties: { + location: 'record_section', + align: 'start', + actionNames: ['delete_my_account'], + }, + }, + ], }, - ], - }, - // ── API Keys ────────────────────────────────────────────── - // Programmatic credentials issued for this user. Filtered by - // user_id FK; the sys_api_key object's own list-item actions - // (revoke / restore) handle row operations. - { - label: { en: 'API Keys', 'zh-CN': 'API 密钥', 'ja-JP': 'API キー', 'es-ES': 'Claves de API' }, - icon: 'key-round', - children: [ + // ── API Keys ────────────────────────────────────────────── + // Programmatic credentials issued for this user. Filtered by + // user_id FK; the sys_api_key object's own list-item actions + // (revoke / restore) handle row operations. { - type: 'record:related_list', - properties: { - objectName: 'sys_api_key', - relationshipField: 'user_id', - columns: ['name', 'prefix', 'expires_at', 'revoked', 'created_at'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 25, - showViewAll: true, - title: { en: 'API Keys', 'zh-CN': 'API 密钥', 'ja-JP': 'API キー', 'es-ES': 'Claves de API' }, - }, + label: { en: 'API Keys', 'zh-CN': 'API 密钥', 'ja-JP': 'API キー', 'es-ES': 'Claves de API' }, + icon: 'key-round', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_api_key', + relationshipField: 'user_id', + columns: ['name', 'prefix', 'expires_at', 'revoked', 'created_at'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 25, + showViewAll: true, + title: { en: 'API Keys', 'zh-CN': 'API 密钥', 'ja-JP': 'API キー', 'es-ES': 'Claves de API' }, + }, + }, + ], }, - ], - }, - // ── Preferences ─────────────────────────────────────────── - // [#23803] This user's `sys_user_preference` rows — the console's - // per-user UI state (`ui.recent`, `ui.favorites`, `ui.list.` - // …). Setup's "User Preferences" menu entry, which listed every - // user's rows, left the menu; this tab is where an administrator - // reaches them now, from the user they belong to. - // - // The columns are the object's `all_preferences` view (the grid that - // entry opened) minus `user_id`, which is the page's own record. - // Nothing is shown here that view did not show. No `add` and no - // `actions`: New / Edit / Delete are the host's, decided by the - // object's `managedBy: 'system-data'` and the caller's grant, the - // same verdict the grid's buttons read. - { - label: { en: 'Preferences', 'zh-CN': '偏好设置', 'ja-JP': '設定', 'es-ES': 'Preferencias' }, - icon: 'sliders', - children: [ + // ── Preferences ─────────────────────────────────────────── + // [#23803] This user's `sys_user_preference` rows — the console's + // per-user UI state (`ui.recent`, `ui.favorites`, `ui.list.` + // …). Setup's "User Preferences" menu entry, which listed every + // user's rows, left the menu; this tab is where an administrator + // reaches them now, from the user they belong to. + // + // The columns are the object's `all_preferences` view (the grid that + // entry opened) minus `user_id`, which is the page's own record. + // Nothing is shown here that view did not show. No `add` and no + // `actions`: New / Edit / Delete are the host's, decided by the + // object's `managedBy: 'system-data'` and the caller's grant, the + // same verdict the grid's buttons read. { - type: 'record:related_list', - properties: { - objectName: 'sys_user_preference', - relationshipField: 'user_id', - columns: ['key', 'created_at', 'updated_at'], - sort: [{ field: 'updated_at', order: 'desc' }], - limit: 25, - showViewAll: true, - title: { en: 'Preferences', 'zh-CN': '偏好设置', 'ja-JP': '設定', 'es-ES': 'Preferencias' }, - }, + label: { en: 'Preferences', 'zh-CN': '偏好设置', 'ja-JP': '設定', 'es-ES': 'Preferencias' }, + icon: 'sliders', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_user_preference', + relationshipField: 'user_id', + columns: ['key', 'created_at', 'updated_at'], + sort: [{ field: 'updated_at', order: 'desc' }], + limit: 25, + showViewAll: true, + title: { en: 'Preferences', 'zh-CN': '偏好设置', 'ja-JP': '設定', 'es-ES': 'Preferencias' }, + }, + }, + ], }, ], }, - ], - }, + }, + // No discussion node: the Chatter feed has no business on a personal + // profile (the slotted page's `discussion: []`). + ], }, - - // ── Suppress the Discussion / Chatter thread ────────────────── - discussion: [], - }, + ], }; diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request.page.ts b/packages/plugins/plugin-approvals/src/sys-approval-request.page.ts index 1b31bf215cb..9a571e5871c 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request.page.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request.page.ts @@ -3,8 +3,8 @@ import type { Page } from '@objectstack/spec/ui'; /** - * sys_approval_request — Record Detail Page (slotted, default for ALL - * sys_approval_request records). + * sys_approval_request — Record Detail Page (`kind: 'full'`, default for + * ALL sys_approval_request records). * * **Audience**: the people on a request — its submitter and its approvers — * opening it from the approvals inbox or from Setup → Approvals → Requests. @@ -17,15 +17,17 @@ import type { Page } from '@objectstack/spec/ui'; * * Strategy * -------- - * - `kind: 'slotted'` + `isDefault: true`. `header` falls through to the - * synthesizer, so the request's title and its header actions stay as the - * object declares them. - * - `actions` holds ONE `record:approval_decision` node: the request's + * - `kind: 'full'` + `isDefault: true`: the page owns its tab strip, so it is + * drawn as authored at every width. It was a `kind: 'slotted'` page until + * the `tabs` slot retired (#27053); the rebuild renders the same nodes in + * the same order. Its `page:header` is the one the synthesizer supplied: + * the request's title, and the header actions the object declares (none). + * - After the header, ONE `record:approval_decision` node: the request's * decision progress and its declared decision actions, the reply * (`approval_comment`) included, in one panel. The type is declared with an * empty strict props row, so the node carries no `properties` at all; the * panel reads the bound request and the object's own actions. - * - `highlights` is the object's `highlightFields` WITHOUT `record_id`. The + * - The highlight strip is the object's `highlightFields` WITHOUT `record_id`. The * details grid hides every field a mounted highlights strip shows, and * `record_id` / `object_name` is a declared `referenceVia` pointer pair that * the console draws as a card of the target record IN THE DETAILS GRID — a @@ -59,22 +61,19 @@ import type { Page } from '@objectstack/spec/ui'; * read it, or the platform deleted it), the page names no object at all. * `object_name` leaves the object's `highlightFields` too, by the rule * above, so the Details tab does not draw it as a row. - * - `tabs` is authored: Details (`record:details`, a bare node: see the - * strip above) and Timeline — `sys_approval_action` as a + * - The tab strip is authored: Details (`record:details`, a bare node: see + * the strip above) and Timeline — `sys_approval_action` as a * related list keyed on `request_id`, newest first. Its step column is * `step_label` (the step's label, stored on every row), not `step_name` * (the node's machine id, such as `dual_signoff`), as the strip's step - * chip is `current_step_label`. The `tabs` slot is - * authored rather than `details` because the console builds the tab strip - * from `tabs` when both are present; the details body therefore lives in - * the first tab here, and the page authors no `details` slot. - * - `discussion: []` removes the generic discussion thread. A request's - * thread is its timeline, and its reply is the declared `approval_comment` - * action, which the service stores as a `sys_approval_action` row the - * Timeline tab lists. - * - There is no aside: the slot map has no right-rail slot. + * chip is `current_step_label`. + * - There is no `record:discussion` node: the generic discussion thread has + * no place here. A request's thread is its timeline, and its reply is the + * declared `approval_comment` action, which the service stores as a + * `sys_approval_action` row the Timeline tab lists. + * - There is no aside: the page places only its `main` region. * - * Copy inside `slots` is authored as inline locale maps, the ruled route for + * Copy inside the components is authored as inline locale maps, the ruled route for * page prose; the page-level `label` is the one key a translation bundle * reaches (`pages.sys_approval_request_detail.label`, in * `translations/pages.ts`). @@ -85,58 +84,66 @@ export const SysApprovalRequestDetailPage: Page = { type: 'record', object: 'sys_approval_request', template: 'default', - kind: 'slotted', + kind: 'full', isDefault: true, - regions: [], + regions: [ + { + name: 'main', + width: 'full', + components: [ + // ── The record header: the request's title. The object declares no + // `record_header` / `record_more` action, so it carries none, exactly + // as the synthesized header did; the host appends its system actions. + { type: 'page:header' }, - slots: { - // ── The decision panel ──────────────────────────────────────── - actions: [{ type: 'record:approval_decision' }], + // ── The decision panel ──────────────────────────────────────── + { type: 'record:approval_decision' }, - // ── Highlight chips, `record_id` left to the details grid ───── - highlights: { - type: 'record:highlights', - properties: { - fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], - }, - }, - - // ── Tabs: the request's fields, then its timeline ───────────── - tabs: { - type: 'page:tabs', - properties: { - tabStyle: 'line', - position: 'top', - items: [ - { - label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, - icon: 'file-text', - children: [{ type: 'record:details' }], + // ── Highlight chips, `record_id` left to the details grid ───── + { + type: 'record:highlights', + properties: { + fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], }, - { - label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - icon: 'history', - children: [ + }, + + // ── Tabs: the request's fields, then its timeline ───────────── + { + type: 'page:tabs', + properties: { + tabStyle: 'line', + position: 'top', + items: [ { - type: 'record:related_list', - properties: { - objectName: 'sys_approval_action', - relationshipField: 'request_id', - columns: ['created_at', 'action', 'actor_id', 'step_label', 'comment', 'attachments'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 50, - showViewAll: true, - title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - }, + label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, + icon: 'file-text', + children: [{ type: 'record:details' }], + }, + { + label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + icon: 'history', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_approval_action', + relationshipField: 'request_id', + columns: ['created_at', 'action', 'actor_id', 'step_label', 'comment', 'attachments'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 50, + showViewAll: true, + title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + }, + }, + ], }, ], }, - ], - }, - }, + }, - // ── No generic discussion thread: the reply is `approval_comment` ── - discussion: [], - }, + // No `record:discussion` node: the reply is `approval_comment`. + ], + }, + ], }; diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 6696eb0da32..9e676e6a107 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -2260,7 +2260,9 @@ describe('walkAddressedPageComponents — the one addressed-component walk, shar regions: [], slots: { highlights: { id: 'path', type: 'record:path', properties: {} }, - tabs: { + // A `page:tabs` node under a live slot (the `tabs` slot retired, + // #27053): the walk reads every slot the same way. + details: { id: 'tabs', type: 'page:tabs', properties: { @@ -2546,7 +2548,9 @@ describe('translatePage — slotted page roots and tab panels', () => { * The measured shape, rebuilt as a minimal fixture rather than restated * from the card: a `kind: 'slotted'` record page authoring `regions: []`, * its header / path / details under `slots.*`, and a `page:tabs` under - * `slots.tabs` whose panels hold the related lists. Every authored copy + * `slots.discussion` whose panels hold the related lists (it sat under + * `slots.tabs` until that slot retired, #27053; the walk reads every slot + * the same way). Every authored copy * site that the walk can address carries an id. */ const contractPage = () => ({ @@ -2560,7 +2564,7 @@ describe('translatePage — slotted page roots and tab panels', () => { header: { type: 'page:header', id: 'hdr', properties: { title: 'Contract', subtitle: 'Lifecycle' } }, highlights: { type: 'record:path', id: 'stage_path', properties: { label: 'Stage', field: 'stage' } }, details: [{ type: 'record:details', id: 'main_details', properties: { label: 'Details' } }], - tabs: { + discussion: { type: 'page:tabs', id: 'detail_tabs', properties: { @@ -2638,7 +2642,7 @@ describe('translatePage — slotted page roots and tab panels', () => { it('resolves each authored key — slots roots, tab panels, and a card inside a panel', () => { const out = translatePage(contractPage(), bundle, { locale: 'zh-CN' }); - const tabs = (out.slots as any).tabs; + const tabs = (out.slots as any).discussion; expect(out.label).toBe('合同'); expect((out.slots as any).highlights.properties.label).toBe('阶段'); expect((out.slots as any).details[0].properties.label).toBe('详细信息'); @@ -2659,7 +2663,7 @@ describe('translatePage — slotted page roots and tab panels', () => { it('CONTROL: a component under `body` inside a panel stays untranslated, and a slotted page without a bundle entry is untouched', () => { const out = translatePage(contractPage(), bundle, { locale: 'zh-CN' }); - const docCard = (out.slots as any).tabs.properties.items[2].children[0]; + const docCard = (out.slots as any).discussion.properties.items[2].children[0]; expect(docCard.properties.body[0].properties.title).toBe('Body control'); const other = translatePage( @@ -2675,10 +2679,10 @@ describe('translatePage — slotted page roots and tab panels', () => { // is also localisable at its authoring site. A bundle that does not key // the item must leave that map alone rather than flatten it. const page = contractPage(); - (page.slots.tabs.properties.items[0] as any).label = { en: 'Parties', 'zh-CN': '当事方' }; + (page.slots.discussion.properties.items[0] as any).label = { en: 'Parties', 'zh-CN': '当事方' }; const out = translatePage(page, bundle, { locale: 'zh-CN' }); - expect((out.slots as any).tabs.properties.items[0].label).toEqual({ en: 'Parties', 'zh-CN': '当事方' }); - expect((out.slots as any).tabs.properties.items[1].label).toBe('Clauses'); + expect((out.slots as any).discussion.properties.items[0].label).toEqual({ en: 'Parties', 'zh-CN': '当事方' }); + expect((out.slots as any).discussion.properties.items[1].label).toBe('Clauses'); }); }); From dee4b1a051ea613b44604efd6c435626bc724916 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:35:40 +0000 Subject: [PATCH 03/11] wip: ui synthesizer, types mirror and Studio slots; producer tests; ADR-0133 amendment, QA pins, skills bullet, changeset Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzff3QiP6kmtajVnKnq --- .changeset/27053-page-slots-tabs-retired.md | 32 ++++++++++ docs/adr/0133-org-management-open-basics.md | 6 ++ .../areas/identity-auth.json | 7 ++- .../areas/studio-authoring.json | 16 +++-- .../src/ui/pages/project-detail.page.ts | 2 +- .../test/platform-page-i18n-parity.test.ts | 13 ++-- .../apps/setup-nav-per-user-stores.test.ts | 3 +- .../src/identity/invite-entry-toolbar.test.ts | 14 ++--- .../src/identity/sys-member.object.ts | 11 ++-- .../canonical-expression-envelopes.test.ts | 13 ++-- .../src/pages/sys-user-details-tab.test.ts | 32 +++++----- .../sys-user-security-tab-own-record.test.ts | 2 +- .../pages/sys-user-two-factor-display.test.ts | 7 ++- .../plugin-approvals/src/approvals-plugin.ts | 4 +- .../src/sys-approval-request-page.test.ts | 60 +++++++++++-------- .../src/translations/pages.ts | 2 +- packages/spec/liveness/page.json | 2 +- ...ponent-filter-record-to-rule-array.test.ts | 4 +- .../retired-key-removal-version.pin.test.ts | 9 +++ skills/objectstack-ui/rules/pages.md | 12 ++-- ui/content/docs/guide/slotted-pages.md | 18 ++++-- .../app-shell/src/views/RecordDetailView.tsx | 20 +++---- .../previews/PageBlockCanvas.tsx | 6 +- .../metadata-admin/previews/PagePreview.tsx | 2 +- .../previews/__tests__/slots-canvas.test.ts | 18 +++--- ui/packages/plugin-detail/README.md | 4 +- .../synth/__tests__/recordPageColumns.test.ts | 4 +- .../src/synth/buildDefaultPageSchema.ts | 18 +++--- .../src/synth/recordPageColumns.ts | 2 +- .../__tests__/imported-defaults-8317.test.ts | 7 +-- .../spec-object-refinements-7715.test.ts | 29 ++++----- ui/packages/types/src/layout.ts | 9 ++- ui/packages/types/src/zod/layout.zod.ts | 19 +++--- 33 files changed, 245 insertions(+), 162 deletions(-) create mode 100644 .changeset/27053-page-slots-tabs-retired.md diff --git a/.changeset/27053-page-slots-tabs-retired.md b/.changeset/27053-page-slots-tabs-retired.md new file mode 100644 index 00000000000..6262f8f2444 --- /dev/null +++ b/.changeset/27053-page-slots-tabs-retired.md @@ -0,0 +1,32 @@ +--- +'@objectstack/spec': minor +'@objectstack/platform-objects': minor +'@objectstack/plugin-approvals': minor +'@object-ui/types': minor +'@object-ui/plugin-detail': minor +'@object-ui/app-shell': minor +'@objectstack/skills': minor +--- + +feat(spec,ui)!: the record page's `slots.tabs` is retired — a slotted page fills six slots, and a page that needs its own tab strip is `kind: 'full'` + +Clause-②: no (narrowing) + +Metadata-protocol: narrowed (`page.slots.tabs` is a tombstone that refuses every value; `slots.related` / `slots.relatedLists` are refused by name instead of being read as `tabs`) + + + +**BREAKING** accept-set narrowing on the record page (`@objectstack/spec`), with the console following it in the same release. + +The record page composes its tabs and columns from the object's roles and relationship flags. An authored `tabs` slot replaced that whole strip, so a page that used it lost every synthesized related list and its wide-screen columns. The slot is gone; the six live slots (`header`, `actions`, `alerts`, `highlights`, `details`, `discussion`) are unchanged. + +- **FROM → TO.** `slots: { tabs: … }` on a `kind: 'slotted'` page → delete the key. To change which related lists appear or their order, set `relatedList` / `relatedList: 'primary'` on the child's reference field; to replace the Details body, author `slots.details`; a page that needs its own strip is `kind: 'full'` and authors its header, highlights, `page:tabs` strip and `record:discussion` in `regions`, drawn as authored. +- **The one-line fix.** `os migrate meta --from 17` lists the edit for existing sources, and `--write` applies it. A stored `sys_metadata` page is converted when it is read (the `page-slots-tabs-removed` conversion): the slot is dropped, and a page whose only slot it was becomes an empty slotted page, which renders the synthesized default. Whether such a page should become `kind: 'full'` is a judgement the conversion cannot make; the upgrade guide entry `page-slots-tabs-retired` lists it. +- **What the parse says.** `slots.tabs` is refused with its prescription ("`page.slots.tabs` was removed in @objectstack/spec 18.3.0 (ADR-0085) — …") at `defineStack` as `STACK_SCHEMA_INVALID` / 422, and at the metadata save door. `slots.related` and `slots.relatedLists` are refused by name with the same remedy. TypeScript rejects `tabs` in a `definePage` slot map. +- **The pair check.** `checkPageSlotPair` (the `details` + `tabs` refusal) is removed from `@objectstack/spec/ui` with the slot it paired; the `page-slots-details-beside-tabs-refused` upgrade entry is folded into `page-slots-tabs-retired`. + +The four in-repo pages that authored `slots.tabs` are rebuilt as `kind: 'full'` pages that render the same nodes in the same order: `sys_user_detail` and `sys_organization_detail` (`@objectstack/platform-objects`), `sys_approval_request_detail` (`@objectstack/plugin-approvals`) and the showcase `showcase_project_detail`. + +The console (`@object-ui/*`): `buildDefaultPageSchema` composes from the six slots only, and `RecordDetailView` lays every slotted page out by width (three columns from 1280 px), since none can opt out with an authored strip any more. `@object-ui/types` drops the pair check from its page schemas and types `PageSlotMap.tabs` as `never`. The Studio slot canvas lists six slots. + +`@objectstack/skills`: the UI skill says a slotted page has no `tabs` slot and how to shape the strip instead. diff --git a/docs/adr/0133-org-management-open-basics.md b/docs/adr/0133-org-management-open-basics.md index d7c79b21fef..54b56004717 100644 --- a/docs/adr/0133-org-management-open-basics.md +++ b/docs/adr/0133-org-management-open-basics.md @@ -260,6 +260,12 @@ resolved here. (`packages/plugins/plugin-auth/src/auth-plugin.ts#pages`). That file was already in the tree at `77781151d`, the very commit cited above. + > **Amended (2026-10-28) — the mechanism moves from slotted to full; the + > strip does not.** The `tabs` slot retired (#27053), so + > `SysOrganizationDetailPage` is now a `kind: 'full'` page that authors the + > same Members, Invitations, Teams strip in its own `page:tabs` node, with + > the header, highlights and discussion the slotted page rendered. + ⭐ **What the measurement missed, stated so it transfers.** The search was exhaustive over the wrong space: scoped to `src/identity/` and to the `relatedList` key, while the declaration lives one directory over, in diff --git a/docs/qa/platform-checklist/areas/identity-auth.json b/docs/qa/platform-checklist/areas/identity-auth.json index ab39f423a11..40d5b951cdf 100644 --- a/docs/qa/platform-checklist/areas/identity-auth.json +++ b/docs/qa/platform-checklist/areas/identity-auth.json @@ -846,7 +846,7 @@ "title": "Setup Organization page resolves the active org and drives member/invitation/team management through the better-auth org endpoints — non-admins refused server-side", "since": "v17", "status": "active", - "revision": 5, + "revision": 6, "priority": "P1", "surface": "mixed", "personas": ["tenant admin / org owner", "a target org member", "a non-admin org member (forger)"], @@ -921,7 +921,7 @@ "traps": ["wrong-persona", "dispatcher-vs-hono-route", "hydration-race"], "source": [ "packages/platform-objects/src/apps/setup-nav.contributions.ts#nav_organization (nav_organization recordId {current_org_id}, cloud ADR-0081 D3; Teams/Invitations always mounted per cloud ADR-0081 D1)", - "packages/platform-objects/src/pages/sys-organization.page.ts#SysOrganizationDetailPage (WHERE THE THREE TABS COME FROM: a kind: 'slotted' record page whose slots.tabs override replaces the synthesized tab strip outright, giving exactly Members / Invitations / Teams with Members at index 0. Handed to the runtime by plugin-auth's pages: [...]. NOT the Field.relatedList: 'primary' prominence key — no identity object declares that key, and it would not move this page while the tabs slot is present. A grader who finds a Details + stacked Related strip instead is looking at the synthesized default, i.e. this page failed to load)", + "packages/platform-objects/src/pages/sys-organization.page.ts#SysOrganizationDetailPage (WHERE THE THREE TABS COME FROM: a kind: 'full' record page that authors its own page:tabs strip, drawn as authored with no synthesized strip, giving exactly Members / Invitations / Teams with Members at index 0 (a kind: 'slotted' page whose slots.tabs override replaced the synthesized strip until that slot retired, #27053). Handed to the runtime by plugin-auth's pages: [...]. NOT the Field.relatedList: 'primary' prominence key — no identity object declares that key, and it would not move this full page. A grader who finds a Details + stacked Related strip instead is looking at the synthesized default, i.e. this page failed to load)", "packages/plugins/plugin-auth/src/auth-route-ledger.ts (organization family: update-member-role, remove-member, update, create-team, add-team-member, list-members/teams/invitations, get-active-member, get-full-organization)", "packages/spec/src/identity/membership-role.ts#BUILTIN_MEMBERSHIP_ROLES (BUILTIN_MEMBERSHIP_ROLES / BUILTIN_MEMBERSHIP_ROLE_OPTIONS — THE role vocabulary: owner/admin/delegated_admin/member, ADR-0108; 'nothing widens these at boot any more')", "docs/adr/0108-membership-grade-is-not-a-capability-channel.md (why the list is closed: a grade decides what you can REACH, never a bundle of what you may do)", @@ -939,7 +939,8 @@ "date": "2026-10-19", "change": "steps[5] and acceptance[3].clause corrected — a checklist-accuracy finding of the 18.0 QA sweep (QA run #24538, identity-auth.org-membership-team-management; wave anchor #24579 §3): after a rename the step expected the nav label to follow, but nav_organization's label is the static 'Organization' (setup-nav.contributions.ts); what follows is the organization record page's title, which the run observed. Not a loosening: the persisted name and the page title must both show the rename", "ref": "#24632" - } + }, + { "revision": 6, "date": "2026-10-28", "change": "PROVENANCE, no clause change: the record page's tabs slot retired (#27053), so SysOrganizationDetailPage moved from kind: 'slotted' with a slots.tabs override to kind: 'full', rebuilt as the header, highlights, the same Members / Invitations / Teams strip and the discussion the slotted page rendered. source[1] now names the full page; the tab order, steps, acceptance clauses, oracles and negatives are unchanged (ADR-0133 carries the matching amendment)", "ref": "#27053" } ] }, { diff --git a/docs/qa/platform-checklist/areas/studio-authoring.json b/docs/qa/platform-checklist/areas/studio-authoring.json index 22b4ac42ec5..c5300bf70b6 100644 --- a/docs/qa/platform-checklist/areas/studio-authoring.json +++ b/docs/qa/platform-checklist/areas/studio-authoring.json @@ -1860,7 +1860,7 @@ "title": "The metadata save door runs the build's authoring rules on v18 — a page with an unknown component prop, a page with both details and tabs slots, a second default record page, a chart list view with no dataset, an object predicate that does not compile, a `protection` block or a lock change, and a non-http(s) URL target are each refused 4xx at PUT (drafts still admitted where the rule says so)", "since": "v18", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "api", "personas": [ @@ -1875,7 +1875,7 @@ }, "steps": [ "PUT /api/v1/meta/page/qa_props_page with a page:header block carrying properties.titel", - "PUT a record page with both slots.details and slots.tabs", + "PUT a record page with slots.tabs (the retired slot)", "PUT a second isDefault:true record page for showcase_task", "PUT /api/v1/meta/view/showcase_task.qa_chart_view as a chart list view with no dataset binding", "PUT an object with an option visibleWhen reading `parent` and a validation condition `sqrt(record.amount)`; then the same with ?mode=draft", @@ -1892,7 +1892,7 @@ { "clause": "each refused body answers 422 INVALID_METADATA (400 VALIDATION_ERROR for protection / _lock*, 422 DEFAULT_RECORD_PAGE_CONFLICT naming both pages) with the located path — and the rule id where the refusal comes from an authoring lint rule (the component-props and object-predicate refusals) — and a read-back shows nothing stored", "oracle": "api", - "verify": "status + code + path per PUT, and the rule id on the lint-rule refusals; GET afterwards. The spec-schema refusals (the slots pair, the chart binding, the action URL target) carry the located path and zod code 'custom' with no `rule` key (e.g. packages/spec/src/ui/page.zod.ts checkPageSlotPair), and protection / _lock* and DEFAULT_RECORD_PAGE_CONFLICT carry their error code: a missing rule id on those is the shipped shape, not a FAIL (QA run #24558; the pins assert path and code only)", + "verify": "status + code + path per PUT, and the rule id on the lint-rule refusals; GET afterwards. The spec-schema refusals (the retired tabs slot, the chart binding, the action URL target) carry the located path with no `rule` key — zod code 'invalid_type' for the retired slot (packages/spec/src/ui/page.zod.ts PAGE_SLOTS_TABS_RETIRED) and 'custom' for the other two — and protection / _lock* and DEFAULT_RECORD_PAGE_CONFLICT carry their error code: a missing rule id on those is the shipped shape, not a FAIL (QA run #24558; the pins assert path and code only)", "evidence": "one line per PUT" }, { @@ -1913,12 +1913,12 @@ ], "automated": { "kind": "unit", - "ref": "packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts + packages/metadata-protocol/src/protocol.save-door-protection.test.ts + packages/metadata-protocol/src/protocol.save-door-lock-family.test.ts + packages/objectql/src/default-record-page-save-door.test.ts + packages/spec/src/ui/page-slot-pair.test.ts + packages/rest/src/meta-view-chart-binding.test.ts + packages/lint/src/runtime-gate.object-option-visibility-writes.test.ts + packages/lint/src/validate-component-props-url-target.test.ts. These score acceptance[0] (oracle test) only: none of the eight boots HTTP — metadata-protocol saveMetaItem, objectql, spec and lint units and a RestServer unit — so acceptance[1]-[3] and negative[0], whose oracle is the live door, stay the run's" + "ref": "packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts + packages/metadata-protocol/src/protocol.save-door-protection.test.ts + packages/metadata-protocol/src/protocol.save-door-lock-family.test.ts + packages/objectql/src/default-record-page-save-door.test.ts + packages/spec/src/ui/page-slots-tabs-retirement.test.ts + packages/rest/src/meta-view-chart-binding.test.ts + packages/lint/src/runtime-gate.object-option-visibility-writes.test.ts + packages/lint/src/validate-component-props-url-target.test.ts. These score acceptance[0] (oracle test) only: none of the eight boots HTTP — metadata-protocol saveMetaItem, objectql, spec and lint units and a RestServer unit — so acceptance[1]-[3] and negative[0], whose oracle is the live door, stay the run's" }, "source": [ "packages/metadata-protocol/src/runtime-authoring-gate.ts", "packages/metadata-protocol/src/protocol.ts#protectionBlockRefusal", - "packages/spec/src/ui/page.zod.ts#checkPageSlotPair", + "packages/spec/src/ui/page.zod.ts#PAGE_SLOTS_TABS_RETIRED", "packages/spec/src/ui/view.zod.ts#checkListViewChartBinding", "packages/lint/src/validate-expressions.ts#validateStackExpressions", "v18 changesets (.changeset/pre/, consumed into 18.0.0-next.*): 23388-page-component-rules-runtime-door, 23357-page-component-view-data-api-refused, 23332-page-action-row-operation, 23427-page-action-row-declarative-update-rules, 23096-save-door-protection, 23119-save-door-lock-envelope, 23015-one-default-record-page-per-object, 22568-page-slots-details-beside-tabs-refused, 22491-chart-list-view-binds-a-dataset, 22157-option-visible-when-parent, 22274-option-visible-when-members, 22394-option-visible-when-evaluser-members, 22019-object-save-door-formula-verdict, 22019-objectql-formula-fault-log, 22032-object-save-door-action-predicates, 22032-object-save-door-field-rule-slots, 22032-object-save-door-option-visible-when, 22032-object-save-door-validation-predicates, 22042-nested-validation-predicate-verdict, 23010-url-action-target-scheme-set, 23077-url-target-doors-scheme-set, 23103-page-action-row-on-success-and-executor, 23168-page-action-row-name-leg-url-target, 23168-page-action-row-on-success-beside-executor" @@ -1941,6 +1941,12 @@ "date": "2026-10-26", "change": "automated.ref says its pins score acceptance[0] only (checklist-accuracy finding a1-a3/n0 of QA run #25815, release-18.2.0-tier1 at 6679644a, objectui pin b41ed7f68956; wave anchor #25807): none of the eight pins boots HTTP (metadata-protocol saveMetaItem, objectql, spec, lint units and a RestServer unit), so the api clauses stay the run's. The clauses are unchanged", "ref": "#25848" + }, + { + "revision": 4, + "date": "2026-10-28", + "change": "steps[1], acceptance[1].verify, automated.ref and source follow the record page's retired tabs slot (#27053): the slots.details + slots.tabs pair check left with the slot, so the step now PUTs a page authoring slots.tabs, which the spec refuses at slots.tabs with zod code 'invalid_type' and the retirement prescription; its pin is packages/spec/src/ui/page-slots-tabs-retirement.test.ts. The clauses are unchanged", + "ref": "#27053" } ] } diff --git a/examples/app-showcase/src/ui/pages/project-detail.page.ts b/examples/app-showcase/src/ui/pages/project-detail.page.ts index f7de8eb55fb..50088df66f4 100644 --- a/examples/app-showcase/src/ui/pages/project-detail.page.ts +++ b/examples/app-showcase/src/ui/pages/project-detail.page.ts @@ -3,7 +3,7 @@ import { definePage } from '@objectstack/spec/ui'; /** - * Project detail — a slotted record page that surfaces the project's Tasks as + * Project detail — a full record page that surfaces the project's Tasks as * an INLINE-EDITABLE `record:line_items` grid (ObjectUI ADR-0001), beside the * read-only Tasks related list. The grid is the "view + edit the children * together" half of the master-detail story: open a project, edit its tasks in diff --git a/packages/cli/test/platform-page-i18n-parity.test.ts b/packages/cli/test/platform-page-i18n-parity.test.ts index 5d83b54532d..8ffdee8d5e5 100644 --- a/packages/cli/test/platform-page-i18n-parity.test.ts +++ b/packages/cli/test/platform-page-i18n-parity.test.ts @@ -668,7 +668,8 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => { // // Fed through the real `collectExpectedEntries`, the three pages together // offer exactly THREE keys -- one page-level `label` each. Nothing else. All -// three author `regions: []` and put every component under `slots.*`, and the +// three authored `regions: []` and put every component under `slots.*` (they +// are `kind: 'full'` pages since objectstack#27053), and the // shared walk (`walkAddressedPageComponents`, `@objectstack/spec/system`) roots // at `regions[].components[]` only. So 45 further authored copy sites, every // one of them an inline `{ en, 'zh-CN', ... }` locale map, are not reachable @@ -797,7 +798,7 @@ describe('shipped platform record pages -- i18n ownership (#14817)', () => { expect(SHIPPED_LOCALES.length).toBeGreaterThan(1); }); - it('records that the walk now reaches under `slots`, that these pages author no component id there, and so the extractor still offers the label alone', () => { + it('records that the walk reaches every component of these pages, that none of them carries an id, and so the extractor still offers the label alone', () => { // A BOUNDARY PIN, not an endorsement — re-measured, and the reason moved. // Until #16772 `offered: ['label']` held because the shared walk rooted at // `regions[].components[]` and these pages author `regions: []`: the 45 @@ -809,6 +810,10 @@ describe('shipped platform record pages -- i18n ownership (#14817)', () => { // by `pages..components.` and the extractor still offers the // page label alone. These pages gained no bundle surface, need no entries, // and their coverage home stays this file (the inline-map case below). + // Since objectstack#27053 retired the `tabs` slot these pages author, they + // are `kind: 'full'` and their components sit under `regions[]` instead of + // `slots.*`: the walk reaches them by the original root, and the answer is + // the same, since the rebuild gave no component an id. // Both halves are held so the next change is told precisely: `reached` // reds if the roots narrow again; `offered` grows the day one of these // components takes an id, and that component then needs a bundle entry. @@ -824,8 +829,8 @@ describe('shipped platform record pages -- i18n ownership (#14817)', () => { .filter((e) => e.path[0] === 'pages' && e.path[1] === page.name) .map((e) => e.path.slice(2).join('.')) .sort(); - expect({ page: page.name, regions: page.regions, reached: visited > 0, addressed, offered }) - .toEqual({ page: page.name, regions: [], reached: true, addressed: 0, offered: ['label'] }); + expect({ page: page.name, kind: page.kind, slots: page.slots, reached: visited > 0, addressed, offered }) + .toEqual({ page: page.name, kind: 'full', slots: undefined, reached: true, addressed: 0, offered: ['label'] }); } }); diff --git a/packages/platform-objects/src/apps/setup-nav-per-user-stores.test.ts b/packages/platform-objects/src/apps/setup-nav-per-user-stores.test.ts index 1a4211c1b1c..c820560344d 100644 --- a/packages/platform-objects/src/apps/setup-nav-per-user-stores.test.ts +++ b/packages/platform-objects/src/apps/setup-nav-per-user-stores.test.ts @@ -70,7 +70,8 @@ function relatedListsOf(root: unknown, objectName: string): Rec[] { /** The `tabs` item whose body holds the related list over `objectName`. */ function tabOf(objectName: string): Rec | undefined { - const items = (((SysUserDetailPage.slots as Rec).tabs as Rec).properties as Rec).items as Rec[]; + const tabs = (SysUserDetailPage.regions![0]!.components as Rec[]).find((c) => c.type === 'page:tabs') as Rec; + const items = (tabs.properties as Rec).items as Rec[]; return items.find((item) => relatedListsOf(item.children, objectName).length > 0); } diff --git a/packages/platform-objects/src/identity/invite-entry-toolbar.test.ts b/packages/platform-objects/src/identity/invite-entry-toolbar.test.ts index 2e67a2c4245..f9eae81e44b 100644 --- a/packages/platform-objects/src/identity/invite-entry-toolbar.test.ts +++ b/packages/platform-objects/src/identity/invite-entry-toolbar.test.ts @@ -10,13 +10,13 @@ // ⚠️ Where that tab order is DECLARED (#16270 measured that it is not declared // here): `SysOrganizationDetailPage` in `../pages/sys-organization.page.ts`, // handed to the runtime by plugin-auth's `pages: [...]`. It is a -// `kind: 'slotted'` record page whose `slots.tabs` override REPLACES the -// synthesized tab strip outright — objectui's `buildDefaultPageSchema` pushes -// the authored node and never calls `buildDefaultTabs` — giving exactly -// Members / Invitations / Teams with Members at index 0. The `relatedList: -// 'primary'` prominence key is a different mechanism and no object under -// `identity/` declares it; with the `tabs` slot present it would not move this -// page if one did. So this pin's premise is declared metadata, not folklore — +// `kind: 'full'` record page that authors its own `page:tabs` strip, drawn as +// authored (no synthesized strip, no `buildDefaultTabs`), giving exactly +// Members / Invitations / Teams with Members at index 0. (It was a slotted page +// whose `tabs` slot replaced the strip until that slot retired, +// objectstack#27053.) The `relatedList: 'primary'` prominence key is a +// different mechanism and no object under `identity/` declares it; on a full +// page it would not move this page if one did. So this pin's premise is declared metadata, not folklore — // but the declaration is one directory over, not in the objects it constrains. // // Two halves are pinned here, because the fix has two failure modes and they diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index 7b5b0c78e27..4dd3b62041e 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -69,13 +69,12 @@ export const SysMember = ObjectSchema.create({ // ⚠️ That tab order is DECLARED, and not by anything in this directory: // `SysOrganizationDetailPage` (`../pages/sys-organization.page.ts`, // handed to the runtime by plugin-auth's `pages: [...]`) is a - // `kind: 'slotted'` record page whose `slots.tabs` override REPLACES the - // synthesized tab strip outright — objectui's `buildDefaultPageSchema` - // pushes the authored node and never calls `buildDefaultTabs` — so the - // strip is exactly Members / Invitations / Teams, Members at index 0. + // `kind: 'full'` record page that authors its own `page:tabs` strip, + // drawn as authored with no synthesized strip, so the strip is exactly + // Members / Invitations / Teams, Members at index 0. // It is NOT the `Field.relatedList: 'primary'` prominence key: no object - // under `identity/` declares that key, and with the `tabs` slot present - // one would not move this page if it did. Looking for the tab order in + // under `identity/` declares that key, and on a full page one would not + // move this page if it did. Looking for the tab order in // this directory finds nothing; it lives one directory over (#16270). // Declaration order is render order — the related-list toolbar bridge // maps the child object's `list_toolbar` actions in array order diff --git a/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts b/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts index 42b2b6d0480..b9315567af9 100644 --- a/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts +++ b/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts @@ -243,22 +243,23 @@ describe('downgrade control — a shipped page, predicate downgraded to bare', ( // half only this package can run, and the one that reds if the lint // detector is ablated or its import breaks.) const source = JSON.parse(JSON.stringify(pageExports.SysUserDetailPage)) as AnyRec; - const slots = source.slots as AnyRec; - const alerts = slots.alerts as AnyRec[]; - const authored = (alerts[0]!.visibleWhen as AnyRec).source as string; + const components = source.regions[0].components as AnyRec[]; + const alertAt = components.findIndex(c => c.type === 'record:alert'); + const alert = components[alertAt]!; + const authored = (alert.visibleWhen as AnyRec).source as string; expect( typeof authored, 'this control expects the shipped page to still carry the CANONICAL envelope. ' + 'If it is already bare, THIS is not the failure to read — the gate above ' + '(`authors NO bare expression string`) is, and it names the offending path.', ).toBe('string'); - alerts[0]!.visibleWhen = authored; + alert.visibleWhen = authored; const audit = auditPageExpressionEnvelopes(source, pageLabel('SysUserDetailPage', source as unknown as Page)); - expect(audit.findings.map(f => f.path)).toEqual(['slots.alerts[0].visibleWhen']); + expect(audit.findings.map(f => f.path)).toEqual([`regions[0].components[${alertAt}].visibleWhen`]); const rendered = renderBareExpressionFindings(audit.findings); expect(rendered).toContain('sys_user_detail'); - expect(rendered).toContain('slots.alerts[0].visibleWhen'); + expect(rendered).toContain(`regions[0].components[${alertAt}].visibleWhen`); // …and the untouched export is still clean, so the red above came from the // mutation and not from something this test left behind. diff --git a/packages/platform-objects/src/pages/sys-user-details-tab.test.ts b/packages/platform-objects/src/pages/sys-user-details-tab.test.ts index 2bf76ec41df..95981890505 100644 --- a/packages/platform-objects/src/pages/sys-user-details-tab.test.ts +++ b/packages/platform-objects/src/pages/sys-user-details-tab.test.ts @@ -1,15 +1,16 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #22568 — the user page's details body is the FIRST `tabs` item, not a - * `details` slot. + * #22568 — the user page's details body is the FIRST tab item of the strip + * the page authors. * - * The `tabs` slot replaces the whole tab strip the synthesized Details tab - * lives in, so a `details` slot authored beside it never rendered: the page's - * Identity and Audit sections never showed, and the admin-internal columns it - * hides (`ban_reason`, `ban_expires`, …) were never hidden by it. - * `PageSchema` now refuses the pair, and the page carries its `record:details` - * inside the strip it authors. + * The page's own strip replaces the synthesized one the Details tab lives in, + * so a details body authored anywhere else never rendered: the page's Identity + * and Audit sections never showed, and the admin-internal columns it hides + * (`ban_reason`, `ban_expires`, …) were never hidden by it. The page carries + * its `record:details` inside the strip it authors. (It was a slotted page + * whose `tabs` slot held that strip until the slot retired, objectstack#27053; + * it is now a `kind: 'full'` page that authors the same strip.) * * Read from the shipped page metadata, through the spec's own page contract, * so the pin fails if the page stops parsing and if the details body moves @@ -46,17 +47,18 @@ const SECTIONS = [ ]; describe('sys_user_detail — the details body lives in the first tab', () => { - const slots = SysUserDetailPage.slots as Rec; - const tabs = slots.tabs as Rec; + const components = SysUserDetailPage.regions![0]!.components as Rec[]; + const tabs = components.find((c) => c.type === 'page:tabs') as Rec; const items = (tabs.properties as Rec).items as Rec[]; - it('parses through PageSchema — the slot-pair refusal does not fire on the shipped page', () => { + it('parses through PageSchema', () => { const parsed = PageSchema.safeParse(SysUserDetailPage); expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true); }); - it('authors no `details` slot beside its `tabs` slot', () => { - expect(slots).not.toHaveProperty('details'); + it('is a full page that authors its own strip, with no slot map', () => { + expect(SysUserDetailPage.kind).toBe('full'); + expect(SysUserDetailPage).not.toHaveProperty('slots'); expect(tabs.type).toBe('page:tabs'); }); @@ -70,7 +72,7 @@ describe('sys_user_detail — the details body lives in the first tab', () => { expect(props.sections).toEqual(SECTIONS); }); - it('is the one `record:details` on the page — no second copy anywhere in the slot map', () => { + it('is the one `record:details` on the page — no second copy anywhere in its body', () => { const found: unknown[] = []; const walk = (n: unknown): void => { if (Array.isArray(n)) { n.forEach(walk); return; } @@ -78,7 +80,7 @@ describe('sys_user_detail — the details body lives in the first tab', () => { if ((n as Rec).type === 'record:details') found.push(n); Object.values(n as Rec).forEach(walk); }; - walk(slots); + walk(components); expect(found).toHaveLength(1); }); }); diff --git a/packages/platform-objects/src/pages/sys-user-security-tab-own-record.test.ts b/packages/platform-objects/src/pages/sys-user-security-tab-own-record.test.ts index d3b738027b3..b768056d411 100644 --- a/packages/platform-objects/src/pages/sys-user-security-tab-own-record.test.ts +++ b/packages/platform-objects/src/pages/sys-user-security-tab-own-record.test.ts @@ -27,7 +27,7 @@ type Rec = Record; const OWN_RECORD = 'has(record.id) && record.id == ctx.user.id'; describe('sys_user_detail — the Security tab is gated to the viewer\'s own record', () => { - const tabs = (SysUserDetailPage.slots as Rec).tabs as Rec; + const tabs = (SysUserDetailPage.regions![0]!.components as Rec[]).find((c) => c.type === 'page:tabs') as Rec; const items = (tabs.properties as Rec).items as Rec[]; const security = items.find((it) => (it.label as Rec).en === 'Security')!; diff --git a/packages/platform-objects/src/pages/sys-user-two-factor-display.test.ts b/packages/platform-objects/src/pages/sys-user-two-factor-display.test.ts index 601cad5206d..061f2d11e9b 100644 --- a/packages/platform-objects/src/pages/sys-user-two-factor-display.test.ts +++ b/packages/platform-objects/src/pages/sys-user-two-factor-display.test.ts @@ -28,8 +28,9 @@ import { SysUserDetailPage } from './sys-user.page.js'; type Rec = Record; -const slots = SysUserDetailPage.slots as Rec; -const tabItems = (slots.tabs.properties as Rec).items as Rec[]; +const components = SysUserDetailPage.regions![0]!.components as Rec[]; +const nodeOf = (type: string): Rec => components.find((c) => c.type === type) as Rec; +const tabItems = (nodeOf('page:tabs').properties as Rec).items as Rec[]; const security = tabItems.find((it) => it.label?.en === 'Security') as Rec; const securityNodes = security.children as Rec[]; @@ -60,7 +61,7 @@ describe('sys_user — 2FA display only where 2FA is offered (#25287)', () => { }); it('the highlights chip strip does not carry the 2FA state', () => { - expect(slots.highlights.properties.fields).not.toContain('two_factor_enabled'); + expect(nodeOf('record:highlights').properties.fields).not.toContain('two_factor_enabled'); }); it('the details grid still hides the field, so dropping the chip does not move it into the body', () => { diff --git a/packages/plugins/plugin-approvals/src/approvals-plugin.ts b/packages/plugins/plugin-approvals/src/approvals-plugin.ts index 869363ef3db..fb39f2100be 100644 --- a/packages/plugins/plugin-approvals/src/approvals-plugin.ts +++ b/packages/plugins/plugin-approvals/src/approvals-plugin.ts @@ -145,11 +145,11 @@ export class ApprovalsServicePlugin implements Plugin { defaultDatasource: 'cloud', namespace: 'sys', objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken, SysApprovalDelegation], - // The request's record-detail page (a slotted page, default for every + // The request's record-detail page (a full page, default for every // sys_approval_request record): the decision panel, the request's // fields, and its timeline. This plugin owns the object, so it ships its // page too — the shape plugin-auth ships sys_user's. The page and its - // slot map are documented on `SysApprovalRequestDetailPage`. + // body are documented on `SysApprovalRequestDetailPage`. pages: [SysApprovalRequestDetailPage], // ADR-0029 D7 — contribute the Approvals entries into the Setup app's // `group_approvals` slot. This plugin owns these objects (K2.b), so it diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts index d8d1d74f1e7..d292cf14fc1 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * `sys_approval_request`'s record-detail page — the slotted page this plugin + * `sys_approval_request`'s record-detail page — the full page this plugin * serves through its manifest `pages` (maintainer ruling 乙 on objectui#12045, * `6079807016`: the page is metadata in plugin-approvals; objectui registers * renderers and mounts `RecordDetailView`). @@ -11,7 +11,9 @@ * * 1. the plugin's manifest carries the page, so the registry — and the * metadata API reading it — has it; - * 2. the slot map is the one the ruling's build list names: one + * 2. the page body is the one the ruling's build list names (a slotted page + * until the `tabs` slot retired, objectstack#27053; rebuilt as the same + * nodes in a `kind: 'full'` page): a header, one * `record:approval_decision` node, `record:details`, `sys_approval_action` * as the related list, no discussion thread, and a highlights strip that * leaves `record_id` to the details grid (the seat's choice on the ui @@ -44,8 +46,12 @@ import { approvalsPageTranslations } from './translations/pages.js'; type AnyRec = Record; const PAGE = SysApprovalRequestDetailPage as AnyRec; -const SLOTS = (PAGE.slots ?? {}) as AnyRec; -const TAB_ITEMS = (SLOTS.tabs?.properties?.items ?? []) as AnyRec[]; +const COMPONENTS = (PAGE.regions?.[0]?.components ?? []) as AnyRec[]; +const nodeOf = (type: string): AnyRec | undefined => COMPONENTS.find((c) => c.type === type); +const HIGHLIGHTS = (nodeOf('record:highlights') ?? {}) as AnyRec; +const TAB_ITEMS = (nodeOf('page:tabs')?.properties?.items ?? []) as AnyRec[]; +/** Where the decision panel sits in the region: after the header, before the highlights. */ +const DECISION_AT = COMPONENTS.findIndex((c) => c.type === 'record:approval_decision'); /** The plugin's one manifest, as `init` registers it. */ async function registeredManifest(): Promise { @@ -60,7 +66,7 @@ async function registeredManifest(): Promise { return registered[0]; } -/** Every component node under the page, slots and tab children included. */ +/** Every component node under the page, tab children included. */ function nodesOf(value: unknown, out: AnyRec[] = []): AnyRec[] { if (Array.isArray(value)) { for (const v of value) nodesOf(v, out); @@ -103,7 +109,10 @@ function pageBuildFindings(page: AnyRec) { } /** The page with its decision node replaced. */ -const withDecisionNode = (node: AnyRec): AnyRec => ({ ...PAGE, slots: { ...SLOTS, actions: [node] } }); +const withDecisionNode = (node: AnyRec): AnyRec => ({ + ...PAGE, + regions: [{ ...PAGE.regions[0], components: COMPONENTS.map((c, i) => (i === DECISION_AT ? node : c)) }], +}); describe('sys_approval_request detail page — served through the manifest', () => { it('the plugin manifest carries the page in `pages`', async () => { @@ -111,12 +120,12 @@ describe('sys_approval_request detail page — served through the manifest', () expect(manifest.pages).toEqual([SysApprovalRequestDetailPage]); }); - it('is the default slotted record page of sys_approval_request', () => { + it('is the default full record page of sys_approval_request', () => { expect(PAGE).toMatchObject({ name: 'sys_approval_request_detail', type: 'record', object: 'sys_approval_request', - kind: 'slotted', + kind: 'full', isDefault: true, }); const parsed = PageSchema.safeParse(PAGE); @@ -124,18 +133,20 @@ describe('sys_approval_request detail page — served through the manifest', () }); }); -describe('sys_approval_request detail page — the slot map', () => { - it('authors exactly the decision panel, the highlights, the tabs and an empty discussion', () => { - // No `details` slot: the console builds the tab strip from `tabs` when both - // are present, so the details body lives in the first tab. No header: it - // falls through to the synthesizer. - expect(Object.keys(SLOTS).sort()).toEqual(['actions', 'discussion', 'highlights', 'tabs']); +describe('sys_approval_request detail page — the page body', () => { + it('authors exactly the header, the decision panel, the highlights and the tabs, in the slotted order', () => { + // The nodes the slotted page rendered, in the synthesizer's order: the + // header it fell through to, its `actions` slot, its `highlights` slot and + // its tab strip, whose first tab is the details body. Its `discussion` + // slot was empty, so there is no discussion node. + expect(COMPONENTS.map((c) => c.type)).toEqual(['page:header', 'record:approval_decision', 'record:highlights', 'page:tabs']); + expect(nodeOf('page:header')).toEqual({ type: 'page:header' }); }); it('holds ONE record:approval_decision node, with no properties (its props row is empty and strict)', () => { - const decision = nodesOf(SLOTS).filter((n) => n.type === 'record:approval_decision'); + const decision = nodesOf(COMPONENTS).filter((n) => n.type === 'record:approval_decision'); expect(decision).toHaveLength(1); - expect(SLOTS.actions).toEqual([{ type: 'record:approval_decision' }]); + expect(COMPONENTS[DECISION_AT]).toEqual({ type: 'record:approval_decision' }); }); it('highlights the object\'s highlightFields minus record_id, which the object itself keeps', () => { @@ -145,8 +156,8 @@ describe('sys_approval_request detail page — the slot map', () => { // pair), and the object's `highlightFields` keeps it. const declared = SysApprovalRequest.highlightFields ?? []; expect(declared).toContain('record_id'); - expect(SLOTS.highlights?.type).toBe('record:highlights'); - expect(SLOTS.highlights.properties.fields).toEqual(declared.filter((f: string) => f !== 'record_id')); + expect(HIGHLIGHTS.type).toBe('record:highlights'); + expect(HIGHLIGHTS.properties.fields).toEqual(declared.filter((f: string) => f !== 'record_id')); expect((SysApprovalRequest.fields as AnyRec).record_id?.referenceVia).toBe('object_name'); }); @@ -156,7 +167,7 @@ describe('sys_approval_request detail page — the slot map', () => { // list moves with the strip: the Details tab's bare `record:details` node // draws the object's `highlightFields` minus the strip, so a strip-only // change would put `process_name` in that tab. - const strip = SLOTS.highlights.properties.fields as string[]; + const strip = HIGHLIGHTS.properties.fields as string[]; const declared = SysApprovalRequest.highlightFields ?? []; expect(strip[0]).toBe('process_label'); expect(declared[0]).toBe('process_label'); @@ -170,7 +181,7 @@ describe('sys_approval_request detail page — the slot map', () => { // `current_step_label` is the step's label, a declared field the approvals // get serves under that name (`approval-service.test.ts` pins the get). The // object's list moves with the strip for the reason the flow chip's does. - const strip = SLOTS.highlights.properties.fields as string[]; + const strip = HIGHLIGHTS.properties.fields as string[]; const declared = SysApprovalRequest.highlightFields ?? []; const fields = SysApprovalRequest.fields as AnyRec; expect({ strip: strip.includes('current_step_label'), declared: declared.includes('current_step_label') }) @@ -191,7 +202,7 @@ describe('sys_approval_request detail page — the slot map', () => { // record with its object's label. The object's list moves with the strip // for the reason the flow chip's does: dropped from the strip alone, // `object_name` would be drawn as a row in that tab. - const strip = SLOTS.highlights.properties.fields as string[]; + const strip = HIGHLIGHTS.properties.fields as string[]; const declared = SysApprovalRequest.highlightFields ?? []; const fields = SysApprovalRequest.fields as AnyRec; expect({ strip: strip.includes('object_name'), declared: declared.includes('object_name') }) @@ -239,7 +250,7 @@ describe('sys_approval_request detail page — the slot map', () => { const actionFields = Object.keys(SysApprovalAction.fields); const [timeline] = TAB_ITEMS[1].children as AnyRec[]; const missing = [ - ...(SLOTS.highlights.properties.fields as string[]).filter((f) => !requestFields.includes(f)).map((f) => `request.${f}`), + ...(HIGHLIGHTS.properties.fields as string[]).filter((f) => !requestFields.includes(f)).map((f) => `request.${f}`), ...(timeline.properties.columns as string[]).filter((f) => !actionFields.includes(f)).map((f) => `action.${f}`), ...(timeline.properties.sort as AnyRec[]).map((s) => s.field).filter((f) => !actionFields.includes(f)).map((f) => `action.${f}`), ]; @@ -247,8 +258,7 @@ describe('sys_approval_request detail page — the slot map', () => { }); it('has no discussion thread anywhere — the reply is the declared approval_comment action', () => { - expect(SLOTS.discussion).toEqual([]); - expect(nodesOf(SLOTS).map((n) => n.type).filter((t) => t === 'record:discussion' || t === 'record:chatter')).toEqual([]); + expect(nodesOf(PAGE).map((n) => n.type).filter((t) => t === 'record:discussion' || t === 'record:chatter')).toEqual([]); const actions = (SysApprovalRequest.actions ?? []) as AnyRec[]; expect(actions.find((a) => a.name === 'approval_comment')?.locations).toEqual(['record_section']); }); @@ -261,7 +271,7 @@ describe('sys_approval_request detail page — what `os validate` judges a page it('control: a misspelled decision-panel type is refused by the same call', () => { const findings = validateComponentTypes({ pages: [withDecisionNode({ type: 'record:approval_decison' })] }); - expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_TYPE_UNKNOWN, 'pages[0].slots.actions[0].type']]); + expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_TYPE_UNKNOWN, `pages[0].regions[0].components[${DECISION_AT}].type`]]); }); it('every node\'s props are inside its declared props row', () => { diff --git a/packages/plugins/plugin-approvals/src/translations/pages.ts b/packages/plugins/plugin-approvals/src/translations/pages.ts index 3bf1c50618a..c338a0f99f1 100644 --- a/packages/plugins/plugin-approvals/src/translations/pages.ts +++ b/packages/plugins/plugin-approvals/src/translations/pages.ts @@ -14,7 +14,7 @@ * declared in that config all the same, so the coverage ratchet counts its * label against this section (see the config's own note). A page-level * `label` is the one key a bundle reaches on this page: every other string on - * it is an inline locale map under `slots.*`, the ruled route for page prose. + * it is an inline locale map in `regions[].components[]`, the ruled route for page prose. * * ## Keeping the copy honest * diff --git a/packages/spec/liveness/page.json b/packages/spec/liveness/page.json index ffbb1fca253..b4cc696a2d1 100644 --- a/packages/spec/liveness/page.json +++ b/packages/spec/liveness/page.json @@ -240,7 +240,7 @@ }, "slots": { "status": "live", - "note": "slotted-page override map (header/actions/alerts/highlights/details/tabs/discussion) — objectui plugin-detail; Preview render in objectui#1948. Verified in the Studio Design slot editor during the dogfood." + "note": "slotted-page override map (header/actions/alerts/highlights/details/discussion; the tabs slot is a tombstone) — objectui plugin-detail; Preview render in objectui#1948. Verified in the Studio Design slot editor during the dogfood." } } } diff --git a/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts b/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts index 40b968c5536..b5b42bc65c8 100644 --- a/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts +++ b/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts @@ -223,7 +223,7 @@ describe('§1 the ruled subset converts to the exact rule array', () => { { name: 'probe', slots: { - tabs: { + details: { type: 'page:tabs', properties: { items: [ @@ -238,7 +238,7 @@ describe('§1 the ruled subset converts to the exact rule array', () => { }, ], }); - const slot = ((stack.pages as Dict[])[0]!.slots as Dict).tabs as Dict; + const slot = ((stack.pages as Dict[])[0]!.slots as Dict).details as Dict; const nested = (((slot.properties as Dict).items as Dict[])[0]!.children as Dict[])[0]!; expect((nested.properties as Dict).filter).toEqual([{ field: 'a', operator: 'equals', value: 1 }]); }); diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 939018d9cfb..6bceecf4921 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -433,6 +433,15 @@ const JOIN: Readonly> = { 'template', ), ], + // The slotted page's `tabs` slot, tombstoned in the slot map; the page is + // otherwise complete, so `slots` is the only issue path. + 'page-slots-tabs-removed': [ + refusal( + 'a slotted page authoring slots.tabs', + () => PageSchema.safeParse({ name: 'lead_detail_page', label: 'Lead', type: 'record', object: 'crm_lead', kind: 'slotted', regions: [], slots: { tabs: { type: 'page:tabs' } } }), + 'slots', + ), + ], // #23543 F3: a loose `config` key under a sibling-block node type, refused by // the flow parse; the node's block is complete, so the key is the only issue. 'flow-node-wait-event-config-lift': [ diff --git a/skills/objectstack-ui/rules/pages.md b/skills/objectstack-ui/rules/pages.md index bb0496c174f..61c0c5c9ef5 100644 --- a/skills/objectstack-ui/rules/pages.md +++ b/skills/objectstack-ui/rules/pages.md @@ -135,6 +135,9 @@ by what the page needs: `kind:'html'`/`'react'` page with no `source` fails the build (ADR-0078). `kind:'jsx'` is retired: the parse refuses it and names `kind:'html'`. +- A `slotted` record page has no `tabs` slot: shape the strip with `relatedList` / + `relatedList: 'primary'` on the child's reference field, or author `kind: 'full'`. + #### `kind:'html'` — constrained JSX, parsed (safe by construction) Tags are the **registered components** (bare names: ``, ``, ``, @@ -233,12 +236,9 @@ at render behind an error boundary, so always test a react page in the browser. To style a metadata-authored block, give it a **`responsiveStyles`** object — a per-breakpoint map of CSS properties. The renderer compiles each styled node to -**id-scoped CSS** at render time. **Do NOT put Tailwind classes in `className`** -expecting them to render: Tailwind is compiled at the *renderer's* build over the -*renderer's* source, never over your metadata, so a class only happens to work if -objectui already uses it — arbitrary classes (`text-[27px]`, `bg-[#1a2b3c]`, -`grid-cols-7`) silently do nothing. `responsiveStyles` has no such trap (values -are compiled from your data at render). +**id-scoped CSS** at render time. **Do NOT put Tailwind classes in `className`**: +Tailwind is compiled over the renderer's source, never over your metadata, so +arbitrary classes (`text-[27px]`, `grid-cols-7`) silently do nothing. Rules: - **`responsiveStyles` and `id` are top-level** envelope fields; **child nodes go diff --git a/ui/content/docs/guide/slotted-pages.md b/ui/content/docs/guide/slotted-pages.md index 85be2130dad..e88930d4d82 100644 --- a/ui/content/docs/guide/slotted-pages.md +++ b/ui/content/docs/guide/slotted-pages.md @@ -30,13 +30,20 @@ slotted pages are the right tool. | `actions` | `record:quick_actions` (the action bar) | | `highlights` | The chips + chevron path strip | | `details` | The Details tab body (other tabs stay synthesized) | -| `tabs` | The entire `page:tabs` node — use to add or reorder tabs (wins over `details`) | | `discussion` | `record:discussion` (the inline conversation footer) | Objects with `enable.files: true` also get a synthesized **Attachments** tab (`record:attachments`, with a count badge) beside Details/Related. It is not a -slot of its own — override `tabs` to reshape it, or pass -`hideAttachments: true` to the synthesizer to drop it. +slot of its own — pass `hideAttachments: true` to the synthesizer to drop +it. + +There is no `tabs` slot. The tab strip is composed from the object's roles +and relationship flags: to change which related lists appear or their order, +set `relatedList` / `relatedList: 'primary'` on the child's reference field; +to replace the Details body, author `details`; a page that needs its own strip +is `kind: "full"`, drawn as authored. A `tabs`, `related` or `relatedLists` +key under `slots` is refused by name, and `os migrate meta --from 17` drops a +stored `slots.tabs`. Each slot accepts a single component schema or an array (arrays are flattened in place). Each slot is a **full replacement at the slot @@ -135,8 +142,9 @@ Attachments and the record's created/updated line); from **1024 px** the related column folds into a rail behind a **Related (N)** toggle; below 1024 px, and always in a drawer or split pane, the strip as described above. One header serves every width, and a slotted `details` body fills the details -column. A slotted `tabs` strip is your own strip, so it stays a strip at every -width; `?tab=` selects a tab in the strip and is ignored by the columns. +column. A full page's own `page:tabs` strip is drawn as authored, so it stays +a strip at every width; `?tab=` selects a tab in the strip and is ignored by +the columns. Related lists are additionally gated on the current user's **object-level read permission** for the child object: a relationship whose child the user diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index f58d6e20fd4..0b81290f987 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -813,18 +813,18 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri }, [assignedPage, assignedSlots, objectDef, namedPageBlocked]); const effectivePage = assignedPage || synthesizedPage; // ─── One page at two widths (objectstack#26678) ────────────────────── - // The synthesized default page — and a slotted page that keeps the default - // tab strip — is laid out by the width of the container it occupies: three - // resident columns from 1280 px, two from 1024 px, the tab strip below that - // and whenever this view is embedded (drawer / split pane), whatever the + // The synthesized default page — and a slotted page, which always keeps the + // default tab strip — is laid out by the width of the container it occupies: + // three resident columns from 1280 px, two from 1024 px, the tab strip below + // that and whenever this view is embedded (drawer / split pane), whatever the // screen. Measured on this view's root, never the viewport. An authored full - // page and an authored `slots.tabs` strip are drawn as authored, so they - // never measure. `null` until the first reading lands (a layout effect, so - // never painted): the page body is held for that one commit rather than - // mounting the tab strip only to replace it. + // page is drawn as authored, so it never measures (the slotted `tabs` slot + // that once opted a page out too retired, objectstack#27053). `null` until + // the first reading lands (a layout effect, so never painted): the page body + // is held for that one commit rather than mounting the tab strip only to + // replace it. const [pageRootEl, setPageRootEl] = useState(null); - const pageColumnsEligible = - !assignedPage && !namedPageBlocked && !(assignedSlots && (assignedSlots as { tabs?: unknown }).tabs !== undefined); + const pageColumnsEligible = !assignedPage && !namedPageBlocked; const recordPageTier = useRecordPageTier(pageRootEl, { embedded: !!embedded, enabled: pageColumnsEligible }); const pageInColumnTier = recordPageTier === 'two-column' || recordPageTier === 'three-column'; const [pageRecord, setPageRecord] = useState(null); diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.tsx index b603a312d00..0d07006760d 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.tsx @@ -223,8 +223,10 @@ export interface PageBlockCanvasProps { /** Canonical record-page slots, in render order. A `kind:'slotted'` page * overrides individual slots; unoverridden ones are filled by the synthesizer. - * We surface all of them so an author can override an inherited slot too. */ -export const SLOT_ORDER = ['header', 'actions', 'alerts', 'highlights', 'details', 'tabs', 'discussion'] as const; + * We surface all of them so an author can override an inherited slot too. + * There is no `tabs` slot (objectstack#27053): the tab strip is composed from + * the object, and a page that needs its own strip is a `kind:'full'` page. */ +export const SLOT_ORDER = ['header', 'actions', 'alerts', 'highlights', 'details', 'discussion'] as const; /** slots object → one region per slot (single component normalised to array). */ export function slotsToRegions(slots: Record | undefined): Region[] { diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx index ce7f31d58ce..be1d80182e6 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx @@ -290,7 +290,7 @@ export function PagePreview({ draft, editing, selection, onSelectionChange, onPa // canonical default page from the bound object's schema and apply the slot // overrides via the SAME `buildDefaultPageSchema(objectDef, { slots })` path, // so omitted slots fall through to the synthesized header/details/discussion - // while authored slots (highlights/tabs/…) override in place. Non-slotted + // while authored slots (highlights/details/…) override in place. Non-slotted // pages render their authored schema unchanged. const isSlotted = (draft as { kind?: string })?.kind === 'slotted'; // The "More details" heading, resolved exactly as the running app resolves it diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/slots-canvas.test.ts b/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/slots-canvas.test.ts index d4fd56f6ab6..59947f21c71 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/slots-canvas.test.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/slots-canvas.test.ts @@ -2,18 +2,20 @@ import { describe, it, expect } from 'vitest'; import { SLOT_ORDER, slotsToRegions, regionsToSlots } from '../PageBlockCanvas'; describe('slotted page ↔ canvas regions', () => { - it('surfaces all 7 canonical slots in order, normalising single components to arrays', () => { + it('surfaces all 6 canonical slots in order, normalising single components to arrays', () => { const slots = { highlights: { type: 'record:highlights' }, alerts: [{ type: 'record:alert' }, { type: 'record:alert' }], - tabs: { type: 'page:tabs' }, + details: { type: 'record:details' }, }; const regions = slotsToRegions(slots); expect(regions.map((r) => r.name)).toEqual([...SLOT_ORDER]); const byName = Object.fromEntries(regions.map((r) => [r.name, r.components])); expect(byName.highlights).toEqual([{ type: 'record:highlights' }]); // single → [single] expect(byName.alerts).toHaveLength(2); // array stays - expect(byName.tabs).toEqual([{ type: 'page:tabs' }]); + expect(byName.details).toEqual([{ type: 'record:details' }]); + // The retired `tabs` slot is not surfaced (objectstack#27053). + expect(byName.tabs).toBeUndefined(); expect(byName.header).toEqual([]); // unoverridden slot → empty (inherited) expect(byName.discussion).toEqual([]); }); @@ -23,17 +25,17 @@ describe('slotted page ↔ canvas regions', () => { { name: 'header', components: [] }, { name: 'highlights', components: [{ type: 'record:highlights' }] }, { name: 'details', components: [] }, - { name: 'tabs', components: [{ type: 'page:tabs' }] }, + { name: 'discussion', components: [{ type: 'record:discussion' }] }, ]; const slots = regionsToSlots(regions); - expect(Object.keys(slots).sort()).toEqual(['highlights', 'tabs']); // empty omitted + expect(Object.keys(slots).sort()).toEqual(['discussion', 'highlights']); // empty omitted expect(slots.highlights).toEqual([{ type: 'record:highlights' }]); }); it('round-trips: regionsToSlots(slotsToRegions(x)) keeps the overridden slots', () => { - const slots = { highlights: { type: 'record:highlights' }, tabs: { type: 'page:tabs' } }; + const slots = { highlights: { type: 'record:highlights' }, details: { type: 'record:details' } }; const out = regionsToSlots(slotsToRegions(slots)); - expect(Object.keys(out).sort()).toEqual(['highlights', 'tabs']); - expect(out.tabs).toEqual([{ type: 'page:tabs' }]); + expect(Object.keys(out).sort()).toEqual(['details', 'highlights']); + expect(out.details).toEqual([{ type: 'record:details' }]); }); }); diff --git a/ui/packages/plugin-detail/README.md b/ui/packages/plugin-detail/README.md index c799b8c7418..0ab34795cee 100644 --- a/ui/packages/plugin-detail/README.md +++ b/ui/packages/plugin-detail/README.md @@ -813,8 +813,8 @@ each tab's stable `value` (`buildDefaultPageColumns`), so Studio's page-create seed, a slotted page and the running page never disagree, and no metadata key or node type says "columns". `?tab=` addresses the strip, so it is honoured in the tabbed tier and ignored in the column tiers. An authored record page -(`kind: 'full'`) and an authored `slots.tabs` strip are drawn as authored at -every width. +(`kind: 'full'`) is drawn as authored at every width; a slotted page keeps the +synthesized strip, so it takes the tiers. A host wires it with three exports: diff --git a/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.test.ts index d9ea565ecac..d6960839a58 100644 --- a/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.test.ts +++ b/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.test.ts @@ -176,7 +176,9 @@ describe('buildDefaultPageColumns (objectstack#26678)', () => { it('answers null — keep the tabs — for a strip it cannot read', () => { const tabsSlot = { type: 'page:tabs', properties: { items: [{ label: 'Overview', value: 'overview', children: [] }] } }; - expect(buildDefaultPageColumns(buildDefaultPageSchema(leadDef, { slots: { tabs: tabsSlot } }))).toBeNull(); + // An authored strip (a `kind: 'full'` page's own; the slotted `tabs` slot + // that once carried one retired, objectstack#27053). + expect(buildDefaultPageColumns({ type: 'record', regions: [{ name: 'main', components: [tabsSlot] }] })).toBeNull(); const gated = buildDefaultPageSchema(leadDef, { related: RELATED }); const main = gated.regions.find((r: any) => r.name === 'main'); diff --git a/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts b/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts index e3fb48c34d0..278bdcc984f 100644 --- a/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts +++ b/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts @@ -303,9 +303,9 @@ export interface BuildPageOptions { * condition is met. * - `details` — replaces the Details tab body (the `record:details` * node). Other tabs (Related / Activity / History) are unaffected. - * - `tabs` — replaces the entire `page:tabs` node. Wins over - * `details` when both are provided. Use this to add or reorder - * tabs. + * There is no `tabs` slot (objectstack#27053): the strip is composed + * from the object's roles and relationship flags, and a page that + * needs its own strip is a `kind: 'full'` page, drawn as authored. * - `discussion` — replaces `record:discussion`. Fires even when * `hideDiscussion` is true (the override is explicit intent to * surface a custom footer). @@ -324,7 +324,6 @@ export interface BuildPageOptions { alerts?: any | any[]; highlights?: any | any[]; details?: any | any[]; - tabs?: any | any[]; discussion?: any | any[]; rightRail?: any | any[]; }; @@ -1164,13 +1163,10 @@ export function buildDefaultPageSchema( const hideRelatedTab = options.hideRelatedTab ?? willEmitRail; - // 4) Tabs — `tabs` slot wins over `details` slot when both are - // provided (broader override). When only `details` is provided, - // splice it into the Details tab body and keep Related/Activity/ - // History tabs synthesized. - if ('tabs' in slots && slots.tabs !== undefined) { - components.push(...toNodeArray(slots.tabs)); - } else if ('details' in slots && slots.details !== undefined) { + // 4) Tabs — always the synthesized strip (the `tabs` slot that replaced + // it retired, objectstack#27053). A `details` slot is spliced into the + // Details tab body; Related/Activity/History tabs stay synthesized. + if ('details' in slots && slots.details !== undefined) { const detailsBody = toNodeArray(slots.details); const tabsNode = buildDefaultTabs(def, { sections: options.sections, diff --git a/ui/packages/plugin-detail/src/synth/recordPageColumns.ts b/ui/packages/plugin-detail/src/synth/recordPageColumns.ts index 5a16cb1de05..b72a7905860 100644 --- a/ui/packages/plugin-detail/src/synth/recordPageColumns.ts +++ b/ui/packages/plugin-detail/src/synth/recordPageColumns.ts @@ -34,7 +34,7 @@ * `rightRail` slot) folds into that same related column — one rail, not two. * * A page this module cannot read — no synthesized tab strip, or a tab whose - * value it does not know (an authored `slots.tabs` strip) — answers `null`, + * value it does not know (an authored strip) — answers `null`, * and the host draws the tabs. Tabs are the correct rendering of any strip. */ diff --git a/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts b/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts index c114c38563b..5913e788d2a 100644 --- a/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -145,7 +145,6 @@ import { checkDashboardWidgetMetricMeasureArity, checkDashboardWidgetChartMeasureArity, checkPageRequiresKind, - checkPageSlotPair, checkPageRegionPlacement, checkPageDefaultPlacement, checkPageMainRegionWidth, @@ -726,10 +725,8 @@ describe('the import boundary strips every imported default (objectui#8317)', () // `PageNodeSchema` and `PageKindNodeSchema`. ['checkDashboardWidgetChartMeasureArity', checkDashboardWidgetChartMeasureArity], ['checkPageRequiresKind', checkPageRequiresKind], - // objectui#12030: the page slot-pair check `@objectstack/spec` 18 adds to - // `PageSchema`, re-attached on `PageNodeSchema` and `PageKindNodeSchema` - // under the same ruling. - ['checkPageSlotPair', checkPageSlotPair], + // objectui#12030's page slot-pair check left this list with the `tabs` + // slot (objectstack#27053): the spec no longer carries it. // objectui#12370's page view-data check left this list with objectui#12666: objectstack#23784 // removed it from the spec, and no mirror attaches it. // objectui#12626: the two page checks `@objectstack/spec` 18.0.0-next.2 adds to diff --git a/ui/packages/types/src/__tests__/spec-object-refinements-7715.test.ts b/ui/packages/types/src/__tests__/spec-object-refinements-7715.test.ts index 7f6927c8809..3b2cc5da3a4 100644 --- a/ui/packages/types/src/__tests__/spec-object-refinements-7715.test.ts +++ b/ui/packages/types/src/__tests__/spec-object-refinements-7715.test.ts @@ -145,24 +145,26 @@ describe('objectui#11717 — the Page `requires` ⇄ `kind` refusal reaches obje }); }); -describe('objectui#12030 — the Page `details` + `tabs` slot-pair refusal reaches objectui', () => { - const BOTH = { kind: 'slotted', slots: { details: [], tabs: [] } }; +describe('objectstack#27053 — the retired `tabs` slot is refused at both objectui doors, with the spec\'s own issue', () => { + // The `details` + `tabs` pair refusal objectui#12030 attached left with the slot: the slot map + // carries the spec's `retiredKey()` tombstone BY REFERENCE, so the refusal needs no re-attach. + const TABS = { kind: 'slotted', slots: { tabs: [] } }; - it('the spec refuses the pair at `slots.details`, and objectui refuses with the spec\'s own issue', () => { - const spec = SpecPageSchema.safeParse(specPage(BOTH)); + it('the spec refuses `slots.tabs` with its prescription, and objectui refuses with the same issue', () => { + const spec = SpecPageSchema.safeParse(specPage(TABS)); expect(spec.success).toBe(false); - const specIssue = spec.error!.issues.find((i) => i.path.join('.') === 'slots.details'); - expect(specIssue?.code).toBe('custom'); + const specIssue = spec.error!.issues.find((i) => i.path.join('.') === 'slots.tabs'); + expect(specIssue?.message).toMatch(/^`page\.slots\.tabs` was removed/); - for (const r of [PageNodeSchema.safeParse(pageNode(BOTH)), safeValidateSchema(pageNode(BOTH))]) { + for (const r of [PageNodeSchema.safeParse(pageNode(TABS)), safeValidateSchema(pageNode(TABS))]) { expect(r.success).toBe(false); - const issues = r.error!.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message })); - expect(issues).toContainEqual({ code: 'custom', path: 'slots.details', message: specIssue!.message }); + const messages = r.error!.issues.map((i) => i.message); + expect(messages).toContain(specIssue!.message); } }); - it('controls: either slot alone passes on both doors', () => { - for (const extra of [{ kind: 'slotted', slots: { tabs: [] } }, { kind: 'slotted', slots: { details: [] } }]) { + it('controls: `details` alone, and `details` beside the other live slots, pass on both doors', () => { + for (const extra of [{ kind: 'slotted', slots: { details: [] } }, { kind: 'slotted', slots: { details: [], discussion: [] } }]) { expect(SpecPageSchema.safeParse(specPage(extra)).success).toBe(true); expect(PageNodeSchema.safeParse(pageNode(extra)).success).toBe(true); expect(safeValidateSchema(pageNode(extra)).success).toBe(true); @@ -340,8 +342,8 @@ const SITES = [ spec: SpecPageSchema, // `checkPageRequiresKind` joined the spec object at 17.7.0 (objectstack#21459) and was // attached by objectui#11717: it reads `kind` and `requires`, both carried by reference. - // `checkPageSlotPair` joined at 18 (objectstack#22568) and was attached by objectui#12030: - // it reads `slots`, carried by reference. + // `checkPageSlotPair` joined at 18 (objectstack#22568), was attached by objectui#12030, and + // left the spec object with the `tabs` slot it paired with `details` (objectstack#27053). // objectui#12370's view-data check left this list with objectui#12666: objectstack#23784 // removed it from the spec, and 18.0.0-next.3 is the first release without it (objectstack // `a35f7a6`). The census below accounts for it by what it judges, not by name @@ -355,7 +357,6 @@ const SITES = [ attached: [ 'checkPageSourceCompleteness', 'checkPageRequiresKind', - 'checkPageSlotPair', 'checkPageRegionPlacement', 'checkPageMainRegionWidth', ], diff --git a/ui/packages/types/src/layout.ts b/ui/packages/types/src/layout.ts index f13bb9000e0..b0f53dac7a5 100644 --- a/ui/packages/types/src/layout.ts +++ b/ui/packages/types/src/layout.ts @@ -1842,7 +1842,14 @@ export interface PageSlotMap { actions?: SchemaNode | SchemaNode[]; highlights?: SchemaNode | SchemaNode[]; details?: SchemaNode | SchemaNode[]; - tabs?: SchemaNode | SchemaNode[]; + /** + * @deprecated REMOVED with the spec's `slots.tabs` (objectstack#27053): the + * tab strip is composed from the object's roles and relationship flags, and + * a page that needs its own strip is a `kind: 'full'` page. Typed `never` + * so a typed slot map cannot author it; the spec parse refuses it with the + * prescription. + */ + tabs?: never; discussion?: SchemaNode | SchemaNode[]; } diff --git a/ui/packages/types/src/zod/layout.zod.ts b/ui/packages/types/src/zod/layout.zod.ts index e1efc70a41f..5780189e782 100644 --- a/ui/packages/types/src/zod/layout.zod.ts +++ b/ui/packages/types/src/zod/layout.zod.ts @@ -27,7 +27,6 @@ import { checkPageRecordBindingPlacement, checkPageRegionPlacement, checkPageRequiresKind, - checkPageSlotPair, checkPageSourceCompleteness, } from '@objectstack/spec/ui'; import { BaseSchema, SchemaNodeSchema, specFieldsExcept } from './base.zod.js'; @@ -1355,10 +1354,6 @@ export const PageNodeSchema = BaseSchema.extend(SpecPageFields.shape).extend({ // - `requires` is refused at `requires` on a page whose `kind` the platform // does not compile at save — anything but `html`, an absent `kind` // included (`checkPageRequiresKind`, objectstack#21459, the spec's 17.7.0); - // - a page that authors both `slots.details` and `slots.tabs` is refused at - // `slots.details`, because the `tabs` slot replaces the strip the details - // body lives in (`checkPageSlotPair`, objectstack#22568, the spec's 18; - // attached by objectui#12030); // - on a `kind: 'full'` page, a region whose `name` the page's `template` // does not place, or a second region for a slot already filled, is // refused at `regions.N.name` (`checkPageRegionPlacement`, the spec's @@ -1378,8 +1373,10 @@ export const PageNodeSchema = BaseSchema.extend(SpecPageFields.shape).extend({ // this node's own {@link PageNodeRegionSchema} carries with the same // meaning: `page.tsx` places `main` by its lowercased `name` and reads its // `width` only for `'full'`. - // The checks listed above are attached as-is: the first three read `kind`, - // `source`, `requires` and `slots`, which this node carries by reference — + // (`checkPageSlotPair`, the `details` + `tabs` slot pair refusal attached by + // objectui#12030, left the spec with the `tabs` slot, objectstack#27053.) + // The checks listed above are attached as-is: the first two read `kind`, + // `source` and `requires`, which this node carries by reference — // none is in {@link PAGE_SPEC_EXCLUDED} nor overridden above. // The page-parse judge of a view-data component's retired `api` binding is no // longer among them: objectstack#23784 removed it from the spec, which now @@ -1402,7 +1399,6 @@ export const PageNodeSchema = BaseSchema.extend(SpecPageFields.shape).extend({ // check the spec adds to `PageSchema` later reddens there. .superRefine(checkPageSourceCompleteness) .superRefine(checkPageRequiresKind) - .superRefine(checkPageSlotPair) .superRefine(checkPageRegionPlacement) .superRefine(checkPageMainRegionWidth); @@ -1453,9 +1449,8 @@ const { type: _pageNodeTypeLiteral, ...PAGE_NODE_MEMBERS } = PageNodeSchema.shap * takes the spec's `PageSchema` fields by reference through * {@link SpecPageFields}. Its four refusals (`actions`, `breadcrumbs`, * `maxWidth`, `padding`), the `body` refusal and the spec's attachable - * object-level checks (`source`, `requires` by `kind`, the `details` / - * `tabs` slot pair, region placement by `template`, and the `main` region's - * `width`) come with it. The spec's `isDefault` placement check and its + * object-level checks (`source`, `requires` by `kind`, region placement by + * `template`, and the `main` region's `width`) come with it. The spec's `isDefault` placement check and its * `record` binding placement check are attached here only: each reads the page * kind from `type`, which this node carries and the `page` node does not. `regions` stays the node's own * {@link PageNodeRegionSchema}, so each region component is judged by this @@ -1467,7 +1462,7 @@ const { type: _pageNodeTypeLiteral, ...PAGE_NODE_MEMBERS } = PageNodeSchema.shap export const PageKindNodeSchema: PageKindNodeSchemaType = BaseSchema.extend({ ...PAGE_NODE_MEMBERS, type: PAGE_KIND_NODE_TYPE.describe('The spec page kind — `record`, `home` or `utility` (`@objectstack/spec` `PageTypeSchema`)'), -}).superRefine(checkPageSourceCompleteness).superRefine(checkPageRequiresKind).superRefine(checkPageSlotPair) +}).superRefine(checkPageSourceCompleteness).superRefine(checkPageRequiresKind) .superRefine(checkPageRegionPlacement) .superRefine(checkPageMainRegionWidth) // On this node `type` is the spec page kind, the field the spec's From a735eec657ec0dbd3c7b94ed585ee6f0dc813db6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:59:42 +0000 Subject: [PATCH 04/11] wip: lint fixture order; plugin-detail stray tabs slot test Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzff3QiP6kmtajVnKnq --- .../src/validate-page-field-bindings.test.ts | 2 +- .../validate-visibility-predicates.test.ts | 2 +- .../__tests__/buildDefaultPageSchema.test.ts | 24 +++++++------------ 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/packages/lint/src/validate-page-field-bindings.test.ts b/packages/lint/src/validate-page-field-bindings.test.ts index 79c53d64773..77b85c37341 100644 --- a/packages/lint/src/validate-page-field-bindings.test.ts +++ b/packages/lint/src/validate-page-field-bindings.test.ts @@ -588,8 +588,8 @@ describe('validatePageFieldBindings — binding precedence and traversal', () => }); expect(findings).toHaveLength(2); expect(findings.map((f) => f.path).sort()).toEqual([ - 'pages[0].slots.highlights.properties.fields[0]', 'pages[0].slots.details.properties.items[0].children[0].properties.fields[0]', + 'pages[0].slots.highlights.properties.fields[0]', ]); }); diff --git a/packages/lint/src/validate-visibility-predicates.test.ts b/packages/lint/src/validate-visibility-predicates.test.ts index bf762c4eb07..7a1e93f58f0 100644 --- a/packages/lint/src/validate-visibility-predicates.test.ts +++ b/packages/lint/src/validate-visibility-predicates.test.ts @@ -431,8 +431,8 @@ describe('visibility-bare-identifier (#6128 / #5149 requirement 3)', () => { ], }; expect(bareFindings(stack).map((f) => f.path).sort()).toEqual([ - 'pages[0].slots.highlights', 'pages[0].slots.details[1]', + 'pages[0].slots.highlights', ]); }); diff --git a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts index ae75050f42a..6e4dbdb725c 100644 --- a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts +++ b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts @@ -700,27 +700,21 @@ describe('buildDefaultPageSchema', () => { expect(firstBodyType).toBe('div'); }); - it('tabs slot wins over details slot when both provided', () => { - // Authored verbatim, in the caller's own spelling: a slot node is the - // author's, and the synthesizer places it UNTOUCHED — it does not - // re-shape someone else's tree (objectui#4232 canonicalizes the nodes - // this file BUILDS, not the ones it is handed). + it('reads no `tabs` slot: a stray one is ignored and the details slot applies (objectstack#27053)', () => { + // The spec retired `slots.tabs` (a tombstone refuses it at parse), so the + // synthesizer composes from the six live slots only. A slot map that + // still carries one, unparsed, keeps the synthesized strip. const slotNode = { type: 'page:tabs', items: [{ label: 'Only', children: [] }] }; const page = buildDefaultPageSchema(leadDef, { slots: { tabs: slotNode, - details: { type: 'div', id: 'unused' }, - }, + details: { type: 'div', id: 'used' }, + } as any, }); const tabs = page.regions[0].components.find((c: any) => c.type === 'page:tabs'); - expect(tabs).toEqual(slotNode); - expect(tabs.items).toHaveLength(1); - expect(tabs.items[0].label).toBe('Only'); - // details slot was not applied - const hasUnused = page.regions[0].components.some( - (c: any) => c.id === 'unused', - ); - expect(hasUnused).toBe(false); + expect(tabs).not.toBe(slotNode); + expect(tabItems(tabs).map((t: any) => t.label)).not.toEqual(['Only']); + expect(tabItems(tabs)[0].children).toEqual([{ type: 'div', id: 'used' }]); }); it('discussion slot overrides even when hideDiscussion is true', () => { From a64e5ca777354a390801325e43453ba2f14efcf0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:00:19 +0000 Subject: [PATCH 05/11] docs(ui): the pages guide drops the tabs slot Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzff3QiP6kmtajVnKnq --- content/docs/ui/pages.mdx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index 52ab7529a03..5fec3c5231d 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -207,13 +207,14 @@ synthesizer fills in the rest. A slotted page authors `slots`, not `regions`: | `alerts` | The alert banners above the body | | `highlights` | The highlights strip | | `details` | The body of the Details tab; the other tabs stay synthesized | -| `tabs` | The whole tab strip, to add, remove or reorder tabs | | `discussion` | The discussion panel | Each slot takes one component or an array of them, and replaces its part whole: there is no -deep merge. `details` and `tabs` are refused together, because the Details tab lives inside -the strip `tabs` replaces; to replace both, put `record:details` in the first `tabs` item -and author no `details` slot. A slotted `tabs` strip stays a strip at every width. +deep merge. There is no `tabs` slot: the tab strip is composed from the object's roles and +relationship flags. To change which related lists appear or their order, set `relatedList` / +`relatedList: 'primary'` on the child's reference field; a page that needs its own strip is +`kind: 'full'`, drawn as authored. `slots.tabs`, `slots.related` and `slots.relatedLists` are +refused by name, and `os migrate meta --from 17` drops a stored `slots.tabs`. {/* os:check */} ```typescript From f8beec668ee76b2c5d0cae632c0309162addd3bc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:05:34 +0000 Subject: [PATCH 06/11] chore(spec): regenerate api-surface, export-origins and references Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzff3QiP6kmtajVnKnq --- content/docs/references/ui/page.mdx | 12 ++++++++++++ packages/spec/api-surface/ui.json | 1 - packages/spec/export-origins/ui.json | 1 - 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 2e5080ca4f8..26119a323dd 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -294,6 +294,18 @@ View filter rule | **repeatTableHeaders** | `boolean` | optional | Page-break hint: repeat a table's column headings at the top of every sheet the table runs onto (`thead { display: table-header-group }`). On when omitted. Applied by the console's browser print rendering. | | **avoidBreakInside** | `boolean` | optional | Page-break hint: keep each block whole on one sheet when it fits, moving it to the next sheet instead of splitting it (`break-inside: avoid` on every block). Off when omitted. Applied by the console's browser print rendering. | +### Nested Shape: `Page.slots` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **header** | `{ type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string; id?: string; label?: string \| Record; properties?: Record; … } \| { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string; id?: string; label?: string \| Record; properties?: Record; … }[]` | optional | | +| **actions** | `{ type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string; id?: string; label?: string \| Record; properties?: Record; … } \| { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string; id?: string; label?: string \| Record; properties?: Record; … }[]` | optional | | +| **alerts** | `{ type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string; id?: string; label?: string \| Record; properties?: Record; … } \| { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string; id?: string; label?: string \| Record; properties?: Record; … }[]` | optional | | +| **highlights** | `{ type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string; id?: string; label?: string \| Record; properties?: Record; … } \| { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string; id?: string; label?: string \| Record; properties?: Record; … }[]` | optional | | +| **details** | `{ type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string; id?: string; label?: string \| Record; properties?: Record; … } \| { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string; id?: string; label?: string \| Record; properties?: Record; … }[]` | optional | | +| **tabs** | `never` | optional | [REMOVED] `page.slots.tabs` was removed in @objectstack/spec 18.3.0 (ADR-0085) — an authored strip replaced the whole synthesized one, so the related lists, approvals, attachments and history the platform composes from the object were dropped unless re-added by hand, and the page stayed tabbed at every width instead of taking the record page's columns. Delete the key. The record page composes its tabs and columns from the object's roles and relationship flags: to change which related lists appear or their order, set `relatedList` / `relatedList: 'primary'` on the child's reference field; to replace the Details body, author `slots.details`; a page that needs its own strip is `kind: 'full'`, drawn as authored. 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. | +| **discussion** | `{ type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string; id?: string; label?: string \| Record; properties?: Record; … } \| { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string; id?: string; label?: string \| Record; properties?: Record; … }[]` | optional | | + --- diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index dd9b551d5ff..fb64982ab8f 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -527,7 +527,6 @@ "checkPageRecordBindingPlacement (function)", "checkPageRegionPlacement (function)", "checkPageRequiresKind (function)", - "checkPageSlotPair (function)", "checkPageSourceCompleteness (function)", "columnSummaryAlias (function)", "compileListViewGroupQuery (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index ee6372813ab..db3ec47a59c 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -510,7 +510,6 @@ "checkPageRecordBindingPlacement": "src/ui/page.zod.ts#checkPageRecordBindingPlacement (function)", "checkPageRegionPlacement": "src/ui/page.zod.ts#checkPageRegionPlacement (function)", "checkPageRequiresKind": "src/ui/page.zod.ts#checkPageRequiresKind (function)", - "checkPageSlotPair": "src/ui/page.zod.ts#checkPageSlotPair (function)", "checkPageSourceCompleteness": "src/ui/page.zod.ts#checkPageSourceCompleteness (function)", "columnSummaryAlias": "src/ui/view-grouping-query.ts#columnSummaryAlias (function)", "compileListViewGroupQuery": "src/ui/view-grouping-query.ts#compileListViewGroupQuery (function)", From dd93f77b46e4cb4845945abcecff7e413f1245b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:10:40 +0000 Subject: [PATCH 07/11] test(console): the served approvals page copies follow the full-page rebuild; preview slot fixture Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzff3QiP6kmtajVnKnq --- ...lsInboxPage.opensServedPage-12484.test.tsx | 81 +++++++++--------- ...ovalRequestsDataSource.servedPage.test.tsx | 81 +++++++++--------- ...aSource.servedPageApprovers-12482.test.tsx | 83 ++++++++++--------- ...ource.servedPageLookupEnter-12569.test.tsx | 81 +++++++++--------- ...servedPageMemberPermissions-13324.test.tsx | 81 +++++++++--------- ...Source.servedPageRecordLink-12557.test.tsx | 81 +++++++++--------- ...e.servedPageReferenceStates-12489.test.tsx | 81 +++++++++--------- ...taSource.servedPageStepLine-12491.test.tsx | 83 ++++++++++--------- ...ataSource.servedPageSummary-12483.test.tsx | 83 ++++++++++--------- ...rce.servedPageSummaryAmount-12534.test.tsx | 81 +++++++++--------- .../previews/PagePreview.test.tsx | 8 +- 11 files changed, 438 insertions(+), 386 deletions(-) diff --git a/ui/apps/console/src/pages/system/ApprovalsInboxPage.opensServedPage-12484.test.tsx b/ui/apps/console/src/pages/system/ApprovalsInboxPage.opensServedPage-12484.test.tsx index 5ab2f645e7b..12f8ed75bc9 100644 --- a/ui/apps/console/src/pages/system/ApprovalsInboxPage.opensServedPage-12484.test.tsx +++ b/ui/apps/console/src/pages/system/ApprovalsInboxPage.opensServedPage-12484.test.tsx @@ -153,51 +153,56 @@ const SERVED_PAGE = { type: 'record', object: 'sys_approval_request', template: 'default', - kind: 'slotted', + kind: 'full', isDefault: true, - regions: [], - slots: { - actions: [{ type: 'record:approval_decision' }], - highlights: { - type: 'record:highlights', - properties: { - fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], - }, - }, - tabs: { - type: 'page:tabs', - properties: { - tabStyle: 'line', - position: 'top', - items: [ - { - label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, - icon: 'file-text', - children: [{ type: 'record:details' }], + regions: [ + { + name: 'main', + width: 'full', + components: [ + { type: 'page:header' }, + { type: 'record:approval_decision' }, + { + type: 'record:highlights', + properties: { + fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], }, - { - label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - icon: 'history', - children: [ + }, + { + type: 'page:tabs', + properties: { + tabStyle: 'line', + position: 'top', + items: [ + { + label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, + icon: 'file-text', + children: [{ type: 'record:details' }], + }, { - type: 'record:related_list', - properties: { - objectName: 'sys_approval_action', - relationshipField: 'request_id', - columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 50, - showViewAll: true, - title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - }, + label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + icon: 'history', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_approval_action', + relationshipField: 'request_id', + columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 50, + showViewAll: true, + title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + }, + }, + ], }, ], }, - ], - }, + }, + ], }, - discussion: [], - }, + ], }; function json(status: number, body: unknown): Response { diff --git a/ui/apps/console/src/services/approvalRequestsDataSource.servedPage.test.tsx b/ui/apps/console/src/services/approvalRequestsDataSource.servedPage.test.tsx index 64cbe92c36f..ee109cb4402 100644 --- a/ui/apps/console/src/services/approvalRequestsDataSource.servedPage.test.tsx +++ b/ui/apps/console/src/services/approvalRequestsDataSource.servedPage.test.tsx @@ -150,51 +150,56 @@ const SERVED_PAGE = { type: 'record', object: 'sys_approval_request', template: 'default', - kind: 'slotted', + kind: 'full', isDefault: true, - regions: [], - slots: { - actions: [{ type: 'record:approval_decision' }], - highlights: { - type: 'record:highlights', - properties: { - fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], - }, - }, - tabs: { - type: 'page:tabs', - properties: { - tabStyle: 'line', - position: 'top', - items: [ - { - label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, - icon: 'file-text', - children: [{ type: 'record:details' }], + regions: [ + { + name: 'main', + width: 'full', + components: [ + { type: 'page:header' }, + { type: 'record:approval_decision' }, + { + type: 'record:highlights', + properties: { + fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], }, - { - label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - icon: 'history', - children: [ + }, + { + type: 'page:tabs', + properties: { + tabStyle: 'line', + position: 'top', + items: [ + { + label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, + icon: 'file-text', + children: [{ type: 'record:details' }], + }, { - type: 'record:related_list', - properties: { - objectName: 'sys_approval_action', - relationshipField: 'request_id', - columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 50, - showViewAll: true, - title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - }, + label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + icon: 'history', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_approval_action', + relationshipField: 'request_id', + columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 50, + showViewAll: true, + title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + }, + }, + ], }, ], }, - ], - }, + }, + ], }, - discussion: [], - }, + ], }; function json(status: number, body: unknown): Response { diff --git a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageApprovers-12482.test.tsx b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageApprovers-12482.test.tsx index 3fd016d1acc..01859d5ce47 100644 --- a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageApprovers-12482.test.tsx +++ b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageApprovers-12482.test.tsx @@ -177,51 +177,56 @@ const SERVED_PAGE = { type: 'record', object: 'sys_approval_request', template: 'default', - kind: 'slotted', + kind: 'full', isDefault: true, - regions: [], - slots: { - actions: [{ type: 'record:approval_decision' }], - highlights: { - type: 'record:highlights', - properties: { - fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], - }, - }, - tabs: { - type: 'page:tabs', - properties: { - tabStyle: 'line', - position: 'top', - items: [ - { - label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, - icon: 'file-text', - children: [{ type: 'record:details' }], + regions: [ + { + name: 'main', + width: 'full', + components: [ + { type: 'page:header' }, + { type: 'record:approval_decision' }, + { + type: 'record:highlights', + properties: { + fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], }, - { - label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - icon: 'history', - children: [ + }, + { + type: 'page:tabs', + properties: { + tabStyle: 'line', + position: 'top', + items: [ + { + label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, + icon: 'file-text', + children: [{ type: 'record:details' }], + }, { - type: 'record:related_list', - properties: { - objectName: 'sys_approval_action', - relationshipField: 'request_id', - columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 50, - showViewAll: true, - title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - }, + label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + icon: 'history', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_approval_action', + relationshipField: 'request_id', + columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 50, + showViewAll: true, + title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + }, + }, + ], }, ], }, - ], - }, + }, + ], }, - discussion: [], - }, + ], }; /** The `position` registry, as `GET /api/v1/meta/position` serves it. */ @@ -309,7 +314,7 @@ describe('the served request page names who the pending step waits on (objectui# it('the served page parses as a spec page, with no node or prop added for the names', () => { const parsed = PageSchema.safeParse(SERVED_PAGE); expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true); - expect(SERVED_PAGE.slots.actions).toEqual([{ type: 'record:approval_decision' }]); + expect(SERVED_PAGE.regions[0].components[1]).toEqual({ type: 'record:approval_decision' }); }); it('names each pending approver, with the countersign group it fills and a position by its registry label', async () => { diff --git a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageLookupEnter-12569.test.tsx b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageLookupEnter-12569.test.tsx index 31650fd93fc..a0b2c4c11cb 100644 --- a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageLookupEnter-12569.test.tsx +++ b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageLookupEnter-12569.test.tsx @@ -139,51 +139,56 @@ const SERVED_PAGE = { type: 'record', object: 'sys_approval_request', template: 'default', - kind: 'slotted', + kind: 'full', isDefault: true, - regions: [], - slots: { - actions: [{ type: 'record:approval_decision' }], - highlights: { - type: 'record:highlights', - properties: { - fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], - }, - }, - tabs: { - type: 'page:tabs', - properties: { - tabStyle: 'line', - position: 'top', - items: [ - { - label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, - icon: 'file-text', - children: [{ type: 'record:details' }], + regions: [ + { + name: 'main', + width: 'full', + components: [ + { type: 'page:header' }, + { type: 'record:approval_decision' }, + { + type: 'record:highlights', + properties: { + fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], }, - { - label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - icon: 'history', - children: [ + }, + { + type: 'page:tabs', + properties: { + tabStyle: 'line', + position: 'top', + items: [ + { + label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, + icon: 'file-text', + children: [{ type: 'record:details' }], + }, { - type: 'record:related_list', - properties: { - objectName: 'sys_approval_action', - relationshipField: 'request_id', - columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 50, - showViewAll: true, - title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - }, + label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + icon: 'history', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_approval_action', + relationshipField: 'request_id', + columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 50, + showViewAll: true, + title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + }, + }, + ], }, ], }, - ], - }, + }, + ], }, - discussion: [], - }, + ], }; function json(status: number, body: unknown): Response { diff --git a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageMemberPermissions-13324.test.tsx b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageMemberPermissions-13324.test.tsx index 3c676b797c4..e1df79707e8 100644 --- a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageMemberPermissions-13324.test.tsx +++ b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageMemberPermissions-13324.test.tsx @@ -146,51 +146,56 @@ const SERVED_PAGE = { type: 'record', object: 'sys_approval_request', template: 'default', - kind: 'slotted', + kind: 'full', isDefault: true, - regions: [], - slots: { - actions: [{ type: 'record:approval_decision' }], - highlights: { - type: 'record:highlights', - properties: { - fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], - }, - }, - tabs: { - type: 'page:tabs', - properties: { - tabStyle: 'line', - position: 'top', - items: [ - { - label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, - icon: 'file-text', - children: [{ type: 'record:details' }], + regions: [ + { + name: 'main', + width: 'full', + components: [ + { type: 'page:header' }, + { type: 'record:approval_decision' }, + { + type: 'record:highlights', + properties: { + fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], }, - { - label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - icon: 'history', - children: [ + }, + { + type: 'page:tabs', + properties: { + tabStyle: 'line', + position: 'top', + items: [ + { + label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, + icon: 'file-text', + children: [{ type: 'record:details' }], + }, { - type: 'record:related_list', - properties: { - objectName: 'sys_approval_action', - relationshipField: 'request_id', - columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 50, - showViewAll: true, - title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - }, + label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + icon: 'history', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_approval_action', + relationshipField: 'request_id', + columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 50, + showViewAll: true, + title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + }, + }, + ], }, ], }, - ], - }, + }, + ], }, - discussion: [], - }, + ], }; /** diff --git a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageRecordLink-12557.test.tsx b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageRecordLink-12557.test.tsx index 0d6ead67188..ffc19b62ee6 100644 --- a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageRecordLink-12557.test.tsx +++ b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageRecordLink-12557.test.tsx @@ -125,51 +125,56 @@ const SERVED_PAGE = { type: 'record', object: 'sys_approval_request', template: 'default', - kind: 'slotted', + kind: 'full', isDefault: true, - regions: [], - slots: { - actions: [{ type: 'record:approval_decision' }], - highlights: { - type: 'record:highlights', - properties: { - fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], - }, - }, - tabs: { - type: 'page:tabs', - properties: { - tabStyle: 'line', - position: 'top', - items: [ - { - label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, - icon: 'file-text', - children: [{ type: 'record:details' }], + regions: [ + { + name: 'main', + width: 'full', + components: [ + { type: 'page:header' }, + { type: 'record:approval_decision' }, + { + type: 'record:highlights', + properties: { + fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], }, - { - label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - icon: 'history', - children: [ + }, + { + type: 'page:tabs', + properties: { + tabStyle: 'line', + position: 'top', + items: [ + { + label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, + icon: 'file-text', + children: [{ type: 'record:details' }], + }, { - type: 'record:related_list', - properties: { - objectName: 'sys_approval_action', - relationshipField: 'request_id', - columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 50, - showViewAll: true, - title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - }, + label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + icon: 'history', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_approval_action', + relationshipField: 'request_id', + columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 50, + showViewAll: true, + title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + }, + }, + ], }, ], }, - ], - }, + }, + ], }, - discussion: [], - }, + ], }; function json(status: number, body: unknown): Response { diff --git a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageReferenceStates-12489.test.tsx b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageReferenceStates-12489.test.tsx index f51fc03c32f..24ec702a534 100644 --- a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageReferenceStates-12489.test.tsx +++ b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageReferenceStates-12489.test.tsx @@ -144,51 +144,56 @@ const SERVED_PAGE = { type: 'record', object: 'sys_approval_request', template: 'default', - kind: 'slotted', + kind: 'full', isDefault: true, - regions: [], - slots: { - actions: [{ type: 'record:approval_decision' }], - highlights: { - type: 'record:highlights', - properties: { - fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], - }, - }, - tabs: { - type: 'page:tabs', - properties: { - tabStyle: 'line', - position: 'top', - items: [ - { - label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, - icon: 'file-text', - children: [{ type: 'record:details' }], + regions: [ + { + name: 'main', + width: 'full', + components: [ + { type: 'page:header' }, + { type: 'record:approval_decision' }, + { + type: 'record:highlights', + properties: { + fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], }, - { - label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - icon: 'history', - children: [ + }, + { + type: 'page:tabs', + properties: { + tabStyle: 'line', + position: 'top', + items: [ + { + label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, + icon: 'file-text', + children: [{ type: 'record:details' }], + }, { - type: 'record:related_list', - properties: { - objectName: 'sys_approval_action', - relationshipField: 'request_id', - columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 50, - showViewAll: true, - title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - }, + label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + icon: 'history', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_approval_action', + relationshipField: 'request_id', + columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 50, + showViewAll: true, + title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + }, + }, + ], }, ], }, - ], - }, + }, + ], }, - discussion: [], - }, + ], }; function json(status: number, body: unknown): Response { diff --git a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageStepLine-12491.test.tsx b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageStepLine-12491.test.tsx index 47984fa971e..2f3dbd965cb 100644 --- a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageStepLine-12491.test.tsx +++ b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageStepLine-12491.test.tsx @@ -189,51 +189,56 @@ const SERVED_PAGE = { type: 'record', object: 'sys_approval_request', template: 'default', - kind: 'slotted', + kind: 'full', isDefault: true, - regions: [], - slots: { - actions: [{ type: 'record:approval_decision' }], - highlights: { - type: 'record:highlights', - properties: { - fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], - }, - }, - tabs: { - type: 'page:tabs', - properties: { - tabStyle: 'line', - position: 'top', - items: [ - { - label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, - icon: 'file-text', - children: [{ type: 'record:details' }], + regions: [ + { + name: 'main', + width: 'full', + components: [ + { type: 'page:header' }, + { type: 'record:approval_decision' }, + { + type: 'record:highlights', + properties: { + fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], }, - { - label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - icon: 'history', - children: [ + }, + { + type: 'page:tabs', + properties: { + tabStyle: 'line', + position: 'top', + items: [ + { + label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, + icon: 'file-text', + children: [{ type: 'record:details' }], + }, { - type: 'record:related_list', - properties: { - objectName: 'sys_approval_action', - relationshipField: 'request_id', - columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 50, - showViewAll: true, - title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - }, + label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + icon: 'history', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_approval_action', + relationshipField: 'request_id', + columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 50, + showViewAll: true, + title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + }, + }, + ], }, ], }, - ], - }, + }, + ], }, - discussion: [], - }, + ], }; function json(status: number, body: unknown): Response { @@ -310,7 +315,7 @@ describe('the served request page shows the flow origin (objectui#12491) and the it('the served page parses as a spec page, with no node or prop added for either', () => { const parsed = PageSchema.safeParse(SERVED_PAGE); expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true); - expect(SERVED_PAGE.slots.actions).toEqual([{ type: 'record:approval_decision' }]); + expect(SERVED_PAGE.regions[0].components[1]).toEqual({ type: 'record:approval_decision' }); }); it('a multi-step request draws its served route as a vertical line, the current step marked, none of the trunk\'s other rungs', async () => { diff --git a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageSummary-12483.test.tsx b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageSummary-12483.test.tsx index 36dfaa3e482..749aeafdff2 100644 --- a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageSummary-12483.test.tsx +++ b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageSummary-12483.test.tsx @@ -201,51 +201,56 @@ const SERVED_PAGE = { type: 'record', object: 'sys_approval_request', template: 'default', - kind: 'slotted', + kind: 'full', isDefault: true, - regions: [], - slots: { - actions: [{ type: 'record:approval_decision' }], - highlights: { - type: 'record:highlights', - properties: { - fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], - }, - }, - tabs: { - type: 'page:tabs', - properties: { - tabStyle: 'line', - position: 'top', - items: [ - { - label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, - icon: 'file-text', - children: [{ type: 'record:details' }], + regions: [ + { + name: 'main', + width: 'full', + components: [ + { type: 'page:header' }, + { type: 'record:approval_decision' }, + { + type: 'record:highlights', + properties: { + fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], }, - { - label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - icon: 'history', - children: [ + }, + { + type: 'page:tabs', + properties: { + tabStyle: 'line', + position: 'top', + items: [ + { + label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, + icon: 'file-text', + children: [{ type: 'record:details' }], + }, { - type: 'record:related_list', - properties: { - objectName: 'sys_approval_action', - relationshipField: 'request_id', - columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 50, - showViewAll: true, - title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - }, + label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + icon: 'history', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_approval_action', + relationshipField: 'request_id', + columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 50, + showViewAll: true, + title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + }, + }, + ], }, ], }, - ], - }, + }, + ], }, - discussion: [], - }, + ], }; function json(status: number, body: unknown): Response { @@ -386,7 +391,7 @@ describe('the served request page shows the declared amount and a summary of the it('the served page parses as a spec page, with no node or prop added for the summary', () => { const parsed = PageSchema.safeParse(SERVED_PAGE); expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true); - expect(SERVED_PAGE.slots.actions).toEqual([{ type: 'record:approval_decision' }]); + expect(SERVED_PAGE.regions[0].components[1]).toEqual({ type: 'record:approval_decision' }); }); it('declared labels and the record page\'s faces: option labels, the date face, a resolved reference by its name', async () => { diff --git a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageSummaryAmount-12534.test.tsx b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageSummaryAmount-12534.test.tsx index 94257c0600b..7b54dcd6fd0 100644 --- a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageSummaryAmount-12534.test.tsx +++ b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageSummaryAmount-12534.test.tsx @@ -234,51 +234,56 @@ const SERVED_PAGE = { type: 'record', object: 'sys_approval_request', template: 'default', - kind: 'slotted', + kind: 'full', isDefault: true, - regions: [], - slots: { - actions: [{ type: 'record:approval_decision' }], - highlights: { - type: 'record:highlights', - properties: { - fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], - }, - }, - tabs: { - type: 'page:tabs', - properties: { - tabStyle: 'line', - position: 'top', - items: [ - { - label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, - icon: 'file-text', - children: [{ type: 'record:details' }], + regions: [ + { + name: 'main', + width: 'full', + components: [ + { type: 'page:header' }, + { type: 'record:approval_decision' }, + { + type: 'record:highlights', + properties: { + fields: ['process_label', 'status', 'current_step_label', 'submitter_id', 'updated_at'], }, - { - label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - icon: 'history', - children: [ + }, + { + type: 'page:tabs', + properties: { + tabStyle: 'line', + position: 'top', + items: [ + { + label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, + icon: 'file-text', + children: [{ type: 'record:details' }], + }, { - type: 'record:related_list', - properties: { - objectName: 'sys_approval_action', - relationshipField: 'request_id', - columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 50, - showViewAll: true, - title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, - }, + label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + icon: 'history', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_approval_action', + relationshipField: 'request_id', + columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 50, + showViewAll: true, + title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + }, + }, + ], }, ], }, - ], - }, + }, + ], }, - discussion: [], - }, + ], }; /** An expense report's snapshot as the approvals get serves it, in the stored column order. */ diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/PagePreview.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/PagePreview.test.tsx index 5270a839055..5e4160c98d5 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/PagePreview.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/PagePreview.test.tsx @@ -98,7 +98,7 @@ describe('PagePreview — slotted record page synthesis', () => { regions: [], // slotted pages carry an empty regions[] — the blank-canvas trap slots: { highlights: [{ type: 'record:highlights', fields: ['industry', 'website'] }], - tabs: [{ type: 'page:tabs', items: [{ label: 'Custom', children: [] }] }], + details: [{ type: 'element:text', id: 'custom_details', properties: { content: 'Custom' } }], }, }; @@ -132,9 +132,11 @@ describe('PagePreview — slotted record page synthesis', () => { const highlights = comps.find((c) => c.type === 'record:highlights'); expect(highlights?.fields).toEqual(['industry', 'website']); - // Authored tabs override wins — the custom tab is present. + // Authored details override fills the Details tab of the synthesized + // strip (there is no `tabs` slot since objectstack#27053). const tabs = comps.find((c) => c.type === 'page:tabs'); - expect(tabs?.items?.[0]?.label).toBe('Custom'); + const items = tabs?.items ?? tabs?.properties?.items; + expect(items?.[0]?.children).toEqual([{ type: 'element:text', id: 'custom_details', properties: { content: 'Custom' } }]); }); it('renders the authored schema unchanged for a non-slotted (full) record page', async () => { From bc41b6b0392d61ccecee01842951b4c9bd956c62 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:16:18 +0000 Subject: [PATCH 08/11] test(platform-objects): type the envelope control's region read Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzff3QiP6kmtajVnKnq --- .../src/pages/canonical-expression-envelopes.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts b/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts index b9315567af9..23294501434 100644 --- a/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts +++ b/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts @@ -243,7 +243,7 @@ describe('downgrade control — a shipped page, predicate downgraded to bare', ( // half only this package can run, and the one that reds if the lint // detector is ablated or its import breaks.) const source = JSON.parse(JSON.stringify(pageExports.SysUserDetailPage)) as AnyRec; - const components = source.regions[0].components as AnyRec[]; + const components = (source.regions as AnyRec[])[0]!.components as AnyRec[]; const alertAt = components.findIndex(c => c.type === 'record:alert'); const alert = components[alertAt]!; const authored = (alert.visibleWhen as AnyRec).source as string; From ca5556e0661a7d19b5bb990f02c2135bbe746a3a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:54:48 +0000 Subject: [PATCH 09/11] test(spec): the slots.tabs absence pin runs in the repo project; docs avoid the reserved word Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzff3QiP6kmtajVnKnq --- content/docs/ui/pages.mdx | 2 +- packages/spec/src/ui/page-slots-tabs-retirement.test.ts | 2 ++ packages/spec/vitest.repo-tests.json | 1 + ui/content/docs/guide/slotted-pages.md | 2 +- 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index 5fec3c5231d..d76b24c2af2 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -210,7 +210,7 @@ synthesizer fills in the rest. A slotted page authors `slots`, not `regions`: | `discussion` | The discussion panel | Each slot takes one component or an array of them, and replaces its part whole: there is no -deep merge. There is no `tabs` slot: the tab strip is composed from the object's roles and +deep merge. There is no `tabs` slot: the tab strip is composed from the object's fields and relationship flags. To change which related lists appear or their order, set `relatedList` / `relatedList: 'primary'` on the child's reference field; a page that needs its own strip is `kind: 'full'`, drawn as authored. `slots.tabs`, `slots.related` and `slots.relatedLists` are diff --git a/packages/spec/src/ui/page-slots-tabs-retirement.test.ts b/packages/spec/src/ui/page-slots-tabs-retirement.test.ts index 60f4546ec46..9832c41dfe7 100644 --- a/packages/spec/src/ui/page-slots-tabs-retirement.test.ts +++ b/packages/spec/src/ui/page-slots-tabs-retirement.test.ts @@ -234,6 +234,8 @@ describe('tree-scoped absence: no slot map inside the declared radius authors `t const EXCLUDED = new Set([ // This pin authors the slot at the parse to assert the refusal. THIS_FILE, + // The removal-version pin parses the retired slot to read its prescription. + 'packages/spec/src/shared/retired-key-removal-version.pin.test.ts', ]); const EXCLUDED_PREFIXES = [ // The D2 conversion's fixture authors the pre-retirement page on purpose. diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 8e56b8bc806..daefa322c66 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -75,6 +75,7 @@ "src/ui/object-grid-resizable-columns-retirement.test.ts", "src/ui/page-header-breadcrumb-retirement.test.ts", "src/ui/page-header-show-star-retirement.test.ts", + "src/ui/page-slots-tabs-retirement.test.ts", "src/ui/view-data-api-provider-retirement.test.ts", "src/ui/view-item-owner-hidden-retirement.test.ts", "src/ui/view-list-filterable-fields-retirement.test.ts", diff --git a/ui/content/docs/guide/slotted-pages.md b/ui/content/docs/guide/slotted-pages.md index e88930d4d82..3f164335d1e 100644 --- a/ui/content/docs/guide/slotted-pages.md +++ b/ui/content/docs/guide/slotted-pages.md @@ -37,7 +37,7 @@ Objects with `enable.files: true` also get a synthesized **Attachments** tab slot of its own — pass `hideAttachments: true` to the synthesizer to drop it. -There is no `tabs` slot. The tab strip is composed from the object's roles +There is no `tabs` slot. The tab strip is composed from the object's fields and relationship flags: to change which related lists appear or their order, set `relatedList` / `relatedList: 'primary'` on the child's reference field; to replace the Details body, author `details`; a page that needs its own strip From 3b2e4329ba1c82be6d7f60e361cb1ffb1c5f1456 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:22:18 +0000 Subject: [PATCH 10/11] test: pin the full platform pages' header action ids to the object; section-coverage fixture is a full page Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzff3QiP6kmtajVnKnq --- .../cli/test/i18n-section-coverage.test.ts | 60 ++++++++++--------- .../pages/record-page-header-actions.test.ts | 55 +++++++++++++++++ 2 files changed, 87 insertions(+), 28 deletions(-) create mode 100644 packages/platform-objects/src/pages/record-page-header-actions.test.ts diff --git a/packages/cli/test/i18n-section-coverage.test.ts b/packages/cli/test/i18n-section-coverage.test.ts index 5d1c3903f36..b2402d48a0e 100644 --- a/packages/cli/test/i18n-section-coverage.test.ts +++ b/packages/cli/test/i18n-section-coverage.test.ts @@ -191,53 +191,57 @@ describe('authored form-view sections', () => { describe('authored record-page sections', () => { /** - * The showcase `showcase_project_detail` shape: a slotted record page whose - * `record:details` lives two levels down inside `page:tabs` + * The showcase `showcase_project_detail` shape: a full record page whose + * `record:details` lives two levels down inside its own `page:tabs` * (`properties.items[].children[]`). This is the nesting the issue calls * out — a walk that only looked at `regions[].components[].properties` * would report nothing here. */ - const slottedPage = { + const tabbedPage = { name: 'crm_opportunity_detail', type: 'record', object: 'crm_opportunity', - kind: 'slotted', - regions: [], - slots: { - details: { - type: 'page:tabs', - properties: { - items: [ - { - key: 'details', - label: 'Details', - children: [ + kind: 'full', + regions: [ + { + name: 'main', + components: [ + { + type: 'page:tabs', + properties: { + items: [ { - type: 'record:details', - properties: { - sections: [ - { name: 'overview', label: 'Overview', fields: ['name'] }, - { name: 'timeline', label: 'Timeline', fields: ['close_date'] }, - ], - }, + key: 'details', + label: 'Details', + children: [ + { + type: 'record:details', + properties: { + sections: [ + { name: 'overview', label: 'Overview', fields: ['name'] }, + { name: 'timeline', label: 'Timeline', fields: ['close_date'] }, + ], + }, + }, + ], }, ], }, - ], - }, + }, + ], }, - }, + ], }; it('reaches sections nested under `page:tabs → children → record:details`', () => { - expect(sectionKeys({ pages: [slottedPage] }).sort()).toEqual([ + expect(sectionKeys({ pages: [tabbedPage] }).sort()).toEqual([ 'objects.crm_opportunity._sections.overview.label', 'objects.crm_opportunity._sections.timeline.label', ]); }); it('reads `label` as the source text', () => { - const timeline = sectionEntries({ pages: [slottedPage] }).find((e) => e.path[3] === 'timeline'); + const timeline = sectionEntries({ pages: [tabbedPage] }).find((e) => e.path[3] === 'timeline'); expect(timeline?.inline).toBe('Timeline'); }); @@ -249,8 +253,8 @@ describe('authored record-page sections', () => { // off-spec key is how the second spelling would become self-documenting. // Asserting the key survives is what keeps this non-vacuous: the entry is // present and its `inline` is empty, not absent because nothing was walked. - const titled = JSON.parse(JSON.stringify(slottedPage)); - const sections = titled.slots.details.properties.items[0].children[0].properties.sections; + const titled = JSON.parse(JSON.stringify(tabbedPage)); + const sections = titled.regions[0].components[0].properties.items[0].children[0].properties.sections; sections[1] = { name: 'timeline', title: 'Timeline', fields: ['close_date'] }; const entries = sectionEntries({ pages: [titled] }); diff --git a/packages/platform-objects/src/pages/record-page-header-actions.test.ts b/packages/platform-objects/src/pages/record-page-header-actions.test.ts new file mode 100644 index 00000000000..cdf5f629066 --- /dev/null +++ b/packages/platform-objects/src/pages/record-page-header-actions.test.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #27053 — the two platform record pages name the header actions the + * synthesizer used to read live from the object. + * + * Both pages were `kind: 'slotted'` until the `tabs` slot retired; the + * synthesized header they rendered carried every object action declared at + * `record_header` or `record_more`, in declaration order. As `kind: 'full'` + * pages they name those action ids themselves, so the list is a copy. This + * pin holds the copy equal to the object's declarations, so an action added + * to either object at a header location cannot silently miss its page. + * + * Read from the shipped page and object metadata. + */ + +import { describe, expect, it } from 'vitest'; +import { SysOrganization } from '../identity/sys-organization.object.js'; +import { SysUser } from '../identity/sys-user.object.js'; +import { SysOrganizationDetailPage } from './sys-organization.page.js'; +import { SysUserDetailPage } from './sys-user.page.js'; + +type Rec = Record; + +/** The ids the synthesized header drew: header-located actions, declaration order, first of a name. */ +function headerLocatedIds(object: { actions?: unknown }): string[] { + const ids: string[] = []; + for (const action of (object.actions ?? []) as Rec[]) { + const locations = (action.locations ?? []) as string[]; + const name = action.name as string; + if (!locations.includes('record_header') && !locations.includes('record_more')) continue; + if (!ids.includes(name)) ids.push(name); + } + return ids; +} + +/** The `page:header` node's authored action ids. */ +function pageHeaderIds(page: { regions?: unknown }): unknown { + const components = ((page.regions as Rec[])[0]!.components ?? []) as Rec[]; + const header = components.find((c) => c.type === 'page:header'); + return (header?.properties as Rec | undefined)?.actions; +} + +describe.each([ + ['sys_organization_detail', SysOrganizationDetailPage, SysOrganization], + ['sys_user_detail', SysUserDetailPage, SysUser], +] as const)('%s — the header names the object\'s header-located actions', (_name, page, object) => { + it('is non-vacuous: the object declares header-located actions', () => { + expect(headerLocatedIds(object).length).toBeGreaterThan(0); + }); + + it('names exactly those ids, in declaration order', () => { + expect(pageHeaderIds(page)).toEqual(headerLocatedIds(object)); + }); +}); From 9573871084ddd8e1b72193a39ee6529df0e64af9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:31:31 +0000 Subject: [PATCH 11/11] Join page-slots-tabs-retired to its conversion; move the skills bullet The D3 entry judges the strip that page-slots-tabs-removed applies, so it names that conversion in conversionIds. The skills bullet about the retired tabs slot moves from the source-pages section to Templates & Regions, beside the kind: 'full' rules it qualifies. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzff3QiP6kmtajVnKnq --- .../entries/semantic/18.page-slots-tabs-retired.ts | 1 + skills/objectstack-ui/rules/pages.md | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/18.page-slots-tabs-retired.ts b/packages/spec/src/migrations/entries/semantic/18.page-slots-tabs-retired.ts index 7388a5a9ddb..5441380c2fa 100644 --- a/packages/spec/src/migrations/entries/semantic/18.page-slots-tabs-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.page-slots-tabs-retired.ts @@ -48,4 +48,5 @@ export const entry: SemanticMigration = { + 'nothing at `pages.N.slots.tabs`, saving each formerly affected page through the metadata API ' + 'succeeds, and each record page shows either the synthesized strip (and the columns at wider ' + 'widths) or, for a `kind: \'full\'` page, the same tabs it showed before.', + conversionIds: ['page-slots-tabs-removed'], }; diff --git a/skills/objectstack-ui/rules/pages.md b/skills/objectstack-ui/rules/pages.md index 61c0c5c9ef5..82e48a3347a 100644 --- a/skills/objectstack-ui/rules/pages.md +++ b/skills/objectstack-ui/rules/pages.md @@ -37,6 +37,8 @@ five) or `dashboard` (`header`, `footer`; other regions are grid cells); `full-width` is retired (a second name for `default`). On a `kind: 'full'` page a region the template does not place, or a second one for a filled slot, is refused at its `name` — the left column is `sidebar`, the right `aside`. +A `slotted` record page has no `tabs` slot: shape the strip with `relatedList` / +`relatedList: 'primary'` on the child's reference field, or author `kind: 'full'`. ### Component Catalogue (selection) @@ -135,9 +137,6 @@ by what the page needs: `kind:'html'`/`'react'` page with no `source` fails the build (ADR-0078). `kind:'jsx'` is retired: the parse refuses it and names `kind:'html'`. -- A `slotted` record page has no `tabs` slot: shape the strip with `relatedList` / - `relatedList: 'primary'` on the child's reference field, or author `kind: 'full'`. - #### `kind:'html'` — constrained JSX, parsed (safe by construction) Tags are the **registered components** (bare names: ``, ``, ``,