feat(layout): Plan 2.5 P6 — final shell parity (Fix 7, 9, 10)

Per RFC-WS-PRIMEVUE-PLAN-2-5 §5.6–§5.10. Final code phase of Plan 2.5
before closure docs (P7 tokens, P8 closure).

Changes:
- Fix 9: sidebar full-height. The desktop <aside> now carries
  `h-screen sticky top-0` so it fills the viewport vertically and
  pins to top on body scroll. Without this the aside sized to its
  children's intrinsic heights (~250-400px) and ended mid-viewport
  even though the surrounding grid row stretched to 100vh. With
  h-screen, SidebarNav's `flex-1` claims the remaining column space
  and WorkspaceSwitcher anchors to the true viewport bottom — its
  `border-t` (existing from P5) is now the divider above the
  switcher per crewli-starter. Mobile Drawer untouched (PrimeVue's
  internal pt classes already give it 100% panel height).
- Fix 10: density toggle promoted to the store. New
  useShellUiStore.toggleDensity() flips comfortable ⇔ compact and
  calls applyDomAttributes() synchronously. AppTopbar's local
  toggleDensity wrapper deleted; the button now invokes
  shell.toggleDensity() directly and carries a stable
  data-testid="density-toggle" plus a `title` matching its
  aria-label. Density icons swapped from generic flex-alignment
  glyphs (tabler-layout-distribute-{vertical,horizontal}) to the
  literal density metaphor (tabler-baseline-density-{small,medium}).
  Both new icons verified present in the loaded
  @iconify-json/tabler set. Topbar right-side order
  (search → density → dark → notifications → user) was already
  correct from P5; locked with a new ordering spec.

Verified (no code change):
- Fix 6 (§5.6): dark mode `.dark` on <html> confirmed in
  useShellUiStore.applyDomAttributes (AD-2.5-D1, P3 complete).
  Component-level dark coverage remains a separate backlog item
  (DARKMODE-V2-COVERAGE).
- Fix 8 (§5.8): the ▼ arrow is the Vue DevTools v8.0.2 dev-only
  toggle button injected by the devtools vite plugin, not Crewli
  code — diagnosed, no action.
- Fix 7 (§5.7): non-reproducible at code level. Topbar is
  `sticky top-0` and is a SIBLING flex item of <main> inside the
  shell's flex-col right column; normal flow stacks <main> below
  the topbar at first paint, so the title cannot fall behind a
  sticky topbar in this composition. Documented as no-op; if
  Bert reproduces it after Fix 9 lands, the symptom is something
  else (likely a per-page negative margin or a separate scroll-
  container interaction worth its own ticket).

Density enum corrected against runtime data-density: 'comfortable'
(not 'comfy' — the earlier RFC assumption is wrong; the store has
always typed `'comfortable' | 'compact'`).

Tests:
- +2 useShellUiStore.spec.ts: toggleDensity flips comfortable ⇔
  compact AND writes data-density via applyDomAttributes;
  toggleDensity from compact returns to comfortable on call 2.
- +2 AppTopbar.spec.ts: density button reachable by
  data-testid="density-toggle"; topbar right-side order locked
  via HTML index comparison (search → density → dark → notif →
  user). Existing density-flip specs adapted to spy on
  toggleDensity (the new direct call site).

Suite delta: 554 → 558 (+4). vue-tsc clean. Scoped ESLint clean
(0 errors, pre-existing warnings only).

Manual smoke pending Bert:
  1. Sidebar full-height, switcher pinned to viewport bottom (Fix 9)
  2. Page title clears topbar (Fix 7 — expected no change needed)
  3. Density toggle visible between search and dark with the
     density icon (Fix 10)
  4. Click density toggle → spacing visibly changes, <html
     data-density> flips between comfortable and compact (Fix 10)
  5. Topbar order: search → density → dark → notifications →
     avatar (Fix 10)
  6. Dark mode still toggles (Fix 6 regression)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 23:06:28 +02:00
parent 641ca5131d
commit 59439c924b
5 changed files with 108 additions and 15 deletions

View File

@@ -32,6 +32,16 @@
* is needed (two flex-1 siblings would split the column 50/50 and
* compress the nav).
*
* Plan 2.5 P6 Fix 9: explicit `h-screen` on the desktop <aside>. Without
* it the aside sized to its children's intrinsic heights (Header +
* SidebarNav-at-min-content + WorkspaceSwitcher ≈ 250-400px) and ended
* mid-viewport, even though the surrounding grid row stretched to 100vh.
* With `h-screen` the aside fills the viewport vertically, the
* SidebarNav `flex-1` expands to claim the remaining column space, and
* WorkspaceSwitcher anchors to the true bottom — matching crewli-starter.
* The mobile Drawer doesn't need this — PrimeVue Drawer sizes its content
* container to 100% of the panel height via internal pt classes.
*
* Deliberate simplification: crewli-starter's bespoke Teleport tooltip (shown in
* collapsed mode for nav items) is NOT ported here. SidebarNav already provides
* native `:title` tooltips in collapsed mode, which is functionally equivalent
@@ -81,7 +91,7 @@ const mobileVisible = computed<boolean>({
-->
<!-- desktop -->
<aside
class="hidden lg:flex flex-col overflow-hidden bg-[var(--p-surface-card)] border-e border-[var(--p-content-border-color)] z-40 transition-[width] duration-200 flex-shrink-0"
class="hidden lg:flex h-screen sticky top-0 flex-col overflow-hidden bg-[var(--p-surface-card)] border-e border-[var(--p-content-border-color)] z-40 transition-[width] duration-200 flex-shrink-0"
:class="shell.sidebarCollapsed ? 'w-16' : 'w-64'"
>
<SidebarHeader />

View File

@@ -97,12 +97,14 @@ const mobileOrgInitials = computed<string>(() => {
})
// ---------------------------------------------------------------------------
// Density toggle
// Density toggle (Plan 2.5 P6 Fix 10)
// ---------------------------------------------------------------------------
function toggleDensity(): void {
shell.setDensity(shell.density === 'comfortable' ? 'compact' : 'comfortable')
}
//
// Wiring delegates to shell.toggleDensity() (the binary comfortable ⇔ compact
// flip lives in the store so applyDomAttributes() fires synchronously).
// Icon and aria-label follow the dark-toggle convention: they describe the
// TARGET state — when current density is comfortable, the button promises
// to switch to compact and shows the small-density icon.
const densityAriaLabel = computed<string>(() =>
shell.density === 'comfortable' ? 'Switch to compact' : 'Switch to comfortable',
@@ -110,8 +112,8 @@ const densityAriaLabel = computed<string>(() =>
const densityIcon = computed<string>(() =>
shell.density === 'comfortable'
? 'tabler-layout-distribute-vertical'
: 'tabler-layout-distribute-horizontal',
? 'tabler-baseline-density-small'
: 'tabler-baseline-density-medium',
)
// ---------------------------------------------------------------------------
@@ -314,9 +316,11 @@ const userMenuItems = computed<MenuItem[]>(() => [
-->
<button
type="button"
data-testid="density-toggle"
class="inline-flex h-[38px] w-[38px] items-center justify-center rounded-[var(--p-border-radius)] border-0 bg-transparent text-[var(--p-text-muted-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-1"
:aria-label="densityAriaLabel"
@click="toggleDensity"
:title="densityAriaLabel"
@click="shell.toggleDensity()"
>
<Icon
:name="densityIcon"

View File

@@ -161,13 +161,18 @@ describe('AppTopbar', () => {
// Density toggle → setDensity with flipped value
// -------------------------------------------------------------------------
it('density toggle flips comfortable → compact', async () => {
// Plan 2.5 P6 Fix 10: the density button now delegates to the
// store's toggleDensity action (binary flip + applyDomAttributes
// in one place). aria-label still reflects the target state so
// these specs assert on the user-facing label.
it('density toggle (comfortable state) calls shell.toggleDensity()', async () => {
const wrapper = mountTopbar()
const shell = useShellUiStore()
shell.density = 'comfortable'
const spy = vi.spyOn(shell, 'setDensity')
const spy = vi.spyOn(shell, 'toggleDensity')
const densityBtn = wrapper.find('button[aria-label="Switch to compact"]')
@@ -175,16 +180,16 @@ describe('AppTopbar', () => {
await densityBtn.trigger('click')
expect(spy).toHaveBeenCalledWith('compact')
expect(spy).toHaveBeenCalledOnce()
})
it('density toggle flips compact → comfortable', async () => {
it('density toggle (compact state) calls shell.toggleDensity()', async () => {
const wrapper = mountTopbar()
const shell = useShellUiStore()
shell.density = 'compact'
const spy = vi.spyOn(shell, 'setDensity')
const spy = vi.spyOn(shell, 'toggleDensity')
await wrapper.vm.$nextTick()
@@ -194,7 +199,41 @@ describe('AppTopbar', () => {
await densityBtn.trigger('click')
expect(spy).toHaveBeenCalledWith('comfortable')
expect(spy).toHaveBeenCalledOnce()
})
// Fix 10 (Plan 2.5 P6) regression locks: density button is reachable
// by stable test selector, and the topbar right-side action order
// matches the crewli-starter reference (search → density → dark →
// notifications → user). Implementation reorders should not regress.
it('density toggle is reachable by data-testid="density-toggle"', () => {
const wrapper = mountTopbar()
expect(wrapper.find('[data-testid="density-toggle"]').exists()).toBe(true)
})
it('topbar right-side actions render in order: search → density → dark → notifications → user', () => {
const wrapper = mountTopbar()
const bar = wrapper.findComponent(MenubarStub)
const html = bar.html()
const idxSearch = html.indexOf('data-tb="search"')
const idxDensity = html.indexOf('data-testid="density-toggle"')
const idxDark = html.indexOf('aria-label="Switch to dark mode"')
const idxNotif = html.indexOf('data-tb="notifications"')
const idxUser = html.indexOf('data-tb="user"')
expect(idxSearch).toBeGreaterThanOrEqual(0)
expect(idxDensity).toBeGreaterThanOrEqual(0)
expect(idxDark).toBeGreaterThanOrEqual(0)
expect(idxNotif).toBeGreaterThanOrEqual(0)
expect(idxUser).toBeGreaterThanOrEqual(0)
expect(idxSearch).toBeLessThan(idxDensity)
expect(idxDensity).toBeLessThan(idxDark)
expect(idxDark).toBeLessThan(idxNotif)
expect(idxNotif).toBeLessThan(idxUser)
})
// -------------------------------------------------------------------------

View File

@@ -57,6 +57,33 @@ describe('useShellUiStore', () => {
s.setMobileOpen(false)
expect(s.mobileOpen).toBe(false)
})
// Plan 2.5 P6 Fix 10 — toggleDensity is the binary UI flip exposed by
// the topbar density button. Calls applyDomAttributes() so the
// <html data-density> attribute mutates synchronously (consumers
// like the AppShellV2 watcher would also fire, but the synchronous
// contract is what topbar callers rely on).
it('toggleDensity flips comfortable ⇔ compact and writes data-density', () => {
const s = useShellUiStore()
expect(s.density).toBe('comfortable')
s.toggleDensity()
expect(s.density).toBe('compact')
expect(document.documentElement.getAttribute('data-density')).toBe('compact')
s.toggleDensity()
expect(s.density).toBe('comfortable')
expect(document.documentElement.getAttribute('data-density')).toBe('comfortable')
})
it('toggleDensity from compact returns to comfortable on the second call', () => {
const s = useShellUiStore()
s.setDensity('compact')
s.toggleDensity()
expect(s.density).toBe('comfortable')
s.toggleDensity()
expect(s.density).toBe('compact')
})
})
describe('applyDomAttributes — dark mode (AD-2.5-D1)', () => {

View File

@@ -44,6 +44,18 @@ export const useShellUiStore = defineStore('shellUi', () => {
density.value = next
}
/**
* Plan 2.5 P6 Fix 10: binary UI toggle between the two density extremes
* the topbar exposes. Calls applyDomAttributes() so consumers (PrimeVue
* Aura preset + Tailwind component styles) react to the new
* <html data-density> immediately, mirroring how AppShellV2's watcher
* would update on a setDensity() call but synchronously.
*/
function toggleDensity(): void {
density.value = density.value === 'compact' ? 'comfortable' : 'compact'
applyDomAttributes()
}
function applyDomAttributes(): void {
const el = document.documentElement
@@ -79,6 +91,7 @@ export const useShellUiStore = defineStore('shellUi', () => {
setMobileOpen,
setTheme,
setDensity,
toggleDensity,
applyDomAttributes,
openDrawer,
closeDrawer,