diff --git a/MIGRATIONS.md b/MIGRATIONS.md index 3b591e199..4de5354b4 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -4,6 +4,116 @@ Breaking changes and upgrade notes for downstream projects. --- +## PageHeader split + tabs spacing/alignment refactor (2026-05-21, v2) + +**Non-breaking for default consumers** — affects 3 stack layouts (Account, Organization detail, Admin) which were already refactored upstream. Downstream projects only need to act if they wrote a custom layout using `PageHeader` + `CoreSurfaceTabBar` directly, or relied on the (now removed) `#tabs` slot on `PageHeader`. + +This entry supersedes the spacing/structure parts of the previous "Account / Organization / Admin chrome convergence" entry below; the four conventions still apply for child views, dialogs, etc. + +### What changed + +1. **`PageHeader` is now content-sized** — the canonical `min-height: 56px / max-height: 56px` is gone from `core.pageHeader.component.vue`. Pages without tabs (list views like Tasks, Scraps) no longer pay the chrome tax. +2. **`PageHeader`'s `#tabs` slot is removed** — it was dead code since #4187 (tabs are siblings of the header, not inside it). +3. **`CoreSurfaceTabBar` aligns tabs horizontally with the title** — `` is now applied internally, so the tab strip sits at the same x-offset as `PageHeader`'s icon+title. +4. **New primitive `CorePageHeaderTabs`** (`src/modules/core/components/core.pageHeaderTabs.component.vue`) bundles `PageHeader` + `CoreSurfaceTabBar` and enforces the 56px title↔breadcrumb rhythm via a scoped `:deep()` rule — used wherever a section needs both a header and a tab bar. +5. **Tabbed layouts use ``** to collapse the dead gap between the tab underline and the routed child's top edge. + +### The new convention + +Pages **with tabs** (Account, Org detail, Admin) — single primitive: + +```vue + +``` + +Pages **without tabs** (Tasks, Scraps, simple list views) — content-sized `PageHeader`, unchanged shape: + +```vue + +``` + +### Breadcrumb mode + +The pattern from the previous migration entry still holds. Now expressed via `CorePageHeaderTabs`: + +```vue + + + +``` + +Set `:hide-tabs="true"` when in breadcrumb mode (user has drilled into a sub-record) so the tab bar disappears; the 56px height stays consistent across the title↔breadcrumb route transition. + +### Empty-state regression to watch for + +If a list view renders both a populated row AND an empty-state row in the same template, **guard the populated row with `v-if`** so it doesn't take vertical space when the list is empty. Example fixed in this batch (`tasks.view.vue`): + +```vue + + + + + + +``` + +Without the guard, the empty populated row still consumes ~16-24px (pa-2 + default v-row margins) and pushes the empty-state card down. + +### Migration for downstream projects + +Most downstream projects don't write custom layouts using `PageHeader` + `CoreSurfaceTabBar` — they consume the layouts via the stack. `/update-stack` will pick up the new convention automatically. + +**If your project has a custom tabbed layout** (rare): + +1. Replace `` + `` siblings with a single `` (import from `src/modules/core/components/core.pageHeaderTabs.component.vue`). +2. Add `class="pb-0"` to the wrapping `` so the gap below tabs collapses. +3. Drop any local CSS that compensated for the 56px PageHeader height — the new primitive handles it. + +**If your project uses `PageHeader` with the `#tabs` slot** (very rare): + +- Remove the slot usage. Render tabs as a sibling of `PageHeader` (or use `CorePageHeaderTabs`). + +### Tests touched + +Stack tests updated to find `CorePageHeaderTabs` by name instead of looking for `PageHeader` + `CoreSurfaceTabBar` separately. Downstream test files that follow the same pattern will need the same update; if a downstream stub-tests `PageHeader` directly, no change needed. + +--- + ## Account / Organization / Admin chrome convergence (2026-05-21) **Non-breaking for default consumers.** Builds on #4183 (`corePageTabs`), #4184 (org tabs), #4185 (Account chrome), and #4187 (Admin chrome + `coreConfirmDialog` + `coreAvatarUploader`). Locks in one chrome convention across all "section + tab bar + routed children" layouts. diff --git a/src/modules/admin/tests/admin.layout.unit.tests.js b/src/modules/admin/tests/admin.layout.unit.tests.js index 94f65a716..03eb53a3d 100644 --- a/src/modules/admin/tests/admin.layout.unit.tests.js +++ b/src/modules/admin/tests/admin.layout.unit.tests.js @@ -44,27 +44,21 @@ const mountLayout = (configOverrides = {}, routePath = '/admin/users') => stubs: { RouterLink: true, RouterView: { template: '
' }, - PageHeader: { - name: 'PageHeader', - props: ['title', 'icon'], + // Single stub for the new bundled header-with-tabs primitive — exposes + // all the props admin.layout passes through, plus the breadcrumb slot. + CorePageHeaderTabs: { + name: 'CorePageHeaderTabs', + props: ['title', 'icon', 'tabs', 'can', 'basePath', 'hideTabs'], template: ` -
+
-
`, }, - // Stub SurfaceTabBar so we can read the props it receives without rendering full v-tabs. - // name: 'CoreSurfaceTabBar' is required for findComponent({ name: ... }) to work. - CoreSurfaceTabBar: { - name: 'CoreSurfaceTabBar', - props: ['tabs', 'can', 'basePath'], - template: '
', - }, }, }, }); @@ -79,7 +73,7 @@ describe('admin.layout', () => { it('renders the page header', () => { const wrapper = mountLayout(); - expect(wrapper.find('.page-header-stub').exists()).toBe(true); + expect(wrapper.find('.page-header-tabs-stub').exists()).toBe(true); }); it('renders a for nested admin content', () => { @@ -87,16 +81,16 @@ describe('admin.layout', () => { expect(wrapper.find('.router-view-stub').exists()).toBe(true); }); - it('passes the four built-in tabs to CoreSurfaceTabBar when no extras are configured', () => { + it('passes the four built-in tabs to CorePageHeaderTabs when no extras are configured', () => { const wrapper = mountLayout(); - const bar = wrapper.findComponent({ name: 'CoreSurfaceTabBar' }); - expect(bar.exists()).toBe(true); - const tabs = bar.props('tabs'); + const headerTabs = wrapper.findComponent({ name: 'CorePageHeaderTabs' }); + expect(headerTabs.exists()).toBe(true); + const tabs = headerTabs.props('tabs'); expect(tabs).toHaveLength(4); expect(tabs.map((t) => t.value)).toEqual(['users', 'organizations', 'readiness', 'activity']); }); - it('passes built-in + extra tabs from config.admin.tabs to CoreSurfaceTabBar', () => { + it('passes built-in + extra tabs from config.admin.tabs to CorePageHeaderTabs', () => { const wrapper = mountLayout({ admin: { tabs: [ @@ -105,29 +99,29 @@ describe('admin.layout', () => { ], }, }); - const bar = wrapper.findComponent({ name: 'CoreSurfaceTabBar' }); - const tabs = bar.props('tabs'); + const headerTabs = wrapper.findComponent({ name: 'CorePageHeaderTabs' }); + const tabs = headerTabs.props('tabs'); expect(tabs).toHaveLength(6); expect(tabs[4].value).toBe('knowledge'); expect(tabs[5].value).toBe('costs'); }); - it('passes basePath="/admin" to CoreSurfaceTabBar', () => { + it('passes basePath="/admin" to CorePageHeaderTabs', () => { const wrapper = mountLayout(); - const bar = wrapper.findComponent({ name: 'CoreSurfaceTabBar' }); - expect(bar.props('basePath')).toBe('/admin'); + const headerTabs = wrapper.findComponent({ name: 'CorePageHeaderTabs' }); + expect(headerTabs.props('basePath')).toBe('/admin'); }); - it('passes a function `can` predicate to CoreSurfaceTabBar', () => { + it('passes a function `can` predicate to CorePageHeaderTabs', () => { const wrapper = mountLayout(); - const bar = wrapper.findComponent({ name: 'CoreSurfaceTabBar' }); - expect(typeof bar.props('can')).toBe('function'); + const headerTabs = wrapper.findComponent({ name: 'CorePageHeaderTabs' }); + expect(typeof headerTabs.props('can')).toBe('function'); }); - it('gracefully handles non-array admin.tabs (CoreSurfaceTabBar receives only the built-in 4)', () => { + it('gracefully handles non-array admin.tabs (CorePageHeaderTabs receives only the built-in 4)', () => { const wrapper = mountLayout({ admin: { tabs: 'invalid' } }); - const bar = wrapper.findComponent({ name: 'CoreSurfaceTabBar' }); - expect(bar.props('tabs')).toHaveLength(4); + const headerTabs = wrapper.findComponent({ name: 'CorePageHeaderTabs' }); + expect(headerTabs.props('tabs')).toHaveLength(4); }); it('renders the error banner at the TOP of the layout, before the header', async () => { @@ -135,7 +129,7 @@ describe('admin.layout', () => { const wrapper = mountLayout(); await wrapper.vm.$nextTick(); const html = wrapper.html(); - expect(html.indexOf('Boom')).toBeLessThan(html.indexOf('page-header-stub')); + expect(html.indexOf('Boom')).toBeLessThan(html.indexOf('page-header-tabs-stub')); }); it('renders the mailer warning at the TOP when serverConfig.mail.configured is false', async () => { @@ -143,7 +137,7 @@ describe('admin.layout', () => { const wrapper = mountLayout(); await wrapper.vm.$nextTick(); const html = wrapper.html(); - expect(html.indexOf('No mailer configured')).toBeLessThan(html.indexOf('page-header-stub')); + expect(html.indexOf('No mailer configured')).toBeLessThan(html.indexOf('page-header-tabs-stub')); }); it('does NOT render the mailer warning when mail is configured', () => { @@ -152,16 +146,6 @@ describe('admin.layout', () => { expect(wrapper.html()).not.toContain('No mailer configured'); }); - it('renders as a SIBLING of (not inside its #tabs slot)', () => { - const wrapper = mountLayout(); - const pageHeaderEl = wrapper.find('.page-header-stub'); - const surfaceTabBarEl = wrapper.find('.surface-tab-bar-stub'); - expect(pageHeaderEl.exists()).toBe(true); - expect(surfaceTabBarEl.exists()).toBe(true); - // Sibling layout: the tab-bar element is NOT inside the page-header-stub element. - expect(pageHeaderEl.element.contains(surfaceTabBarEl.element)).toBe(false); - }); - it('renders OUTSIDE the layout ', () => { const wrapper = mountLayout(); const layoutContainer = wrapper.find('.v-container'); @@ -171,32 +155,34 @@ describe('admin.layout', () => { expect(layoutContainer.element.contains(routerViewEl.element)).toBe(false); }); - it('PageHeader receives title="Admin" + icon="fa-solid fa-user-tie" in list mode', () => { + it('CorePageHeaderTabs receives title="Admin" + icon="fa-solid fa-user-tie" in list mode', () => { const wrapper = mountLayout(); - const ph = wrapper.findComponent({ name: 'PageHeader' }); - expect(ph.props('title')).toBe('Admin'); - expect(ph.props('icon')).toBe('fa-solid fa-user-tie'); + const headerTabs = wrapper.findComponent({ name: 'CorePageHeaderTabs' }); + expect(headerTabs.props('title')).toBe('Admin'); + expect(headerTabs.props('icon')).toBe('fa-solid fa-user-tie'); }); - it('PageHeader receives title="" + icon stays in breadcrumb mode', async () => { + it('CorePageHeaderTabs receives title="" + icon stays in breadcrumb mode', async () => { adminStoreState.currentBreadcrumb = { title: 'Jane Doe' }; const wrapper = mountLayout({}, '/admin/users/u1'); await wrapper.vm.$nextTick(); - const ph = wrapper.findComponent({ name: 'PageHeader' }); - expect(ph.props('title')).toBe(''); - expect(ph.props('icon')).toBe('fa-solid fa-user-tie'); + const headerTabs = wrapper.findComponent({ name: 'CorePageHeaderTabs' }); + expect(headerTabs.props('title')).toBe(''); + expect(headerTabs.props('icon')).toBe('fa-solid fa-user-tie'); }); - it('does NOT render when currentBreadcrumb is set (detail mode)', async () => { + it('passes hideTabs=true when currentBreadcrumb is set (detail mode)', async () => { adminStoreState.currentBreadcrumb = { title: 'Jane Doe' }; const wrapper = mountLayout({}, '/admin/users/u1'); await wrapper.vm.$nextTick(); - expect(wrapper.findComponent({ name: 'CoreSurfaceTabBar' }).exists()).toBe(false); + const headerTabs = wrapper.findComponent({ name: 'CorePageHeaderTabs' }); + expect(headerTabs.props('hideTabs')).toBe(true); }); - it('renders when currentBreadcrumb is null (list mode)', () => { + it('passes hideTabs=false when currentBreadcrumb is null (list mode)', () => { const wrapper = mountLayout(); - expect(wrapper.findComponent({ name: 'CoreSurfaceTabBar' }).exists()).toBe(true); + const headerTabs = wrapper.findComponent({ name: 'CorePageHeaderTabs' }); + expect(headerTabs.props('hideTabs')).toBe(false); }); it('renders the breadcrumb when useAdminStore().currentBreadcrumb is set', async () => { diff --git a/src/modules/admin/views/admin.layout.vue b/src/modules/admin/views/admin.layout.vue index da6d77343..a4c6897d5 100644 --- a/src/modules/admin/views/admin.layout.vue +++ b/src/modules/admin/views/admin.layout.vue @@ -1,5 +1,5 @@