diff --git a/.changeset/related-list-primary-tabs.md b/.changeset/related-list-primary-tabs.md new file mode 100644 index 000000000..1a42f120f --- /dev/null +++ b/.changeset/related-list-primary-tabs.md @@ -0,0 +1,28 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/plugin-detail': minor +'@object-ui/fields': minor +--- + +Detail-page related lists: `relatedList: 'primary'` → own tab, multi-FK & self-referential related lists, unified picker columns (framework #2579). + +- **plugin-detail** (`buildDefaultTabs`): the default related-list layout is now + the ADR-0085 prominence rule — lists whose FK declares `relatedList: 'primary'` + each get their OWN tab; every other related list collapses into a single + "Related" tab. With no primary lists this is byte-for-byte the previous stacked + default, so it is opt-in per relationship. `relatedLayout: 'tabs' | 'stack'` + remain app-level overrides (force all-own-tabs / all-stacked). +- **app-shell** (`deriveRelatedLists`): emits one related list per eligible FK — + a child referencing the parent through several relationships (e.g. + `primary_account` + `partner_account`) now surfaces each, disambiguated by the + FK label; includes self-referential relationships (hierarchies → a "child" + list); and carries the `isPrimary` prominence flag through. `RecordDetailView` + threads `isPrimary` into the synthesized page. +- **fields** (`deriveLookupColumns`): the lookup-picker default columns now + prefer the object's ADR-0085 `highlightFields` (then legacy `displayFields`, + then the field walk) — the same "how to list this object" source the related + list uses, so a picker and a related list of the same object agree with zero + per-surface config. + +Pairs with the `@objectstack/spec` change that makes `relatedList` a tri-state +(`boolean | 'primary'`) and `record:related_list` `columns` optional. diff --git a/packages/app-shell/src/utils/__tests__/deriveRelatedLists.test.ts b/packages/app-shell/src/utils/__tests__/deriveRelatedLists.test.ts index 940962e6d..bcafb238a 100644 --- a/packages/app-shell/src/utils/__tests__/deriveRelatedLists.test.ts +++ b/packages/app-shell/src/utils/__tests__/deriveRelatedLists.test.ts @@ -23,6 +23,7 @@ describe('deriveRelatedLists', () => { childLabel: 'Task', referenceField: 'project', isOwned: true, + isPrimary: false, }, ]); }); @@ -34,7 +35,12 @@ describe('deriveRelatedLists', () => { fields: { project_id: { type: 'lookup', reference: 'project' } }, }; const out = deriveRelatedLists(project, [project, note]); - expect(out[0]).toMatchObject({ childObject: 'note', referenceField: 'project_id', isOwned: false }); + expect(out[0]).toMatchObject({ + childObject: 'note', + referenceField: 'project_id', + isOwned: false, + isPrimary: false, + }); }); it('supports reference_to as well as reference', () => { @@ -94,20 +100,54 @@ describe('deriveRelatedLists', () => { }); }); - it('dedupes to one related list per child object (first eligible FK wins)', () => { - const assignment = { - name: 'assignment', + it('flags relatedList: "primary" as isPrimary (prominence → own tab)', () => { + const task = { + name: 'task', + label: 'Task', + fields: { project: { type: 'master_detail', reference: 'project', relatedList: 'primary' } }, + }; + const out = deriveRelatedLists(project, [project, task]); + expect(out[0]).toMatchObject({ childObject: 'task', isPrimary: true, isOwned: true }); + }); + + it('emits one related list per eligible FK when a child references the parent multiple times', () => { + const opportunity = { + name: 'opportunity', + label: 'Opportunity', fields: { - project: { type: 'master_detail', reference: 'project' }, - secondary_project: { type: 'lookup', reference: 'project' }, + primary_project: { type: 'lookup', reference: 'project', label: 'Primary Project' }, + partner_project: { type: 'lookup', reference: 'project', label: 'Partner Project' }, }, }; - const out = deriveRelatedLists(project, [project, assignment]); - expect(out).toHaveLength(1); - expect(out[0].referenceField).toBe('project'); + const out = deriveRelatedLists(project, [project, opportunity]); + expect(out).toHaveLength(2); + expect(out.map((r) => r.referenceField).sort()).toEqual(['partner_project', 'primary_project']); + // No explicit relatedListTitle → the FK label disambiguates the two lists. + expect(out.map((r) => r.title).sort()).toEqual([ + 'Opportunity · Partner Project', + 'Opportunity · Primary Project', + ]); + }); + + it('keeps an explicit relatedListTitle over the multi-FK disambiguation suffix', () => { + const opportunity = { + name: 'opportunity', + label: 'Opportunity', + fields: { + primary_project: { + type: 'lookup', reference: 'project', label: 'Primary Project', + relatedListTitle: 'Primary Opps', + }, + partner_project: { type: 'lookup', reference: 'project', label: 'Partner Project' }, + }, + }; + const out = deriveRelatedLists(project, [project, opportunity]); + const byField = Object.fromEntries(out.map((r) => [r.referenceField, r.title])); + expect(byField.primary_project).toBe('Primary Opps'); + expect(byField.partner_project).toBe('Opportunity · Partner Project'); }); - it('skips a suppressed FK but still considers a sibling FK on the same child', () => { + it('skips a suppressed FK but keeps a sibling FK on the same child', () => { const assignment = { name: 'assignment', fields: { @@ -120,10 +160,27 @@ describe('deriveRelatedLists', () => { expect(out[0].referenceField).toBe('project'); }); - it('ignores unrelated objects and self-references', () => { + it('includes a self-referential relationship (hierarchy → "child" list)', () => { + const selfRef = { + name: 'project', + label: 'Project', + fields: { + name: { type: 'text' }, + parent_project: { type: 'lookup', reference: 'project', label: 'Parent Project' }, + }, + }; + const out = deriveRelatedLists(selfRef, [selfRef]); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ + childObject: 'project', + referenceField: 'parent_project', + isOwned: false, + }); + }); + + it('ignores unrelated objects', () => { const other = { name: 'other', fields: { foo: { type: 'lookup', reference: 'somethingelse' } } }; - const selfRef = { name: 'project', fields: { parent: { type: 'tree', reference: 'project' } } }; - expect(deriveRelatedLists(project, [project, other, selfRef])).toEqual([]); + expect(deriveRelatedLists(project, [project, other])).toEqual([]); }); it('handles array-shaped fields', () => { diff --git a/packages/app-shell/src/utils/deriveRelatedLists.ts b/packages/app-shell/src/utils/deriveRelatedLists.ts index f8dd81ce7..cdb4cd749 100644 --- a/packages/app-shell/src/utils/deriveRelatedLists.ts +++ b/packages/app-shell/src/utils/deriveRelatedLists.ts @@ -9,20 +9,29 @@ * * This helper scans every object for fields whose `reference`/`reference_to` * points back at the parent object and produces one related-list descriptor per - * child collection. The detail page (`RecordDetailView`) feeds these into the + * eligible FK. The detail page (`RecordDetailView`) feeds these into the * `record:related_list` renderers (and the legacy `DetailView.related`). * * Rules (kept in lockstep with the relationship-level `relatedList` spec flag): * - Owned children (`master_detail`) and `lookup` children are SHOWN by * default. Set `relatedList: false` on the FK field to suppress a noisy - * association/audit link. + * association/audit link; set `relatedList: 'primary'` to mark a CORE + * relationship — the detail page promotes it to its own tab (the tab-vs- + * Related split is decided downstream in `buildDefaultTabs`). * - `relatedListTitle` / `relatedListColumns` on the FK field override the - * derived title / columns. + * derived title / columns (columns default to the child object's own list + * columns when omitted — resolved by the renderer). * - Audit FKs (`created_by` / `updated_by` / `owner_id`) are skipped — they * exist on virtually every object and would balloon the detail page into * dozens of duplicate cards. - * - One related list per child object (deduped by child object name): the - * first non-audit, non-suppressed FK wins. + * - ONE related list per eligible FK. A child may point at the parent through + * MORE THAN ONE relationship (e.g. `opportunity.primary_account` + + * `opportunity.partner_account`); each surfaces as its own list. When a + * child appears more than once and gave no explicit `relatedListTitle`, the + * FK's label is suffixed to disambiguate ("Opportunity · Partner Account"). + * - Self-references are allowed (e.g. `account.parent_account` → `account`): + * the parent record lists the records whose self-FK points back at it + * ("Child Accounts"). Suppress with `relatedList: false` if unwanted. * - Owned (`master_detail`) children are ordered before plain `lookup` * children, preserving discovery order within each group. */ @@ -43,6 +52,12 @@ export interface DerivedRelatedList { columns?: any[]; /** True when the child→parent link is a `master_detail` (owned) relationship. */ isOwned: boolean; + /** + * True when the FK declares `relatedList: 'primary'`. A prominence hint + * (ADR-0085): the detail page promotes this relationship to its OWN tab, + * while non-primary lists collapse into a single "Related" tab. + */ + isPrimary: boolean; } interface ObjectLike { @@ -73,12 +88,15 @@ export function deriveRelatedLists( ): DerivedRelatedList[] { if (!objectDef?.name || !Array.isArray(objects) || objects.length === 0) return []; const parentName = objectDef.name; - const owned: DerivedRelatedList[] = []; - const referenced: DerivedRelatedList[] = []; - const seenChild = new Set(); + + // Working entries carry the FK label so we can disambiguate multi-FK children + // after the full sweep; it is stripped from the returned descriptors. + type Working = DerivedRelatedList & { _fkLabel: string }; + const owned: Working[] = []; + const referenced: Working[] = []; for (const child of objects) { - if (!child?.name || child.name === parentName) continue; + if (!child?.name) continue; for (const [fieldName, fieldDef] of fieldEntries(child.fields)) { if (!fieldDef) continue; const type = fieldDef.type; @@ -87,15 +105,14 @@ export function deriveRelatedLists( if (AUDIT_FK_FIELDS.has(fieldName)) continue; // Explicit opt-out lives on the relationship. if (fieldDef.relatedList === false) continue; - // One related list per child object — the first eligible FK wins. - if (seenChild.has(child.name)) continue; - seenChild.add(child.name); - const entry: DerivedRelatedList = { + const entry: Working = { childObject: child.name, childLabel: child.label || child.name, referenceField: fieldName, isOwned: type === 'master_detail', + isPrimary: fieldDef.relatedList === 'primary', + _fkLabel: (typeof fieldDef.label === 'string' && fieldDef.label) || fieldName, ...(typeof fieldDef.relatedListTitle === 'string' && fieldDef.relatedListTitle ? { title: fieldDef.relatedListTitle } : {}), @@ -103,10 +120,22 @@ export function deriveRelatedLists( ? { columns: fieldDef.relatedListColumns } : {}), }; + // NO `break`: a child object may reference this parent through several FKs. (entry.isOwned ? owned : referenced).push(entry); - break; // move on to the next child object } } - return [...owned, ...referenced]; + const all = [...owned, ...referenced]; + // Multi-FK disambiguation: when a child object points here through more than + // one relationship and gave no explicit title, suffix the FK label so the two + // lists are distinguishable (e.g. "Opportunity · Partner Account"). + const counts: Record = {}; + for (const r of all) counts[r.childObject] = (counts[r.childObject] || 0) + 1; + + return all.map(({ _fkLabel, ...rest }) => { + if (!rest.title && counts[rest.childObject] > 1) { + return { ...rest, title: `${rest.childLabel} · ${_fkLabel}` }; + } + return rest; + }); } diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index cf601d7e3..5250176ee 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -1714,14 +1714,23 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri const synthRelated = Array.isArray((detailSchema as any).related) ? ((detailSchema as any).related as any[]) .filter((r) => r?.api && r?.referenceField) - .map((r) => ({ - title: r.title, - objectName: r.api, - relationshipField: r.referenceField, - ...(Array.isArray(r.columns) ? { columns: r.columns } : {}), - ...(typeof r.pageSize === 'number' ? { limit: r.pageSize } : {}), - ...(r.icon ? { icon: r.icon } : {}), - })) + .map((r) => { + // Carry the `relatedList: 'primary'` prominence flag from the derived + // relationship graph. Matched by (childObject, referenceField) — the + // unique key of a related list — so it is robust to ordering/filtering. + const derived = childRelations.find( + (c) => c.childObject === r.api && c.referenceField === r.referenceField, + ); + return { + title: r.title, + objectName: r.api, + relationshipField: r.referenceField, + ...(Array.isArray(r.columns) ? { columns: r.columns } : {}), + ...(typeof r.pageSize === 'number' ? { limit: r.pageSize } : {}), + ...(r.icon ? { icon: r.icon } : {}), + ...(derived?.isPrimary ? { isPrimary: true } : {}), + }; + }) : undefined; const synthHistory = (detailSchema as any).history ? { diff --git a/packages/fields/src/widgets/deriveLookupColumns.test.ts b/packages/fields/src/widgets/deriveLookupColumns.test.ts index eb2b72490..c53868f65 100644 --- a/packages/fields/src/widgets/deriveLookupColumns.test.ts +++ b/packages/fields/src/widgets/deriveLookupColumns.test.ts @@ -71,4 +71,29 @@ describe('deriveLookupColumns', () => { const cols = deriveLookupColumns(schema, { displayField: 'name' }); expect(cols.map((c) => c.field)).toEqual(['name', 'region']); }); + + it('prefers ADR-0085 highlightFields over the declaration-order walk', () => { + const schema = { + fields: { + name: { type: 'text', label: 'Name' }, + code: { type: 'text', label: 'Code' }, + region: { type: 'select', label: 'Region' }, + notes: { type: 'text', label: 'Notes' }, + }, + highlightFields: ['region', 'code'], + }; + const cols = deriveLookupColumns(schema, { displayField: 'name', max: 4 }); + // Display field leads, then highlightFields in order — not the raw field walk. + expect(cols.map((c) => c.field)).toEqual(['name', 'region', 'code']); + }); + + it('lets highlightFields win over legacy displayFields when both are present', () => { + const schema = { + fields: { name: { type: 'text' }, code: { type: 'text' }, region: { type: 'select' } }, + highlightFields: ['region'], + displayFields: ['code'], + }; + const cols = deriveLookupColumns(schema, { displayField: 'name' }); + expect(cols.map((c) => c.field)).toEqual(['name', 'region']); + }); }); diff --git a/packages/fields/src/widgets/deriveLookupColumns.ts b/packages/fields/src/widgets/deriveLookupColumns.ts index 90d54a383..bc6052ff6 100644 --- a/packages/fields/src/widgets/deriveLookupColumns.ts +++ b/packages/fields/src/widgets/deriveLookupColumns.ts @@ -32,7 +32,14 @@ export interface DeriveColumnsOptions { interface ObjectSchemaLike { fields?: Record; - /** Object-level "fields to display in search result cards" (spec metadata). */ + /** + * ADR-0085 semantic role: the object's most important fields. The single + * source for "how to list this object" — shared with the detail-page related + * list — so a lookup picker and a related list of the same object agree on + * columns with zero per-surface config. + */ + highlightFields?: unknown; + /** Object-level "fields to display in search result cards" (legacy spec metadata). */ displayFields?: unknown; } @@ -53,9 +60,10 @@ function dedupe(names: string[]): string[] { * a lookup field declares no explicit `lookup_columns`. * * Priority: - * 1. The referenced object's own `displayFields` ("fields to display in - * search result cards") — the author's declared search-card shape. - * 2. Otherwise the display field plus the next few business fields in + * 1. The referenced object's ADR-0085 `highlightFields` — the canonical + * "how to list this object" set, shared with the detail-page related list. + * 2. Else the object's legacy `displayFields` search-card shape. + * 3. Otherwise the display field plus the next few business fields in * declaration order, skipping system/audit fields and heavy non-tabular * types. * @@ -79,17 +87,23 @@ export function deriveLookupColumns( if (Object.keys(fields).length === 0) return []; - // 1. Honour the object's explicit search-card fields when present. - const declared = objectSchema?.displayFields; - if (Array.isArray(declared) && declared.length > 0) { - const names = (declared as unknown[]).filter( + // 1 & 2. Honour a declared column list — `highlightFields` (ADR-0085 + // canonical, shared with the related list) first, then the legacy + // `displayFields` search-card list. The display field always leads. + const fromDeclaredList = (list: unknown): LookupColumnDef[] | null => { + if (!Array.isArray(list) || list.length === 0) return null; + const names = (list as unknown[]).filter( (n): n is string => typeof n === 'string' && n.length > 0, ); const ordered = dedupe([displayField, ...names.filter((n) => n !== displayField)]); return ordered.slice(0, max).map(toCol); - } + }; + const fromHighlights = fromDeclaredList(objectSchema?.highlightFields); + if (fromHighlights) return fromHighlights; + const fromDisplay = fromDeclaredList(objectSchema?.displayFields); + if (fromDisplay) return fromDisplay; - // 2. Derive from the field set in declaration order. + // 3. Derive from the field set in declaration order. const candidates = Object.keys(fields).filter((name) => { if (name === displayField) return false; if (SYSTEM_FIELDS.has(name)) return false; diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx index 294fb5164..3113a4b4e 100644 --- a/packages/plugin-detail/src/RelatedList.tsx +++ b/packages/plugin-detail/src/RelatedList.tsx @@ -527,6 +527,23 @@ export const RelatedList: React.FC = ({ } if (!objectSchema?.fields) return []; + // ADR-0085 prominence: when the child object declares `highlightFields` — + // the canonical "how to list this object" set, the SAME source the lookup + // picker (`deriveLookupColumns`) leads with — auto-derive from those before + // the heuristic field walk below, so a related list and a picker of the + // same object agree on columns with zero per-surface config. Run through the + // identical normalize / FK / FLS / empty pruning as explicit columns; fall + // through to the walk if nothing survives (e.g. all FLS-blocked). + const declaredHighlights = Array.isArray((objectSchema as any).highlightFields) + ? ((objectSchema as any).highlightFields as any[]).filter( + (n): n is string => typeof n === 'string' && n.length > 0, + ) + : []; + if (declaredHighlights.length > 0) { + const hf = pruneEmpty(filterFLS(filterFK(declaredHighlights.map(normalizeColumn)))); + if (hf.length > 0) return hf.slice(0, Math.max(1, maxColumns)); + } + const resolvedObjectName = relatedObjectName; const SKIP_TYPES = new Set(['image', 'file', 'attachment', 'rich_text', 'html', 'json']); const PRIORITY_NAMES = [ diff --git a/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts b/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts index 894389b90..df9a15861 100644 --- a/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts +++ b/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts @@ -316,6 +316,59 @@ describe('buildDefaultPageSchema', () => { expect(tabs.items[1].children[0].limit).toBe(10); }); + it('promotes an isPrimary related list to its OWN tab (rule Z default)', () => { + const tabs = buildDefaultTabs(leadDef, { + related: [ + { objectName: 'opportunity', relationshipField: 'lead_id', title: 'Opportunities', isPrimary: true }, + ], + }); + const labels = tabs.items.map((t: any) => t.label); + expect(labels).toEqual(['Details', 'Opportunities']); + expect(labels).not.toContain('Related'); + }); + + it('gives primary lists their own tab and collapses the rest into Related', () => { + const tabs = buildDefaultTabs(leadDef, { + related: [ + { objectName: 'opportunity', relationshipField: 'lead_id', title: 'Opportunities', isPrimary: true }, + { objectName: 'task', relationshipField: 'lead_id', title: 'Tasks' }, + { objectName: 'note', relationshipField: 'parent_id', title: 'Notes' }, + ], + }); + const labels = tabs.items.map((t: any) => t.label); + expect(labels).toEqual(['Details', 'Opportunities', 'Related']); + const related = tabs.items.find((t: any) => t.label === 'Related'); + expect(related.children).toHaveLength(2); + expect(related.children.map((c: any) => c.objectName)).toEqual(['task', 'note']); + }); + + it("relatedLayout:'tabs' gives every related list its own tab, ignoring isPrimary", () => { + const tabs = buildDefaultTabs(leadDef, { + relatedLayout: 'tabs', + related: [ + { objectName: 'task', relationshipField: 'lead_id', title: 'Tasks' }, + { objectName: 'note', relationshipField: 'parent_id', title: 'Notes' }, + ], + }); + const labels = tabs.items.map((t: any) => t.label); + expect(labels).toEqual(['Details', 'Tasks', 'Notes']); + expect(labels).not.toContain('Related'); + }); + + it("relatedLayout:'stack' collapses all lists into one Related tab, ignoring isPrimary", () => { + const tabs = buildDefaultTabs(leadDef, { + relatedLayout: 'stack', + related: [ + { objectName: 'opportunity', relationshipField: 'lead_id', title: 'Opportunities', isPrimary: true }, + { objectName: 'task', relationshipField: 'lead_id', title: 'Tasks' }, + ], + }); + const labels = tabs.items.map((t: any) => t.label); + expect(labels).toEqual(['Details', 'Related']); + const related = tabs.items.find((t: any) => t.label === 'Related'); + expect(related.children).toHaveLength(2); + }); + it('does NOT emit a Reference Rail by default, keeping the Related tab', () => { const page = buildDefaultPageSchema(leadDef, { related: [ diff --git a/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts b/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts index ca11f78fd..40d48e51a 100644 --- a/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts +++ b/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts @@ -136,20 +136,31 @@ export interface BuildPageOptions { columns?: any[]; limit?: number; icon?: string; + /** + * `relatedList: 'primary'` — a CORE relationship. Under the default + * layout this list is promoted to its OWN tab; non-primary lists collapse + * into a single "Related" tab. Ignored when `relatedLayout` forces a + * uniform layout. + */ + isPrimary?: boolean; }>; /** * How the related child lists are laid out under the tab strip. * - * - `'stack'` (default) — all related lists stack vertically inside a - * single `Related` tab. Preserves the legacy behavior. - * - `'tabs'` — each related child gets its OWN tab (label = the child's - * `title`, falling back to `objectName`) instead of sharing one - * `Related` tab. Lets authors surface related tables as peer tabs - * purely via config (per object: `detail.relatedLayout`). + * - **default (unset)** — the ADR-0085 prominence rule: lists flagged + * `relatedList: 'primary'` (`isPrimary`) each get their OWN tab; every + * other related list collapses into a single stacked `Related` tab. With + * no primary lists this is identical to the legacy stacked behavior, so + * the change is opt-in per relationship — never a surprise. + * - `'stack'` — app-level override: ALL related lists stack vertically + * inside one `Related` tab, ignoring `isPrimary`. + * - `'tabs'` — app-level override: EVERY related child gets its own peer + * tab (label = the child's `title`, falling back to `objectName`), + * ignoring `isPrimary`. * - * Ignored when the `Related` tab is suppressed (`hideRelatedTab`). - * - * @default 'stack' + * Precise per-tab ordering / filtered splits / non-relationship tabs are a + * custom Page concern (Tier 2) — this synthesizer only covers the derived + * default. Ignored when the `Related` tab is suppressed (`hideRelatedTab`). */ relatedLayout?: 'stack' | 'tabs'; /** @@ -512,21 +523,29 @@ export function buildDefaultTabs( ...(rel.limit ? { limit: rel.limit } : {}), ...(rel.icon ? { icon: rel.icon } : {}), }); + const asOwnTab = (rel: NonNullable[number]) => ({ + label: rel.title || rel.objectName, + ...(rel.icon ? { icon: rel.icon } : {}), + children: [relatedNode(rel)], + }); if (options.relatedLayout === 'tabs') { - // One peer tab per related child, instead of a single shared - // `Related` tab that stacks them all vertically. - for (const rel of options.related) { - items.push({ - label: rel.title || rel.objectName, - ...(rel.icon ? { icon: rel.icon } : {}), - children: [relatedNode(rel)], - }); - } + // App-level override: one peer tab per related child. + for (const rel of options.related) items.push(asOwnTab(rel)); + } else if (options.relatedLayout === 'stack') { + // App-level override: all related lists share one stacked `Related` tab. + items.push({ label: 'Related', children: options.related.map(relatedNode) }); } else { - items.push({ - label: 'Related', - children: options.related.map(relatedNode), - }); + // DEFAULT (rule Z, ADR-0085 prominence): `isPrimary` lists become their + // own tab; the rest collapse into one `Related` tab. Owned-before-lookup + // order is preserved by deriveRelatedLists upstream. With no primary + // lists this is identical to the legacy stacked default (opt-in per + // relationship, never a surprise). + const primary = options.related.filter((r) => r.isPrimary); + const rest = options.related.filter((r) => !r.isPrimary); + for (const rel of primary) items.push(asOwnTab(rel)); + if (rest.length > 0) { + items.push({ label: 'Related', children: rest.map(relatedNode) }); + } } } if (options.showActivity) {