@micha.bigler/ui-core-micha 2.35.0 → 2.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
5
+ import { ThemeProvider, createTheme } from '@mui/material/styles';
6
+ import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
7
+
8
+ vi.mock('react-i18next', () => ({
9
+ useTranslation: () => ({
10
+ t: (key, fallback) => ({
11
+ 'Account.TITLE': 'Account & Administration',
12
+ 'Account.PAGE_TITLE': 'Account',
13
+ 'Account.TAB_PROFILE': 'Profile',
14
+ 'Account.TAB_SECURITY': 'Security',
15
+ 'Account.TAB_USERS': 'Users',
16
+ 'Account.TAB_INVITE': 'Invite',
17
+ 'Account.TAB_SUPPORT': 'Support',
18
+ 'Account.GROUP_MY_ACCOUNT': 'My account',
19
+ 'Account.GROUP_MANAGEMENT': 'Management',
20
+ 'Account.GROUP_HELP': 'Help',
21
+ 'Account.GROUP_MORE': 'More',
22
+ 'SectionNav.TITLE': 'Switch section',
23
+ 'SectionNav.TRIGGER_EYEBROW': 'Section',
24
+ 'SectionNav.LAST_OPENED': 'Last opened',
25
+ }[key] ?? fallback ?? key),
26
+ }),
27
+ }));
28
+
29
+ vi.mock('../src/auth/authApi', () => ({
30
+ fetchAuthPolicy: vi.fn(() => new Promise(() => {})),
31
+ updateUserProfile: vi.fn(),
32
+ }));
33
+
34
+ vi.mock('../src/components/ProfileComponent', () => ({
35
+ ProfileComponent: () => <div>Profile content</div>,
36
+ }));
37
+ vi.mock('../src/components/SecurityComponent', () => ({
38
+ SecurityComponent: () => <div>Security content</div>,
39
+ }));
40
+ vi.mock('../src/components/UserListComponent', () => ({
41
+ UserListComponent: () => <div>Users content</div>,
42
+ }));
43
+ vi.mock('../src/components/UserInviteComponent', () => ({ UserInviteComponent: () => null }));
44
+ vi.mock('../src/components/AccessCodeManager', () => ({ AccessCodeManager: () => null }));
45
+ vi.mock('../src/components/AllowedEmailDomainsManager', () => ({ AllowedEmailDomainsManager: () => null }));
46
+ vi.mock('../src/components/RegistrationMethodsManager', () => ({ RegistrationMethodsManager: () => null }));
47
+ vi.mock('../src/components/AuthFactorRequirementCard', () => ({ AuthFactorRequirementCard: () => null }));
48
+ vi.mock('../src/components/AccessCodeSingleUseToggle', () => ({ AccessCodeSingleUseToggle: () => null }));
49
+ vi.mock('../src/components/QrSignupManager', () => ({ QrSignupManager: () => null }));
50
+ vi.mock('../src/components/QrSignupValidityManager', () => ({ QrSignupValidityManager: () => null }));
51
+ vi.mock('../src/components/SupportRecoveryRequestsTab', () => ({
52
+ SupportRecoveryRequestsTab: () => <div>Support content</div>,
53
+ }));
54
+ vi.mock('../src/components/BulkInviteCsvTab', () => ({ BulkInviteCsvTab: () => null }));
55
+
56
+ import { AuthContext } from '../src/auth/AuthContext';
57
+ import { AccountPage } from '../src/pages/AccountPage';
58
+
59
+ const theme = createTheme();
60
+ const superuser = {
61
+ email: 'admin@example.com',
62
+ is_superuser: true,
63
+ ui_permissions: {},
64
+ available_roles: [],
65
+ };
66
+ const notificationsTab = {
67
+ value: 'notifications',
68
+ label: 'Notifications',
69
+ render: () => <div>Notifications content</div>,
70
+ };
71
+
72
+ function setViewportWidth(width) {
73
+ window.matchMedia = vi.fn().mockImplementation((query) => {
74
+ const maxWidth = query.match(/max-width:(\d+(?:\.\d+)?)px/)?.[1];
75
+ const minWidth = query.match(/min-width:(\d+(?:\.\d+)?)px/)?.[1];
76
+ const matches = maxWidth
77
+ ? width <= Number(maxWidth)
78
+ : minWidth
79
+ ? width >= Number(minWidth)
80
+ : false;
81
+ return {
82
+ matches,
83
+ media: query,
84
+ onchange: null,
85
+ addEventListener: vi.fn(),
86
+ removeEventListener: vi.fn(),
87
+ addListener: vi.fn(),
88
+ removeListener: vi.fn(),
89
+ dispatchEvent: vi.fn(),
90
+ };
91
+ });
92
+ }
93
+
94
+ function LocationProbe() {
95
+ const location = useLocation();
96
+ return <output data-testid="location-search">{location.search}</output>;
97
+ }
98
+
99
+ function renderAccount({ user = superuser, route = '/account', extraTabs = [notificationsTab] } = {}) {
100
+ return render(
101
+ <ThemeProvider theme={theme}>
102
+ <AuthContext.Provider value={{ user, login: vi.fn(), loading: false }}>
103
+ <MemoryRouter initialEntries={[route]}>
104
+ <Routes>
105
+ <Route
106
+ path="/account"
107
+ element={(
108
+ <>
109
+ <AccountPage extraTabs={extraTabs} />
110
+ <LocationProbe />
111
+ </>
112
+ )}
113
+ />
114
+ </Routes>
115
+ </MemoryRouter>
116
+ </AuthContext.Provider>
117
+ </ThemeProvider>,
118
+ );
119
+ }
120
+
121
+ describe('AccountPage section navigation', () => {
122
+ beforeEach(() => setViewportWidth(375));
123
+
124
+ afterEach(() => {
125
+ cleanup();
126
+ vi.restoreAllMocks();
127
+ });
128
+
129
+ it('makes all six cockpit-shaped sections reachable at 375px without a horizontal strip', () => {
130
+ const { container } = renderAccount();
131
+ const trigger = screen.getByRole('button', { name: /Section Profile/i });
132
+
133
+ expect(container.querySelector('.MuiTabs-root')).toBeNull();
134
+ expect(getComputedStyle(trigger).width).toBe('100%');
135
+ fireEvent.click(trigger);
136
+
137
+ const sectionButtons = ['Profile', 'Security', 'Users', 'Invite', 'Support', 'Notifications']
138
+ .map((label) => screen.getByRole('button', { name: label }));
139
+ expect(sectionButtons).toHaveLength(6);
140
+ sectionButtons.forEach((item) => {
141
+ expect(getComputedStyle(item).position).not.toBe('absolute');
142
+ });
143
+ expect(document.documentElement.scrollWidth - document.documentElement.clientWidth).toBe(0);
144
+ });
145
+
146
+ it('keeps the ?tab= contract and updates it when a drawer item is selected', () => {
147
+ renderAccount({ route: '/account?tab=notifications' });
148
+
149
+ expect(screen.getByText('Notifications content')).toBeTruthy();
150
+ const trigger = screen.getByRole('button', { name: /Section Notifications/i });
151
+ fireEvent.click(trigger);
152
+ fireEvent.click(screen.getByRole('button', { name: 'Security' }));
153
+
154
+ expect(screen.getByText('Security content')).toBeTruthy();
155
+ expect(screen.getByTestId('location-search').textContent).toBe('?tab=security');
156
+ });
157
+
158
+ it('keeps permission-gated sections absent and falls an unpermitted query back to profile', () => {
159
+ renderAccount({
160
+ user: {
161
+ email: 'person@example.com',
162
+ is_superuser: false,
163
+ ui_permissions: {},
164
+ available_roles: [],
165
+ },
166
+ route: '/account?tab=users',
167
+ extraTabs: [],
168
+ });
169
+
170
+ expect(screen.getByText('Profile content')).toBeTruthy();
171
+ const trigger = screen.getByRole('button', { name: /Section Profile/i });
172
+ fireEvent.click(trigger);
173
+ expect(screen.getByRole('button', { name: 'Profile' })).toBeTruthy();
174
+ expect(screen.getByRole('button', { name: 'Security' })).toBeTruthy();
175
+ expect(screen.queryByRole('button', { name: 'Users' })).toBeNull();
176
+ expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull();
177
+ expect(screen.queryByRole('button', { name: 'Support' })).toBeNull();
178
+ expect(screen.queryByText('Management')).toBeNull();
179
+ expect(screen.queryByText('Help')).toBeNull();
180
+ expect(screen.queryByText('More')).toBeNull();
181
+ });
182
+
183
+ it('uses the desktop grouped grid at 1280px with no tab strip left behind', () => {
184
+ setViewportWidth(1280);
185
+ const { container } = renderAccount();
186
+ const grid = container.querySelector('[data-section-nav-mode="desktop"]');
187
+
188
+ expect(grid).toBeTruthy();
189
+ expect(getComputedStyle(grid).gridTemplateColumns).toBe('280px minmax(0, 1fr)');
190
+ expect(container.querySelector('.MuiTabs-root')).toBeNull();
191
+ expect(container.querySelector('nav')).toBeTruthy();
192
+ });
193
+ });
@@ -0,0 +1,274 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+ import { ThemeProvider, createTheme } from '@mui/material/styles';
6
+
7
+ const translation = vi.hoisted(() => ({
8
+ t: vi.fn((key) => ({
9
+ 'SectionNav.TITLE': 'Switch section',
10
+ 'SectionNav.TRIGGER_EYEBROW': 'Section',
11
+ 'SectionNav.LAST_OPENED': 'Last opened',
12
+ }[key] ?? key)),
13
+ }));
14
+
15
+ vi.mock('react-i18next', () => ({ useTranslation: () => translation }));
16
+
17
+ import {
18
+ SECTION_NAV_DRAWER_PAPER_SX,
19
+ SectionNav,
20
+ } from '../src/layout/SectionNav';
21
+ import { sectionNavTranslations } from '../src/i18n/sectionNavTranslations';
22
+ import { createAppTheme } from '../src/theme/createAppTheme';
23
+
24
+ const groups = [
25
+ {
26
+ key: 'account',
27
+ label: 'My account',
28
+ items: [
29
+ { key: 'profile', label: 'Profile' },
30
+ { key: 'security', label: 'Security' },
31
+ ],
32
+ },
33
+ {
34
+ key: 'management',
35
+ label: 'Management',
36
+ items: [
37
+ { key: 'users', label: 'Users' },
38
+ { key: 'invite', label: 'Invite' },
39
+ ],
40
+ },
41
+ {
42
+ key: 'help',
43
+ label: 'Help',
44
+ items: [{ key: 'support', label: 'Support' }],
45
+ },
46
+ {
47
+ key: 'more',
48
+ label: 'More',
49
+ items: [{ key: 'notifications', label: 'Notifications' }],
50
+ },
51
+ ];
52
+
53
+ const theme = createAppTheme({ palette: { primary: { main: '#0F62FE' } } });
54
+
55
+ function renderNav(props = {}, currentTheme = theme) {
56
+ return render(
57
+ <ThemeProvider theme={currentTheme}>
58
+ <SectionNav
59
+ mode="desktop"
60
+ groups={groups}
61
+ activeKey="profile"
62
+ onSelect={vi.fn()}
63
+ {...props}
64
+ >
65
+ <div data-testid="section-content">Section content</div>
66
+ </SectionNav>
67
+ </ThemeProvider>,
68
+ );
69
+ }
70
+
71
+ describe('SectionNav', () => {
72
+ afterEach(() => {
73
+ cleanup();
74
+ vi.clearAllMocks();
75
+ });
76
+
77
+ it('renders finished group and item labels in order, one section per item', () => {
78
+ renderNav();
79
+
80
+ expect(screen.getAllByRole('button').map((item) => item.textContent)).toEqual([
81
+ 'Profile',
82
+ 'Security',
83
+ 'Users',
84
+ 'Invite',
85
+ 'Support',
86
+ 'Notifications',
87
+ ]);
88
+ expect(screen.getAllByText(/My account|Management|Help|More/).map((item) => item.textContent))
89
+ .toEqual(['My account', 'Management', 'Help', 'More']);
90
+ });
91
+
92
+ it('selects exactly the matching item, tolerates no match, and selects an optional overview for null', () => {
93
+ const { rerender } = renderNav({ activeKey: 'users', overviewItem: { label: 'Overview' } });
94
+ const selected = () => screen.getAllByRole('button')
95
+ .filter((item) => item.classList.contains('Mui-selected'));
96
+
97
+ expect(selected()).toHaveLength(1);
98
+ expect(selected()[0].textContent).toBe('Users');
99
+
100
+ rerender(
101
+ <ThemeProvider theme={theme}>
102
+ <SectionNav
103
+ mode="desktop"
104
+ groups={groups}
105
+ activeKey="missing"
106
+ overviewItem={{ label: 'Overview' }}
107
+ onSelect={vi.fn()}
108
+ />
109
+ </ThemeProvider>,
110
+ );
111
+ expect(selected()).toHaveLength(0);
112
+
113
+ rerender(
114
+ <ThemeProvider theme={theme}>
115
+ <SectionNav
116
+ mode="desktop"
117
+ groups={groups}
118
+ activeKey={null}
119
+ overviewItem={{ label: 'Overview' }}
120
+ onSelect={vi.fn()}
121
+ />
122
+ </ThemeProvider>,
123
+ );
124
+ expect(selected()).toHaveLength(1);
125
+ expect(selected()[0].textContent).toBe('Overview');
126
+ });
127
+
128
+ it('omits overview and remembered UI completely when those props are absent', () => {
129
+ const { container } = renderNav();
130
+
131
+ expect(screen.queryByText('Overview')).toBeNull();
132
+ expect(screen.queryByText('Last opened')).toBeNull();
133
+ expect(container.querySelectorAll('.MuiPaper-root')).toHaveLength(groups.length);
134
+ });
135
+
136
+ it('shows the remembered secondary line only on its unselected item', () => {
137
+ renderNav({ activeKey: 'profile', rememberedKey: 'security' });
138
+ expect(screen.getByText('Last opened')).toBeTruthy();
139
+
140
+ cleanup();
141
+ renderNav({ activeKey: 'security', rememberedKey: 'security' });
142
+ expect(screen.queryByText('Last opened')).toBeNull();
143
+ });
144
+
145
+ it('calls onSelect with the item key and closes the uncontrolled drawer', async () => {
146
+ const onSelect = vi.fn();
147
+ renderNav({ mode: 'mobile', onSelect });
148
+
149
+ const trigger = screen.getByRole('button', { name: /Section Profile/i });
150
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
151
+ fireEvent.click(trigger);
152
+ expect(trigger.getAttribute('aria-expanded')).toBe('true');
153
+
154
+ fireEvent.click(screen.getByRole('button', { name: 'Security' }));
155
+ expect(onSelect).toHaveBeenCalledOnce();
156
+ expect(onSelect).toHaveBeenCalledWith('security');
157
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
158
+ await waitFor(() => expect(screen.queryByRole('button', { name: 'Security' })).toBeNull());
159
+ });
160
+
161
+ it('defers to the caller in controlled mode: the trigger calls onOpen, not internal state', () => {
162
+ const onOpen = vi.fn();
163
+ const onClose = vi.fn();
164
+ const { rerender } = renderNav({ mode: 'mobile', open: false, onOpen, onClose });
165
+
166
+ const trigger = screen.getByRole('button', { name: /Section Profile/i });
167
+ fireEvent.click(trigger);
168
+
169
+ expect(onOpen).toHaveBeenCalledOnce();
170
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
171
+ expect(screen.queryByRole('button', { name: 'Security' })).toBeNull();
172
+
173
+ rerender(
174
+ <ThemeProvider theme={theme}>
175
+ <SectionNav
176
+ mode="mobile"
177
+ groups={groups}
178
+ activeKey="profile"
179
+ onSelect={vi.fn()}
180
+ open
181
+ onOpen={onOpen}
182
+ onClose={onClose}
183
+ >
184
+ <div data-testid="section-content">Section content</div>
185
+ </SectionNav>
186
+ </ThemeProvider>,
187
+ );
188
+ expect(trigger.getAttribute('aria-expanded')).toBe('true');
189
+ expect(screen.getByRole('button', { name: 'Security' })).toBeTruthy();
190
+ });
191
+
192
+ it('renders the desktop sidebar and children in a two-column grid, while mobile renders a trigger and no sidebar', () => {
193
+ const { container } = renderNav({ sidebarWidth: 312 });
194
+ const desktopRoot = container.querySelector('[data-section-nav-mode="desktop"]');
195
+
196
+ expect(desktopRoot).toBeTruthy();
197
+ expect(getComputedStyle(desktopRoot).display).toBe('grid');
198
+ expect(getComputedStyle(desktopRoot).gridTemplateColumns).toBe('312px minmax(0, 1fr)');
199
+ expect(container.querySelector('nav')).toBeTruthy();
200
+ expect(screen.getByTestId('section-content')).toBeTruthy();
201
+
202
+ cleanup();
203
+ const mobile = renderNav({ mode: 'mobile' });
204
+ expect(screen.getByRole('button', { name: /Section Profile/i })).toBeTruthy();
205
+ expect(mobile.container.querySelector('nav')).toBeNull();
206
+ expect(mobile.container.querySelector('[data-section-nav-mode="desktop"]')).toBeNull();
207
+ expect(screen.getByTestId('section-content')).toBeTruthy();
208
+ });
209
+
210
+ it('resolves the default drawer z-index above the SHELL-3 bottom bar', () => {
211
+ renderNav({ mode: 'mobile' });
212
+ fireEvent.click(screen.getByRole('button', { name: /Section Profile/i }));
213
+ const drawer = document.querySelector('.MuiDrawer-root');
214
+ const drawerZIndex = Number(getComputedStyle(drawer).zIndex);
215
+
216
+ expect(drawerZIndex).toBe(theme.zIndex.drawer + 3);
217
+ expect(drawerZIndex).toBeGreaterThan(theme.zIndex.drawer + 2);
218
+ });
219
+
220
+ it('follows surface and divider tokens under both createAppTheme and plain MUI themes', () => {
221
+ const themes = [
222
+ createAppTheme({
223
+ palette: {
224
+ primary: { main: '#123456' },
225
+ background: { paper: '#F1F2F3' },
226
+ divider: '#445566',
227
+ },
228
+ }),
229
+ createTheme({
230
+ palette: {
231
+ primary: { main: '#654321' },
232
+ background: { paper: '#E7E8E9' },
233
+ divider: '#665544',
234
+ },
235
+ }),
236
+ ];
237
+ const expected = [
238
+ { surface: 'rgb(241, 242, 243)', divider: 'rgb(68, 85, 102)' },
239
+ { surface: 'rgb(231, 232, 233)', divider: 'rgb(102, 85, 68)' },
240
+ ];
241
+
242
+ themes.forEach((currentTheme, index) => {
243
+ const { container } = renderNav({}, currentTheme);
244
+ const groupPaper = container.querySelector('.MuiPaper-root');
245
+ const groupHeader = groupPaper.querySelector('[id]');
246
+
247
+ expect(getComputedStyle(groupPaper).backgroundColor).toBe(expected[index].surface);
248
+ expect(getComputedStyle(groupHeader).borderBottomColor).toBe(expected[index].divider);
249
+ cleanup();
250
+ });
251
+ });
252
+
253
+ it('keeps the promoted drawer dimensions and safe-area declaration', () => {
254
+ expect(SECTION_NAV_DRAWER_PAPER_SX).toMatchObject({
255
+ borderTopLeftRadius: 18,
256
+ borderTopRightRadius: 18,
257
+ maxHeight: '78dvh',
258
+ p: 2,
259
+ pb: 'max(16px, env(safe-area-inset-bottom))',
260
+ });
261
+ });
262
+
263
+ it('ships every component-owned chrome string in all supported locales', () => {
264
+ expect(Object.keys(sectionNavTranslations)).toEqual([
265
+ 'SectionNav.TITLE',
266
+ 'SectionNav.TRIGGER_EYEBROW',
267
+ 'SectionNav.LAST_OPENED',
268
+ ]);
269
+ Object.values(sectionNavTranslations).forEach((translations) => {
270
+ expect(Object.keys(translations)).toEqual(['de', 'fr', 'en', 'sw']);
271
+ expect(Object.values(translations).every(Boolean)).toBe(true);
272
+ });
273
+ });
274
+ });
@@ -0,0 +1,183 @@
1
+ # AUTH-4 — The account user list on a phone
2
+
3
+ **Target repo:** `ui-core-micha` (branch `main` — this repo has no `develop`)
4
+ **Tier:** 3 (shared core)
5
+ **Review:** independent `reviewer` **and** `ui_reviewer` — both mandatory, concurrent
6
+ **Version target:** the next **minor** after `SHELL-5` — the component gains a responsive mode it did
7
+ not have, which is a capability rather than a fix. Read the published version at implementation time.
8
+ **Prototype:** `work-orders/assets/AUTH-4-user-list-card.html` — the **composition** spec. The
9
+ two-width rendered side-by-side is therefore a commit gate.
10
+ **Sequencing:** **after `SHELL-5`.** Both are ucm work orders on the same screen; `SHELL-5` changes how
11
+ the users section is *reached*, this changes what is *in* it.
12
+ **Strand:** split out of `DS-11` by operator decision, because its fix class differs from the
13
+ navigation shell's.
14
+
15
+ ---
16
+
17
+ ## A. Envelope
18
+
19
+ ### Goal
20
+
21
+ `UserListComponent`'s table is right for a desktop and unusable on a phone. Give it a card layout
22
+ below `md`. Above `md` **nothing changes**.
23
+
24
+ ### The driver, measured 2026-08-11 in cockpit
25
+
26
+ All figures at `innerWidth` **411** CSS px — the mobile preset yields 411, not 375, so at the real
27
+ target every figure is worse.
28
+
29
+ | | measured |
30
+ |---|---|
31
+ | Table width | **1053 px** |
32
+ | Container | **325 px** → 728 px of overflow |
33
+ | Container `overflow-x` | `auto`; **the document does not overflow** |
34
+ | Columns | Email 220, Name 180, New 90, Successful Login 163, Role 180, Actions 220 |
35
+ | Cell padding | `10px 16px` — already the baseline's `density.tableCellPadding` |
36
+
37
+ **This is not a broken layout.** The container scrolls and the page does not. It is a desktop table
38
+ shown unchanged on a phone, with Role and Actions — the two things the screen exists for — entirely
39
+ outside the view.
40
+
41
+ **The cheap fix was tested and does not work.** 253 px of that width is `New` (90) and
42
+ `Successful Login` (163), both **default-on props an app can switch off today** —
43
+ `AccountPage` forwards all of them (`AccountPage.jsx:238-240`), and cockpit simply left the defaults.
44
+ But the four remaining columns are Email 220 + Name 180 + Role 180 + Actions 220 = **800 px** against
45
+ a 325 px container. **Configuration cannot fix this; only structure can.** That is now established
46
+ rather than assumed.
47
+
48
+ `SHELL-2` measured 1045 px on 2026-08-08 and it is 1053 px today (+8 px), so the number is stable —
49
+ `THEME-4`/`THEME-5` did not move the rendered density.
50
+
51
+ ### Why cards and not a scrolling table
52
+
53
+ The component is **interactive per row**: the role is an inline `Select`
54
+ (`UserListComponent.jsx:358-364`), there is a delete action plus app-supplied `extraRowActions`
55
+ (`:425-451`), a search field (`:506`), sortable columns (`:541`) and pagination. An administrator
56
+ opens this screen to **change a role** or **remove someone**. Any layout that puts those behind
57
+ horizontal scrolling defeats the screen's purpose — which is why a sticky-first-column table was
58
+ considered and rejected: it makes the defect tolerable instead of fixing it.
59
+
60
+ ### Scope
61
+
62
+ Below `md`, one card per user. The prototype is the composition spec; **four decisions in it are
63
+ load-bearing and must not be quietly re-decided:**
64
+
65
+ 1. **The email is the card title, the name is secondary.** The email is what an administrator
66
+ searches by and the only guaranteed-present field. A user without a name gets a **muted
67
+ placeholder**, not an empty line. Verified in the prototype: a 47-character address wraps to two
68
+ lines via `overflow-wrap: anywhere` without overflowing a 357 px card.
69
+ 2. **The two boolean columns become labelled chips.** In the table they are icon-only with a
70
+ `Tooltip` (`:187-195`) — and **a tooltip is unreachable on a touch pointer**, so the information is
71
+ effectively absent on the device this work order is about. Chips carry the words. This is a
72
+ deliberate small improvement over strict parity, not drift, and it is why the flags cost vertical
73
+ space instead of being dropped.
74
+ 3. **Role and delete sit in a footer behind a divider.** They are the actions; separating them keeps
75
+ the card's top half purely identifying. The select fills the remaining width so its value is never
76
+ truncated; delete keeps the full 44 px touch target.
77
+ 4. **Sorting needs its own control.** A card list has no column headers to click, so the
78
+ sortable-column affordance **disappears with the table**. A compact sort control sits beside the
79
+ search field. Without it the mobile view silently loses a capability the desktop has — the kind of
80
+ regression that is invisible in a diff and that no test would catch unless it is asked for. It is
81
+ asked for here.
82
+
83
+ **What the app supplies must keep working**, since the column set is app-configurable:
84
+
85
+ | Input | In the card |
86
+ |---|---|
87
+ | `extraColumns` | rendered as **label: value** rows below the chips, in the given order. **No new required field** on the column definition — an app that passes columns today keeps working unchanged. |
88
+ | `extraRowActions` | join delete in the footer. **Beyond three actions total they must collapse into an overflow menu**, or the footer wraps and the card loses its shape. |
89
+ | `showNewColumn` / `showSuccessfulLoginColumn` / `showRoleColumn` | chips (or the role select) when on, **absent** when off — a shorter card, not an empty chip row. |
90
+
91
+ ### Non-goals / do not touch
92
+
93
+ - **The table above `md`.** Byte-identical. Six columns are scannable at 1136 px and the strip beside
94
+ them has 570 px of headroom.
95
+ - **The column defaults.** `New` and `Successful Login` stay default-on. Flipping them would be a
96
+ silent change to the account screen in every app — a separate decision, not a side effect of this
97
+ work order.
98
+ - **The delete confirmation dialog**, the invite flow, the role list an app supplies, and every other
99
+ account section.
100
+ - **The section navigation** — that is `SHELL-5`.
101
+ - No permission, data-contract or API change. No new required prop. No baseline token change: the
102
+ card consumes tokens and introduces none, so there is **no token delta list**.
103
+ - No pagination or search redesign; both stay, above the list.
104
+
105
+ ### Risks
106
+
107
+ - **`AccountPage` is mounted by at least ten apps** — bigler-consult, cockpit, fitness-monitor,
108
+ hpc-bridge, hram, innoservice, jg-ferien, kerzenziehen, reimbursements. The account screen's user
109
+ list changes on a phone everywhere at once, and there is no staging net: a version bump on `main`
110
+ publishes. The two reviews and the rendered gate carry it.
111
+ - **Two renderings to keep in step.** From now on a change to a column must be made twice, or the
112
+ card and the table drift. Tests 3 and 4 below are what make the drift visible.
113
+ - **A long list of cards is a long scroll.** Mitigated by the existing search and pagination, not by
114
+ this work order — and worth watching in an app with many users rather than asserting it is fine.
115
+ - **`extraRowActions` beyond three** is the shape's real limit. The overflow-menu rule is in scope
116
+ precisely so the limit is handled rather than discovered.
117
+
118
+ ### Required tests to WRITE
119
+
120
+ 1. Below `md` the card list renders and no `<table>` is present; at `md` and above the table renders
121
+ and no cards are. **Prove both directions** — a one-sided test passes on a component that never
122
+ switches.
123
+ 2. A user without a name renders the muted placeholder, not an empty element.
124
+ 3. `extraColumns` appear as label/value rows in the card, in the given order, and still as columns in
125
+ the table.
126
+ 4. `extraRowActions`: three or fewer render inline in the footer; **more than three collapse into an
127
+ overflow menu**.
128
+ 5. Each of the three column toggles set to `false` removes its chip from the card **and** its column
129
+ from the table — no empty chip row.
130
+ 6. The mobile sort control changes the order, and the sortable column headers still work above `md`.
131
+ 7. Pagination and search still function in the card mode.
132
+ 8. `THEME-4`'s shadowing check still returns no findings — any `sx` the card introduces must not
133
+ duplicate a baseline `styleOverrides` key. The card uses `MuiCard`, which the baseline styles, so
134
+ this is a live risk rather than a formality.
135
+
136
+ Plus `tsc -p tsconfig.build.json --noEmit` clean. **No full-suite run** — affected set is
137
+ `UserListComponent`'s spec, `AccountPage`'s, and the theme spec touched by test 8.
138
+
139
+ ### Verification
140
+
141
+ The **two-width rendered side-by-side at 375 px and 1280 px** against the prototype, per the standing
142
+ gate. Measure, do not eyeball, the number this work order exists to fix: **at 375 px no element may
143
+ exceed its container**, and the 728 px of overflow must be gone. At 1280 px the table must be
144
+ unchanged — compare it against the prototype's section 3, which exists so that "unchanged" is part of
145
+ the spec rather than an omission.
146
+
147
+ State whether real screenshots were obtained or DOM/computed-style inspection substituted. Capture has
148
+ failed repeatedly in this estate; the substitution is declared, not silent.
149
+
150
+ ### Parity guardrail
151
+
152
+ Presentation only, with the one stated exception: the boolean flags gain visible labels because their
153
+ tooltip is unreachable on touch. Every datum the table shows, the card shows. No behaviour, no
154
+ permissions, no data contract, no routing.
155
+
156
+ ---
157
+
158
+ ## B. Implementation map
159
+
160
+ *Filled by the Orchestrator on `git pull` — see `AGENTS.md` → "Work Order".*
161
+
162
+ ### Execution directive (read this first)
163
+
164
+ > **If you are the implementer reading this work order as your own specification: this section is
165
+ > NOT addressed to you.** It tells the Orchestrator how to invoke you. **You ARE that invocation —
166
+ > do NOT shell out to `codex exec`.**
167
+ >
168
+ > Implement through `codex exec` in the background — invoked directly via Bash (never the
169
+ > `debugger`/`*_coder` Agent wrappers) with BOTH flags `--skip-git-repo-check` and
170
+ > `--dangerously-bypass-approvals-and-sandbox`. **Pass the WO via stdin, not as a positional
171
+ > argument** (a positional arg hits Windows' command-line length limit — proven repeatedly in this
172
+ > repo). Fallback to direct Claude implementation only on Codex quota/rate-limit/non-zero exit — and
173
+ > note that the fallback flips authorship, so both reviewers stay mandatory either way.
174
+ >
175
+ > **Read `git log origin/main..HEAD` and `git status`, and re-read this WO's register row, before your
176
+ > own review.** Across four ucm work orders the implementer produced an invalid review claim three
177
+ > times — twice by self-committing with a self-report, once by writing a **fabricated** verdict into
178
+ > `WORK_ORDERS.md`. The preamble now forbids editing that file; verify it was respected.
179
+
180
+ ### Mini-handover
181
+
182
+ Repo: `ui-core-micha` (`C:\Users\biglmi\Documents\webapps\ui-core-micha`), branch `main`.
183
+ WO: `work-orders/AUTH-4.md`. **Run after `SHELL-5`.** Follow `orchestrate-codex`.