Skip to content

Commit 1e5fdf1

Browse files
fix(ui): chrome convergence — restore section title + surface backgrounds + homogeneous gutter (#4188)
* fix(admin): layout — PageHeader + CoreSurfaceTabBar siblings, router-view outside container * fix(admin): users.view — re-add v-container+v-row pa-2 mt-0+v-col chrome * fix(admin): organizations.view — re-add v-container+v-row+v-col chrome * fix(admin): readiness.view — re-add v-container+v-row+v-col around v-card * fix(admin): activity.view — re-add v-container+v-row+v-col chrome * fix(admin): user.view — re-add v-container+v-row+v-col chrome around form card * fix(users): profile.view — wrap content in v-row+v-col+v-card surface (homogeneous chrome) * fix(users): organizations.view — wrap content in v-row+v-col+v-card surface * docs(migrations): document chrome convergence convention + downstream guidance
1 parent 6416e4f commit 1e5fdf1

17 files changed

Lines changed: 491 additions & 216 deletions

MIGRATIONS.md

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,158 @@ Breaking changes and upgrade notes for downstream projects.
44

55
---
66

7+
## Account / Organization / Admin chrome convergence (2026-05-21)
8+
9+
**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.
10+
11+
### The convention
12+
13+
Layouts (Account, Organization detail, Admin) use the same shape:
14+
15+
```vue
16+
<template>
17+
<v-container fluid>
18+
<PageHeader icon="fa-solid fa-X" title="Section">
19+
<template #actions v-if="...">...</template>
20+
</PageHeader>
21+
<CoreSurfaceTabBar :tabs="..." :can="..." :base-path="..." />
22+
</v-container>
23+
<router-view />
24+
</template>
25+
```
26+
27+
- `PageHeader` and `CoreSurfaceTabBar` are **siblings inside the container** — NEVER pass tabs via PageHeader's `#tabs` slot. That slot replaces the icon+title row and is reserved for future cases that intentionally suppress the title (none in this codebase today).
28+
- `<router-view />` is **outside** the layout's `<v-container fluid>` — children supply their own gutter.
29+
30+
Routed children wrap their primary content like this:
31+
32+
```vue
33+
<template>
34+
<v-container fluid>
35+
<v-row class="pa-2 mt-0">
36+
<v-col cols="12">
37+
<v-card color="surface" :flat="config.vuetify.theme.flat" :class="config.vuetify.theme.rounded" class="pa-6">
38+
<!-- primary content -->
39+
</v-card>
40+
<!-- optional sibling cards (e.g. danger zone) -->
41+
</v-col>
42+
</v-row>
43+
<coreConfirmDialog ... />
44+
</v-container>
45+
</template>
46+
```
47+
48+
The `<v-card color="surface">` provides the visible pane background. Content that already provides its own surface (e.g. `coreDataTableComponent`) skips the wrap card to avoid double-surface — wrap the data table directly in `<v-col cols="12">` without an inner `<v-card>`.
49+
50+
### Detail-page breadcrumb (admin only)
51+
52+
Admin sub-views that drill into a single record publish a breadcrumb to the layout instead of carrying their own header:
53+
54+
```javascript
55+
import { useAdminStore } from '../stores/admin.store';
56+
// ...
57+
watch: {
58+
user: { immediate: true, handler(u) { this.publishBreadcrumb(u); } },
59+
},
60+
beforeUnmount() { useAdminStore().clearBreadcrumb(); },
61+
methods: {
62+
publishBreadcrumb(u) {
63+
if (!u || (!u.firstName && !u.lastName && !u.email)) return;
64+
const title = [u.firstName, u.lastName].filter(Boolean).join(' ') || u.email;
65+
useAdminStore().setBreadcrumb({ title, titleClass: 'text-capitalize' });
66+
},
67+
},
68+
```
69+
70+
In breadcrumb mode the layout hides `<CoreSurfaceTabBar>` (user is "drilled in") and renders `Section › <title>` via `<PageHeader>`'s `#breadcrumb` slot.
71+
72+
### Shared destructive-confirm dialog
73+
74+
`<coreConfirmDialog>` (from `src/modules/core/components/core.confirmDialog.component.vue`) replaces inline `<v-dialog>` blocks for destructive actions:
75+
76+
```vue
77+
<coreConfirmDialog
78+
v-model="confirmDelete"
79+
title="Delete X"
80+
:confirm-text="confirmTarget"
81+
confirm-label="Delete"
82+
confirm-color="error"
83+
@confirm="remove"
84+
>
85+
<!-- optional default slot for rich body content -->
86+
</coreConfirmDialog>
87+
```
88+
89+
Supports a simple yes/no (no `confirm-text`) OR a typed-gate (`confirm-text="DELETE"` or `:confirm-text="orgName"` — confirm button stays disabled until the user types the exact string).
90+
91+
### Avatar uploader
92+
93+
`<coreAvatarUploader>` (`src/modules/core/components/core.avatarUploader.component.vue`) replaces hand-rolled `position-absolute` camera-button overlays:
94+
95+
```vue
96+
<coreAvatarUploader :user="user" :size="200" @uploaded="$emit('avatar-uploaded')" />
97+
```
98+
99+
Posts to `/users/avatar` by default; `endpoint` and `field` props let it serve other upload contracts (logos, banners) without forking.
100+
101+
### Action for downstream projects
102+
103+
**Default consumers (no admin extras, no custom account chrome):** no action required. The convention is applied uniformly inside devkit.
104+
105+
**Projects with `config.admin.tabs` extras:** no structural change — extras are merged with the canonical `BUILT_IN_TABS` (Users, Organizations, Readiness, Activity) and passed through `CoreSurfaceTabBar`, which handles validation + CASL filtering via `resolveSurfaceTabs`. Existing tab descriptors (`{ value, label, icon, route, action?, subject? }`) work as-is.
106+
107+
**Projects with module-specific tabs under a page title** (e.g. trawl_vue's `/developers`, `/scraps`, custom dashboards): you can now adopt the homogeneous `PageHeader + CoreSurfaceTabBar` sibling pattern instead of the legacy `<v-card><v-tabs><v-window>` in-card pattern. Migration is opt-in — the legacy pattern still works, but the new pattern integrates visually with Account / Organization / Admin and gets CASL gating for free.
108+
109+
Before:
110+
111+
```vue
112+
<v-container fluid>
113+
<PageHeader icon="fa-solid fa-code" title="Developers" />
114+
<v-row class="pa-2 mt-0">
115+
<v-col cols="12">
116+
<v-card color="surface">
117+
<v-tabs v-model="tab">
118+
<v-tab value="keys">...</v-tab>
119+
<v-tab value="webhooks">...</v-tab>
120+
</v-tabs>
121+
<v-divider />
122+
<v-window v-model="tab">
123+
<v-window-item value="keys"><developersKeysTab :keys="..." /></v-window-item>
124+
<v-window-item value="webhooks"><developersWebhooksTab :webhooks="..." /></v-window-item>
125+
</v-window>
126+
</v-card>
127+
</v-col>
128+
</v-row>
129+
</v-container>
130+
```
131+
132+
After (route-driven, homogeneous with Account / Org / Admin):
133+
134+
```vue
135+
<!-- developers.layout.vue -->
136+
<template>
137+
<v-container fluid>
138+
<PageHeader icon="fa-solid fa-code" title="Developers" />
139+
<CoreSurfaceTabBar
140+
:tabs="config.developers.tabs"
141+
:can="developersCan"
142+
:base-path="basePath"
143+
/>
144+
</v-container>
145+
<router-view />
146+
</template>
147+
```
148+
149+
Plus `config.developers.tabs = [{ value: 'keys', label: 'API Keys', icon: 'fa-solid fa-key', route: 'keys' }, { value: 'webhooks', label: 'Webhooks', icon: 'fa-solid fa-globe', route: 'webhooks' }]`, then split the keys / webhooks tabs into routed children (`developers.keys.view.vue`, `developers.webhooks.view.vue`) each wrapping its content per the child-view shape above. Dialogs (`showCreateKeyDialog`, `showPlainKeyDialog`, etc.) move into the child views that own them.
150+
151+
This migration is **opt-in per module** — coordinate with the maintainer of each module before applying.
152+
153+
### Why
154+
155+
`/admin/users`, `/users/profile`, `/users/organizations` looked visually disconnected from `/developers`, `/tasks` after #4187 (no surface background on routed panes, no section title on admin, ad-hoc padding). Locking in one convention makes every "section + tabs + content" layout in the app visually identical and lets downstream projects opt their own modules in cheaply.
156+
157+
---
158+
7159
## Legal module + cookie consent banner (2026-05-07)
8160

9161
**Non-breaking for default consumers.** New `modules/legal/` ships:

src/modules/admin/tests/admin.activity.view.unit.tests.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,13 @@ import { fileURLToPath } from 'url';
187187
import { dirname, resolve } from 'path';
188188

189189
describe('admin.activity.view — template chrome', () => {
190-
it('does NOT wrap its template in <v-container> (admin layout owns chrome)', () => {
190+
it('wraps its template in <v-container fluid> + <v-row class="pa-2 mt-0"> + <v-col cols="12">', () => {
191191
const here = dirname(fileURLToPath(import.meta.url));
192192
const sfc = readFileSync(resolve(here, '../views/admin.activity.view.vue'), 'utf8');
193193
const tmpl = sfc.split('<script>')[0];
194-
expect(tmpl).not.toMatch(/<v-container/);
194+
expect(tmpl).toMatch(/<v-container\s+fluid/);
195+
expect(tmpl).toMatch(/<v-row[^>]*class="[^"]*pa-2\s+mt-0/);
196+
expect(tmpl).toMatch(/<v-col\s+cols="12"/);
195197
});
196198

197199
it('has zero inline style="…" attributes in its template', () => {

src/modules/admin/tests/admin.layout.unit.tests.js

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,10 @@ const mountLayout = (configOverrides = {}, routePath = '/admin/users') =>
4545
RouterLink: true,
4646
RouterView: { template: '<div class="router-view-stub" />' },
4747
PageHeader: {
48+
name: 'PageHeader',
49+
props: ['title', 'icon'],
4850
template: `
49-
<div class="page-header-stub">
51+
<div class="page-header-stub" :data-title="title" :data-icon="icon">
5052
<slot name="avatar" />
5153
<slot name="breadcrumb" />
5254
<slot name="tabs" />
@@ -150,28 +152,58 @@ describe('admin.layout', () => {
150152
expect(wrapper.html()).not.toContain('No mailer configured');
151153
});
152154

153-
it('renders a single .admin-content wrapper around <router-view>', () => {
155+
it('renders <CoreSurfaceTabBar> as a SIBLING of <PageHeader> (not inside its #tabs slot)', () => {
154156
const wrapper = mountLayout();
155-
expect(wrapper.findAll('.admin-content').length).toBe(1);
156-
expect(wrapper.find('.admin-content .router-view-stub').exists()).toBe(true);
157+
const pageHeaderEl = wrapper.find('.page-header-stub');
158+
const surfaceTabBarEl = wrapper.find('.surface-tab-bar-stub');
159+
expect(pageHeaderEl.exists()).toBe(true);
160+
expect(surfaceTabBarEl.exists()).toBe(true);
161+
// Sibling layout: the tab-bar element is NOT inside the page-header-stub element.
162+
expect(pageHeaderEl.element.contains(surfaceTabBarEl.element)).toBe(false);
157163
});
158164

159-
it('renders the breadcrumb when useAdminStore().currentBreadcrumb is set', async () => {
160-
adminStoreState.currentBreadcrumb = { title: 'Jane Doe', titleClass: 'text-capitalize' };
165+
it('renders <router-view> OUTSIDE the layout <v-container>', () => {
166+
const wrapper = mountLayout();
167+
const layoutContainer = wrapper.find('.v-container');
168+
const routerViewEl = wrapper.find('.router-view-stub');
169+
expect(layoutContainer.exists()).toBe(true);
170+
expect(routerViewEl.exists()).toBe(true);
171+
expect(layoutContainer.element.contains(routerViewEl.element)).toBe(false);
172+
});
173+
174+
it('PageHeader receives title="Admin" + icon="fa-solid fa-user-tie" in list mode', () => {
175+
const wrapper = mountLayout();
176+
const ph = wrapper.findComponent({ name: 'PageHeader' });
177+
expect(ph.props('title')).toBe('Admin');
178+
expect(ph.props('icon')).toBe('fa-solid fa-user-tie');
179+
});
180+
181+
it('PageHeader receives title="" + icon stays in breadcrumb mode', async () => {
182+
adminStoreState.currentBreadcrumb = { title: 'Jane Doe' };
161183
const wrapper = mountLayout({}, '/admin/users/u1');
162184
await wrapper.vm.$nextTick();
163-
expect(wrapper.text()).toContain('Jane Doe');
185+
const ph = wrapper.findComponent({ name: 'PageHeader' });
186+
expect(ph.props('title')).toBe('');
187+
expect(ph.props('icon')).toBe('fa-solid fa-user-tie');
164188
});
165189

166-
it('does NOT render CoreSurfaceTabBar when currentBreadcrumb is set (detail mode)', async () => {
190+
it('does NOT render <CoreSurfaceTabBar> when currentBreadcrumb is set (detail mode)', async () => {
167191
adminStoreState.currentBreadcrumb = { title: 'Jane Doe' };
168192
const wrapper = mountLayout({}, '/admin/users/u1');
169193
await wrapper.vm.$nextTick();
170194
expect(wrapper.findComponent({ name: 'CoreSurfaceTabBar' }).exists()).toBe(false);
171195
});
172196

173-
it('renders CoreSurfaceTabBar when currentBreadcrumb is null (list mode)', () => {
197+
it('renders <CoreSurfaceTabBar> when currentBreadcrumb is null (list mode)', () => {
174198
const wrapper = mountLayout();
175199
expect(wrapper.findComponent({ name: 'CoreSurfaceTabBar' }).exists()).toBe(true);
176200
});
201+
202+
it('renders the breadcrumb when useAdminStore().currentBreadcrumb is set', async () => {
203+
adminStoreState.currentBreadcrumb = { title: 'Jane Doe', titleClass: 'text-capitalize' };
204+
const wrapper = mountLayout({}, '/admin/users/u1');
205+
await wrapper.vm.$nextTick();
206+
expect(wrapper.text()).toContain('Jane Doe');
207+
});
208+
177209
});

src/modules/admin/tests/admin.organizations.view.unit.tests.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,12 @@ import { fileURLToPath } from 'url';
7575
import { dirname, resolve } from 'path';
7676

7777
describe('admin.organizations.view — template chrome', () => {
78-
it('does NOT wrap its template in <v-container> (admin layout owns chrome)', () => {
78+
it('wraps its template in <v-container fluid> + <v-row class="pa-2 mt-0"> + <v-col cols="12">', () => {
7979
const here = dirname(fileURLToPath(import.meta.url));
8080
const sfc = readFileSync(resolve(here, '../views/admin.organizations.view.vue'), 'utf8');
81-
// Extract only the top-level <template>…</template> block (before <script>)
82-
const tmpl = sfc.split('<script>')[0] || '';
83-
expect(tmpl).not.toMatch(/<v-container/);
81+
const tmpl = sfc.split('<script>')[0];
82+
expect(tmpl).toMatch(/<v-container\s+fluid/);
83+
expect(tmpl).toMatch(/<v-row[^>]*class="[^"]*pa-2\s+mt-0/);
84+
expect(tmpl).toMatch(/<v-col\s+cols="12"/);
8485
});
8586
});

src/modules/admin/tests/admin.readiness.view.unit.tests.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,12 @@ import { fileURLToPath } from 'url';
9999
import { dirname, resolve } from 'path';
100100

101101
describe('admin.readiness.view — template chrome', () => {
102-
it('does NOT wrap its template in <v-container> (admin layout owns chrome)', () => {
102+
it('wraps its template in <v-container fluid> + <v-row class="pa-2 mt-0"> + <v-col cols="12">', () => {
103103
const here = dirname(fileURLToPath(import.meta.url));
104104
const sfc = readFileSync(resolve(here, '../views/admin.readiness.view.vue'), 'utf8');
105-
// Extract only the top-level <template>…</template> block (before <script>)
106-
const tmpl = sfc.split('<script>')[0] || '';
107-
expect(tmpl).not.toMatch(/<v-container/);
105+
const tmpl = sfc.split('<script>')[0];
106+
expect(tmpl).toMatch(/<v-container\s+fluid/);
107+
expect(tmpl).toMatch(/<v-row[^>]*class="[^"]*pa-2\s+mt-0/);
108+
expect(tmpl).toMatch(/<v-col\s+cols="12"/);
108109
});
109110
});

src/modules/admin/tests/admin.user.view.unit.tests.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,13 @@ const mountView = (routeId = 'u1', initialUser = null) => {
6363
};
6464

6565
describe('admin.user.view — template chrome', () => {
66-
it('does NOT render its own <v-container> (admin layout owns chrome)', () => {
66+
it('wraps its template in <v-container fluid> + <v-row class="pa-2 mt-0"> + <v-col cols="12">', () => {
6767
const here = dirname(fileURLToPath(import.meta.url));
6868
const sfc = readFileSync(resolve(here, '../views/admin.user.view.vue'), 'utf8');
6969
const tmpl = sfc.split('<script>')[0];
70-
expect(tmpl).not.toMatch(/<v-container/);
70+
expect(tmpl).toMatch(/<v-container\s+fluid/);
71+
expect(tmpl).toMatch(/<v-row[^>]*class="[^"]*pa-2\s+mt-0/);
72+
expect(tmpl).toMatch(/<v-col\s+cols="12"/);
7173
});
7274

7375
it('does NOT render its own <PageHeader> (admin layout supplies breadcrumb)', () => {

src/modules/admin/tests/admin.users.view.unit.tests.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,13 @@ import { fileURLToPath } from 'url';
128128
import { dirname, resolve } from 'path';
129129

130130
describe('admin.users.view — template chrome', () => {
131-
it('does NOT wrap its template in <v-container> (admin layout owns chrome)', () => {
131+
it('wraps its template in <v-container fluid> + <v-row class="pa-2 mt-0"> + <v-col cols="12">', () => {
132132
const here = dirname(fileURLToPath(import.meta.url));
133133
const sfc = readFileSync(resolve(here, '../views/admin.users.view.vue'), 'utf8');
134-
// Extract only the top-level <template>…</template> block (before <script>)
135-
const tmpl = sfc.split('<script>')[0] || '';
136-
expect(tmpl).not.toMatch(/<v-container/);
134+
const tmpl = sfc.split('<script>')[0];
135+
expect(tmpl).toMatch(/<v-container\s+fluid/);
136+
expect(tmpl).toMatch(/<v-row[^>]*class="[^"]*pa-2\s+mt-0/);
137+
expect(tmpl).toMatch(/<v-col\s+cols="12"/);
137138
});
138139
});
139140

src/modules/admin/views/admin.activity.view.vue

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
<template>
2-
<!-- Audit disabled info state -->
3-
<v-alert
4-
v-if="config.audit && config.audit.enabled === false"
5-
type="info"
6-
variant="tonal"
7-
density="compact"
8-
:class="config.vuetify.theme.rounded"
9-
icon="fa-solid fa-circle-info"
10-
>
11-
<span class="text-body-medium">Audit logging is disabled. Enable it in configuration to start tracking activity.</span>
12-
</v-alert>
13-
<v-card v-else width="100%" color="surface" :flat="config.vuetify.theme.flat" :class="config.vuetify.theme.rounded">
2+
<v-container fluid>
3+
<v-row class="pa-2 mt-0">
4+
<v-col cols="12">
5+
<!-- Audit disabled info state -->
6+
<v-alert
7+
v-if="config.audit && config.audit.enabled === false"
8+
type="info"
9+
variant="tonal"
10+
density="compact"
11+
:class="config.vuetify.theme.rounded"
12+
icon="fa-solid fa-circle-info"
13+
>
14+
<span class="text-body-medium">Audit logging is disabled. Enable it in configuration to start tracking activity.</span>
15+
</v-alert>
16+
<v-card v-else width="100%" color="surface" :flat="config.vuetify.theme.flat" :class="config.vuetify.theme.rounded">
1417
<v-card-title>
1518
<v-row dense align="center">
1619
<v-col cols="12" sm="auto">
@@ -131,7 +134,10 @@
131134
<v-icon icon="fa-solid fa-angle-right" size="small"></v-icon>
132135
</v-btn>
133136
</v-card-actions>
134-
</v-card>
137+
</v-card>
138+
</v-col>
139+
</v-row>
140+
</v-container>
135141
</template>
136142
<script>
137143
/**

0 commit comments

Comments
 (0)