Skip to content
110 changes: 110 additions & 0 deletions MIGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** — `<v-tabs class="mx-4">` 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 `<v-container fluid class="pb-0">`** 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
<template>
<v-container fluid class="pb-0">
<CorePageHeaderTabs
icon="fa-solid fa-X"
title="Section"
:tabs="config.section.tabs"
:can="sectionCan"
:base-path="basePath"
:hide-tabs="false"
>
<template #actions>...</template>
<template #breadcrumb v-if="...">...</template>
</CorePageHeaderTabs>
</v-container>
<router-view />
</template>
```

Pages **without tabs** (Tasks, Scraps, simple list views) — content-sized `PageHeader`, unchanged shape:

```vue
<template>
<v-container fluid>
<PageHeader icon="fa-solid fa-X" title="Section">
<template #actions>...</template>
</PageHeader>
<v-row class="pa-2 mt-0">
<!-- content -->
</v-row>
</v-container>
</template>
```

### Breadcrumb mode

The pattern from the previous migration entry still holds. Now expressed via `CorePageHeaderTabs`:

```vue
<CorePageHeaderTabs
icon="fa-solid fa-user-tie"
:title="currentBreadcrumb ? '' : 'Section'"
:tabs="tabs"
:can="can"
:base-path="basePath"
:hide-tabs="!!currentBreadcrumb"
>
<template v-if="currentBreadcrumb" #breadcrumb>
<router-link to="/section">Section</router-link>
<v-icon icon="fa-solid fa-chevron-right" size="x-small" class="mx-2 text-medium-emphasis" />
<span>{{ currentBreadcrumb.title }}</span>
</template>
</CorePageHeaderTabs>
```

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
<v-row v-if="tasks && tasks.length" class="pa-2 mt-0">
<taskComponent v-for="..." />
</v-row>
<v-row v-if="!tasks || !tasks.length" class="pa-2 mt-0">
<!-- empty state -->
</v-row>
```

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 `<PageHeader>` + `<CoreSurfaceTabBar>` siblings with a single `<CorePageHeaderTabs>` (import from `src/modules/core/components/core.pageHeaderTabs.component.vue`).
2. Add `class="pb-0"` to the wrapping `<v-container fluid>` 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.
Expand Down
92 changes: 39 additions & 53 deletions src/modules/admin/tests/admin.layout.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,27 +44,21 @@ const mountLayout = (configOverrides = {}, routePath = '/admin/users') =>
stubs: {
RouterLink: true,
RouterView: { template: '<div class="router-view-stub" />' },
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: `
<div class="page-header-stub" :data-title="title" :data-icon="icon">
<div class="page-header-tabs-stub" :data-title="title" :data-icon="icon" :data-hide-tabs="hideTabs">
<slot name="avatar" />
<slot name="breadcrumb" />
<slot name="tabs" />
<slot name="title" />
<slot name="subtitle" />
<slot name="actions" />
</div>
`,
},
// 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: '<div class="surface-tab-bar-stub" :data-tabs-count="tabs?.length || 0" />',
},
},
},
});
Expand All @@ -79,24 +73,24 @@ 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 <router-view> for nested admin content', () => {
const wrapper = mountLayout();
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: [
Expand All @@ -105,45 +99,45 @@ 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 () => {
adminStoreState.error = 'Boom';
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 () => {
authStoreState.serverConfig = { mail: { configured: false } };
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', () => {
Expand All @@ -152,16 +146,6 @@ describe('admin.layout', () => {
expect(wrapper.html()).not.toContain('No mailer configured');
});

it('renders <CoreSurfaceTabBar> as a SIBLING of <PageHeader> (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 <router-view> OUTSIDE the layout <v-container>', () => {
const wrapper = mountLayout();
const layoutContainer = wrapper.find('.v-container');
Expand All @@ -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 <CoreSurfaceTabBar> 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 <CoreSurfaceTabBar> 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 () => {
Expand Down
21 changes: 9 additions & 12 deletions src/modules/admin/views/admin.layout.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<v-container fluid>
<v-container fluid class="pb-0">
<v-alert
v-if="error"
type="error"
Expand Down Expand Up @@ -27,22 +27,20 @@
</span>
</v-alert>

<PageHeader
<CorePageHeaderTabs
icon="fa-solid fa-user-tie"
:title="currentBreadcrumb ? '' : 'Admin'"
:tabs="allTabs"
:can="adminCan"
:base-path="basePath"
:hide-tabs="!!currentBreadcrumb"
>
<template v-if="currentBreadcrumb" #breadcrumb>
<router-link to="/admin" class="text-medium-emphasis text-decoration-none">Admin</router-link>
<v-icon icon="fa-solid fa-chevron-right" size="x-small" class="mx-2 text-medium-emphasis"></v-icon>
<span :class="currentBreadcrumb.titleClass || ''">{{ currentBreadcrumb.title }}</span>
</template>
</PageHeader>
<CoreSurfaceTabBar
v-if="!currentBreadcrumb"
:tabs="allTabs"
:can="adminCan"
:base-path="basePath"
/>
</CorePageHeaderTabs>
</v-container>

<router-view />
Expand All @@ -51,8 +49,7 @@
/**
* Module dependencies.
*/
import PageHeader from '../../core/components/core.pageHeader.component.vue';
import CoreSurfaceTabBar from '../../core/components/core.surfaceTabBar.component.vue';
import CorePageHeaderTabs from '../../core/components/core.pageHeaderTabs.component.vue';
import { ability } from '../../../lib/helpers/ability';
import { useAdminStore } from '../stores/admin.store';
import { useAuthStore } from '../../auth/stores/auth.store';
Expand Down Expand Up @@ -85,7 +82,7 @@ const BUILT_IN_TABS = Object.freeze([
*/
export default {
name: 'AdminLayout',
components: { PageHeader, CoreSurfaceTabBar },
components: { CorePageHeaderTabs },
computed: {
/**
* @desc Base path for admin tabs (where CoreSurfaceTabBar resolves relative routes).
Expand Down
Loading
Loading