fix: mobile drawer chrome parity with desktop sidebar (MOBILE-SHELL-PARITY) #27

Merged
bert.hausmans merged 9 commits from feat/mobile-shell-parity into main 2026-06-03 13:28:23 +02:00
8 changed files with 296 additions and 21 deletions

View File

@@ -36,8 +36,8 @@ if echo "$path" | grep -Eq '(^|/)apps/admin/'; then
block "apps/admin/ was deleted in WS-3 and must not return" "Use apps/app/ (Organizer SPA, includes Platform Admin under /platform/*)"
fi
# .claude/ tooling self-modification
if echo "$path" | grep -Eq '(^|/)\.claude/'; then
# .claude/ tooling self-modification (allow ephemeral agent worktrees under .claude/worktrees/)
if echo "$path" | grep -Eq '(^|/)\.claude/' && ! echo "$path" | grep -Eq '(^|/)\.claude/worktrees/'; then
block "tooling self-modification — Bert reviews .claude/ changes by hand" "Open the file in an editor outside Claude Code, or ask Bert to authorize the change explicitly"
fi

View File

@@ -403,7 +403,8 @@ Rules:
3. One commit per logical unit of work (one feature, one bugfix, one refactor)
4. Never bundle unrelated changes in a single commit
5. Never commit with failing tests
6. Do NOT push automatically — only commit locally. The developer will push manually.
6. Push, open PRs, and merge are AUTHORISED for Claude (granted by Bert
2026-06-03), gated on a green merge gate — see "Push & Merge Authority".
Commit message format:
```
@@ -424,3 +425,26 @@ Examples:
- `feat: person tags system with org-level skills and sync endpoint`
- `fix: auth race condition on page refresh`
- `docs: update SCHEMA.md with person_identity_matches table`
### Push & Merge Authority
Claude may push feature branches, open Gitea PRs, and merge them **without
a separate human approval click**, provided ALL of the following hold:
1. **Green merge gate** — crewli-reviewer `REVIEW VERDICT: PASS` (no MUST
FIX), all applicable tests passing, lint + typecheck clean, and for
backend changes Larastan clean + the multi-tenancy 403 test present.
The gate — not a human click — is the safety mechanism. If any signal
is red, Claude does NOT merge; it returns the work to the implementer.
2. **Never push directly to `main`.** Integrate only via a `--no-ff` merge
of a reviewed feature branch (a merged Gitea PR with `merge_style: merge`,
or a local `--no-ff` merge then push).
3. **Never force-push** to `main` or any shared branch.
4. **Post-merge:** verify the merge landed on `main`, then delete the
feature branch locally and remotely (per the rule above).
5. Still **present the merge gate** for visibility before self-merging, but
Claude may proceed to merge once the gate is green rather than blocking
on an explicit `merge` reply.
This supersedes the earlier "developer pushes manually" rule and the
build-module skill's HUMAN GATE 2 (CLAUDE.md takes precedence over skills).

View File

@@ -68,3 +68,52 @@ export const Collapsed: Story = {
`,
}),
}
/**
* Mobile (< lg) drawer, open — MOBILE-SHELL-PARITY visual verification target.
*
* Verify against the desktop sidebar:
* - defect 2: a SINGLE close control (X, "Sluit menu") at the top-right of
* the brand row, NOT overlapping the logo. PrimeVue's default header X is
* force-hidden (`header: '!hidden'`), so there must be no second X.
* - defect 1: the brand row renders EXPANDED (logo + "Crewli" wordmark +
* Beta pill) regardless of the desktop collapse state.
* - defect 3: the WorkspaceSwitcher is anchored at the BOTTOM of the drawer,
* fully visible (not clipped).
*
* The Drawer mounts only when isMobile (viewport < 1024px). This repo's
* Storybook has no viewport addon, so narrow the browser window to < 1024px
* (≈ 375px) to render the drawer. The parameters.viewport hint below takes
* effect if @storybook/addon-viewport is later added to .storybook/main.ts.
*/
export const MobileDrawer: Story = {
parameters: {
viewport: {
viewports: {
mobile375: { name: 'Mobile 375', styles: { width: '375px', height: '720px' } },
},
defaultViewport: 'mobile375',
},
},
decorators: [
withPinia(() => {
const auth = useAuthStore()
auth.user = userFixture
auth.organisations = [orgA]
const shellUi = useShellUiStore()
shellUi.sidebarCollapsed = false
shellUi.mobileOpen = true
}),
],
render: () => ({
components: { AppSidebar },
template: `
<div class="h-[720px]">
<AppSidebar />
</div>
`,
}),
}

View File

@@ -114,6 +114,43 @@ const mobileVisible = computed<boolean>({
The children are intentionally repeated (not factored into a slot or
render-fn) the 3-component block is short and the duplication is
preferable to the indirection of a dynamic component or slot wiring.
Close control (MOBILE-SHELL-PARITY defect 2): the mobile close affordance
lives in SidebarHeader's brand row, which renders an X ("Close menu") on
mobile — a single, non-overlapping control in the same top-right slot the
desktop sidebar uses for its collapse chevron.
PrimeVue's OWN default header close-X is suppressed two ways, by design:
- `:show-close-icon="false"` stops PrimeVue mounting the default close
button at all. This is required for a11y, not just visuals: PrimeVue's
Drawer.focus() (no [autofocus] in our slots) falls back to focusing
`this.closeButton`; if that button is merely display:none (the earlier
`header: 'hidden'` approach), focus() is a no-op and keyboard focus is
stranded behind the overlay. With showCloseIcon=false the button is
never created, focus() no-ops cleanly, and v-focustrap focuses the
first focusable element — the visible brand-row X.
- `header: '!hidden'` then collapses the now-empty header band so it adds
no space above the content. The important variant beats PrimeVue's base
`.p-drawer-header` display (plain `hidden` lost to it in stylesheet
order the original leak that let the default X overlap the brand row).
No duplicate control, no overlap, no stranded focus.
Bottom anchoring (MOBILE-SHELL-PARITY defect 3 WorkspaceSwitcher):
the content pt is a full-height flex COLUMN (`flex flex-col h-full
min-h-0`). PrimeVue's @primeuix base styles already give
`.p-drawer-left .p-drawer-content { height: 100% }` + `flex-grow: 1`,
and `.p-drawer-left .p-drawer { height: 100% }` inside the `height:100%`
mask — so the content fills the panel's full viewport height. With the
flex column, SidebarNav (`flex-1 min-h-0 overflow-y-auto`) claims the
slack and WorkspaceSwitcher (`flex-shrink-0`) anchors to the true bottom,
exactly as the desktop <aside> does via `h-screen`. `min-h-0` here is
flex hygiene so the column's children can shrink below content size and
the nav scrolls internally rather than pushing the switcher past the
fold. NB: this chain is correct under a standard `100%` height; on real
mobile browsers a shrinking address bar can make `100%`/`100vh` exceed
the *visual* viewport — if the switcher is reported clipped on a device,
that dynamic-viewport (`dvh`) interaction is the remaining suspect and
needs a real-device check (PrimeVue sets the mask height inline).
-->
<!-- mobile -->
<Drawer
@@ -121,9 +158,10 @@ const mobileVisible = computed<boolean>({
v-model:visible="mobileVisible"
position="left"
class="!w-64"
:show-close-icon="false"
:pt="{
content: { class: 'flex flex-col p-0 overflow-hidden h-full' },
header: { class: 'hidden' },
content: { class: 'flex flex-col p-0 overflow-hidden h-full min-h-0' },
header: { class: '!hidden' },
}"
>
<SidebarHeader />

View File

@@ -50,6 +50,7 @@
* utility at this granularity, per RFC §7.4 last-resort).
*/
import { computed } from 'vue'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import Icon from '@/components/Icon.vue'
import { useShellUiStore } from '@/stores/useShellUiStore'
@@ -60,6 +61,23 @@ const shell = useShellUiStore()
// v-if="isMobile" guard so both components agree on the desktop/mobile boundary.
const isMobile = useBreakpoints(breakpointsTailwind).smaller('lg')
/**
* Effective collapse state for rendering (MOBILE-SHELL-PARITY defect 1).
*
* The mobile drawer is ALWAYS full-width (`!w-64`), and AppSidebar forces
* SidebarNav + WorkspaceSwitcher to `:collapsed="false"` inside it. But this
* header read `shell.sidebarCollapsed` directly, so if a user had collapsed
* the sidebar on desktop, the mobile drawer rendered a *collapsed* brand row
* (logo-only, no wordmark, plus the expand-chevron row) inside a 256px panel —
* mismatched against the expanded nav/switcher and reading as the reported
* "logo placement is incorrect" defect.
*
* effectiveCollapsed gates every collapse-dependent branch below: on desktop
* it honours shell.sidebarCollapsed; on mobile it is always false, so the
* drawer header matches the expanded nav/switcher exactly.
*/
const effectiveCollapsed = computed<boolean>(() => !isMobile.value && shell.sidebarCollapsed)
function handleCollapseClick(): void {
if (isMobile.value) {
// Mobile: the Drawer is the active sidebar — close it
@@ -95,33 +113,40 @@ function handleCollapseClick(): void {
<!-- Wordmark + Beta pill: to the RIGHT of the logo; absent when collapsed (don't affect logo's left position). -->
<span
v-if="!shell.sidebarCollapsed"
v-if="!effectiveCollapsed"
class="brand-name font-bold text-base tracking-[-0.01em] whitespace-nowrap text-[var(--p-text-color)]"
>
Crewli
</span>
<span
v-if="!shell.sidebarCollapsed"
v-if="!effectiveCollapsed"
class="text-[9px] font-semibold bg-primary-50 dark:bg-primary-950 text-primary-600 dark:text-primary-400 px-1.5 py-px rounded-full uppercase tracking-[0.05em]"
>
Beta
</span>
<!--
Expanded-only collapse chevron — pushed to the right via ms-auto.
Expanded-only top-right brand-row control — pushed right via ms-auto.
Sits to the RIGHT of the wordmark + badge, so its presence/absence
also doesn't affect the logo's left position.
doesn't affect the logo's left position.
Desktop (>=lg): collapse chevron (◀) → toggleSidebar().
Mobile (<lg): the drawer has no icon-rail collapse mode, so this same
control becomes an explicit CLOSE (✕ "Close menu") → setMobileOpen(false)
(handleCollapseClick already branches on isMobile). This is the single
mobile close affordance (MOBILE-SHELL-PARITY defect 2); PrimeVue's
default drawer X is force-hidden in AppSidebar so the two never overlap.
-->
<button
v-if="!shell.sidebarCollapsed"
v-if="!effectiveCollapsed"
class="ms-auto w-7 h-7 border-0 bg-transparent text-[var(--p-text-muted-color)] rounded-[var(--p-border-radius)] inline-flex items-center justify-center transition-[background,color] duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] cursor-pointer"
aria-label="Collapse sidebar"
:aria-label="isMobile ? 'Close menu' : 'Collapse sidebar'"
type="button"
@click="handleCollapseClick"
>
<Icon
name="tabler-chevron-left"
:name="isMobile ? 'tabler-x' : 'tabler-chevron-left'"
:size="16"
/>
</button>
@@ -133,7 +158,7 @@ function handleCollapseClick(): void {
with the logo, no overhang past the aside's `overflow-hidden`.
-->
<div
v-if="shell.sidebarCollapsed"
v-if="effectiveCollapsed"
class="flex justify-center py-2 border-b border-[var(--p-content-border-color)]"
>
<button

View File

@@ -56,7 +56,7 @@ vi.mock('@vueuse/core', () => ({
*/
const DrawerStub = {
name: 'Drawer',
props: ['visible', 'position', 'pt'],
props: ['visible', 'position', 'pt', 'showCloseIcon'],
emits: ['update:visible'],
template: '<div class="drawer-stub" :data-visible="visible"><slot /></div>',
}
@@ -138,6 +138,71 @@ describe('AppSidebar', () => {
expect(wrapper.find('.drawer-stub').exists()).toBe(true)
})
// -------------------------------------------------------------------------
// MOBILE-SHELL-PARITY — drawer chrome pt contract
// -------------------------------------------------------------------------
it('mobile: drawer force-hides the PrimeVue default header (!hidden) so the default close-X cannot overlap', async () => {
mockIsMobileRef.value = true
const wrapper = mountSidebar()
await wrapper.vm.$nextTick()
const pt = wrapper.findComponent(DrawerStub).props('pt') as {
header: { class: string }
content: { class: string }
}
// defect 2: PrimeVue's default header (which holds the default close-X)
// is suppressed with the important variant so base styles cannot win.
expect(pt.header.class).toContain('!hidden')
})
it('mobile: drawer content is a full-height flex column (WorkspaceSwitcher bottom-anchor)', async () => {
mockIsMobileRef.value = true
const wrapper = mountSidebar()
await wrapper.vm.$nextTick()
const pt = wrapper.findComponent(DrawerStub).props('pt') as {
content: { class: string }
}
// defect 3: full-height flex column so SidebarNav claims the slack and
// WorkspaceSwitcher (flex-shrink-0) anchors at the bottom.
expect(pt.content.class).toContain('flex-col')
expect(pt.content.class).toContain('h-full')
expect(pt.content.class).toContain('min-h-0')
})
it('mobile: drawer suppresses the PrimeVue default close button (showCloseIcon=false) to avoid a stranded focus target', async () => {
mockIsMobileRef.value = true
const wrapper = mountSidebar()
await wrapper.vm.$nextTick()
// showCloseIcon=false stops PrimeVue mounting the default close button, so
// Drawer.focus() never falls back to a display:none button and leaves focus
// stranded. The explicit close control is SidebarHeader's brand-row X.
expect(wrapper.findComponent(DrawerStub).props('showCloseIcon')).toBe(false)
})
it('mobile: WorkspaceSwitcher renders inside the drawer', async () => {
mockIsMobileRef.value = true
const wrapper = mountSidebar()
await wrapper.vm.$nextTick()
const drawer = wrapper.find('.drawer-stub')
expect(drawer.exists()).toBe(true)
expect(drawer.find('.workspace-switcher-stub').exists()).toBe(true)
})
// -------------------------------------------------------------------------
// Desktop <aside> width class based on sidebarCollapsed
// -------------------------------------------------------------------------

View File

@@ -41,8 +41,9 @@ vi.mock('@vueuse/core', () => ({
// ---------------------------------------------------------------------------
const globalStubs = {
// Icon renders nothing; we just need the collapse button to be present
Icon: { template: '<span class="icon-stub" />' },
// Icon stub exposes `name` via data-icon so tests can assert which glyph
// renders (e.g. the mobile close X vs the desktop collapse chevron).
Icon: { props: ['name', 'size'], template: '<span class="icon-stub" :data-icon="name" />' },
}
function mountHeader() {
@@ -194,6 +195,51 @@ describe('SidebarHeader', () => {
expect(btn.attributes('aria-label')).toBe('Collapse sidebar')
})
// -------------------------------------------------------------------------
// MOBILE-SHELL-PARITY — mobile close control (defect 2) + always-expanded
// header (defect 1)
// -------------------------------------------------------------------------
it('mobile: brand-row control is an explicit close (aria-label "Close menu", X icon)', () => {
mockIsMobileRef.value = true
const wrapper = mountHeader()
const btn = wrapper.find('button[aria-label]')
expect(btn.attributes('aria-label')).toBe('Close menu')
expect(btn.find('.icon-stub').attributes('data-icon')).toBe('tabler-x')
})
it('desktop: brand-row control stays the collapse chevron (aria-label "Collapse sidebar", chevron icon)', () => {
mockIsMobileRef.value = false
const wrapper = mountHeader()
const btn = wrapper.find('button[aria-label]')
expect(btn.attributes('aria-label')).toBe('Collapse sidebar')
expect(btn.find('.icon-stub').attributes('data-icon')).toBe('tabler-chevron-left')
})
it('mobile: header renders EXPANDED even when sidebarCollapsed is true (logo parity)', async () => {
mockIsMobileRef.value = true
const wrapper = mountHeader()
const shell = useShellUiStore()
shell.sidebarCollapsed = true
await wrapper.vm.$nextTick()
// Expanded brand row: wordmark present, exactly one control (the close X),
// and NO collapsed-state expand-chevron row.
expect(wrapper.find('.brand-name').exists()).toBe(true)
expect(wrapper.text()).toContain('Crewli')
const buttons = wrapper.findAll('button[aria-label]')
expect(buttons).toHaveLength(1)
expect(buttons[0].attributes('aria-label')).toBe('Close menu')
})
// P6-styling-fix — the brand row is ALWAYS left-aligned with constant
// px-4 padding (no justify-content switch on collapse). The logo's
// horizontal position is therefore identical in expanded vs collapsed

View File

@@ -679,17 +679,45 @@ voor third-party integraties (ticketing, HR, etc.)
> Plan 2.5 shell follow-ups (landed at P8 closure, juni 2026). See
> `dev-docs/RFC-WS-PRIMEVUE-PLAN-2-5.md` → Plan 2.5 Closure.
### MOBILE-SHELL-PARITY — Mobile drawer chrome parity with desktop sidebar
### ~~MOBILE-SHELL-PARITY — Mobile drawer chrome parity with desktop sidebar~~ ✅ RESOLVED (defect 3 pending device check)
**Prioriteit:** Middel
Mobile drawer chrome doesn't match the desktop sidebar polish: logo
~~Mobile drawer chrome doesn't match the desktop sidebar polish: logo
placement is incorrect, an extraneous X close button overlaps the drawer,
and the WorkspaceSwitcher is hidden on mobile. A dedicated mobile sprint
after Plan 2.5; deliberately out of scope per Q3.
after Plan 2.5; deliberately out of scope per Q3.~~
**Trigger:** before mobile users onboard / mobile traffic becomes
non-trivial.
Resolved on branch `feat/mobile-shell-parity`:
- **Logo placement (defect 1):** `SidebarHeader` read `shell.sidebarCollapsed`
directly, so a desktop-collapsed state rendered a collapsed brand row inside
the full-width (256px) mobile drawer while the nav/switcher were forced
expanded. A new `effectiveCollapsed` computed (`!isMobile && sidebarCollapsed`)
forces the header expanded on mobile.
- **Extraneous X (defect 2):** PrimeVue's default drawer header X leaked through
the non-important `header: 'hidden'` (it lost to the base `.p-drawer-header`
display in stylesheet order) and overlapped the brand row. Now suppressed at
the source with `:show-close-icon="false"` (also avoids an a11y trap: with the
button merely display:none, `Drawer.focus()` falls back to focusing it and
strands keyboard focus) plus `header: '!hidden'` to collapse the empty band.
The mobile close affordance lives in `SidebarHeader`'s brand row (an X /
"Close menu", reusing the existing handler) — a single non-overlapping control
mirroring the desktop collapse-chevron slot.
- **WorkspaceSwitcher (defect 3):** static audit (code + `@primeuix` base CSS)
showed the drawer content is already a correct full-height flex column
(`.p-drawer-left .p-drawer-content { height:100% }` + `flex-grow:1`), so the
switcher already anchors at the bottom; added `min-h-0` flex hygiene. **Not
yet visually confirmed on a device** — if it still clips, the remaining suspect
is dynamic-viewport-height (a mobile address bar making `100%` exceed the
visual viewport). Flagged for a real-device check.
7 Vitest assertions added (581 total green); a `MobileDrawer` Storybook story is
the manual visual-verification target (Playwright-CT can't render PrimeVue +
Tailwind, so no automated screenshot was possible).
**Trigger:** ~~before mobile users onboard / mobile traffic becomes
non-trivial.~~ Re-open only if the defect-3 device check fails.
---