diff --git a/apps/app/src/components-v2/layout/AppSidebar.vue b/apps/app/src/components-v2/layout/AppSidebar.vue
new file mode 100644
index 00000000..a928c6ec
--- /dev/null
+++ b/apps/app/src/components-v2/layout/AppSidebar.vue
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app/src/components-v2/layout/SidebarHeader.vue b/apps/app/src/components-v2/layout/SidebarHeader.vue
new file mode 100644
index 00000000..64cff966
--- /dev/null
+++ b/apps/app/src/components-v2/layout/SidebarHeader.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+ C
+
+
+
+
+ Crewli
+
+
+
+ Beta
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app/src/components-v2/layout/__tests__/AppSidebar.spec.ts b/apps/app/src/components-v2/layout/__tests__/AppSidebar.spec.ts
new file mode 100644
index 00000000..2dbb7fd6
--- /dev/null
+++ b/apps/app/src/components-v2/layout/__tests__/AppSidebar.spec.ts
@@ -0,0 +1,167 @@
+/**
+ * AppSidebar.spec.ts — unit tests for AppSidebar composition and mobile wiring.
+ *
+ * Strategy: mount with @vue/test-utils stubs for all heavy children (SidebarHeader,
+ * SidebarNav, WorkspaceSwitcher, Drawer) so we test only:
+ * 1. Renders the 3 child components (SidebarHeader, SidebarNav, WorkspaceSwitcher).
+ * 2. Passes `groups` prop to SidebarNav.
+ * 3. Mobile Drawer v-model:visible wires to shell.mobileOpen (get path).
+ * 4. Drawer close (v-model:visible = false) calls shell.setMobileOpen(false).
+ *
+ * Stubs: Drawer is stubbed with a simple slot passthrough so we can inspect
+ * whether its `visible` prop is correctly bound to the store.
+ */
+
+import { createPinia, setActivePinia } from 'pinia'
+import { mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { useShellUiStore } from '@/stores/useShellUiStore'
+import AppSidebar from '@/components-v2/layout/AppSidebar.vue'
+import type { V2NavGroup } from '@/types/v2/nav'
+
+// ---------------------------------------------------------------------------
+// A minimal nav group fixture
+// ---------------------------------------------------------------------------
+
+const testGroups: V2NavGroup[] = [
+ {
+ label: 'Main',
+ items: [
+ { id: 'dashboard', label: 'Dashboard', icon: 'tabler-home', to: { name: 'dashboard' } },
+ ],
+ },
+]
+
+// ---------------------------------------------------------------------------
+// Stubs
+// ---------------------------------------------------------------------------
+
+/**
+ * DrawerStub exposes the same `visible` prop as PrimeVue Drawer and renders
+ * its default slot so child components are mounted. It also emits
+ * `update:visible` when the close method would fire, which mirrors the
+ * v-model:visible contract.
+ */
+const DrawerStub = {
+ name: 'Drawer',
+ props: ['visible', 'position', 'pt'],
+ emits: ['update:visible'],
+ template: '
',
+}
+
+const globalStubs = {
+ Drawer: DrawerStub,
+ SidebarHeader: { name: 'SidebarHeader', template: '' },
+ SidebarNav: {
+ name: 'SidebarNav',
+ props: ['groups', 'collapsed'],
+ template: '',
+ },
+ WorkspaceSwitcher: {
+ name: 'WorkspaceSwitcher',
+ props: ['collapsed'],
+ template: '
',
+ },
+}
+
+function mountSidebar(groups: V2NavGroup[] = testGroups) {
+ return mount(AppSidebar, {
+ props: { groups },
+ global: {
+ plugins: [createPinia()],
+ stubs: globalStubs,
+ },
+ })
+}
+
+describe('AppSidebar', () => {
+ beforeEach(() => setActivePinia(createPinia()))
+
+ // -------------------------------------------------------------------------
+ // Child composition
+ // -------------------------------------------------------------------------
+
+ it('renders SidebarHeader', () => {
+ const wrapper = mountSidebar()
+
+ expect(wrapper.find('.sidebar-header-stub').exists()).toBe(true)
+ })
+
+ it('renders SidebarNav', () => {
+ const wrapper = mountSidebar()
+
+ expect(wrapper.find('.sidebar-nav-stub').exists()).toBe(true)
+ })
+
+ it('renders WorkspaceSwitcher', () => {
+ const wrapper = mountSidebar()
+
+ expect(wrapper.find('.workspace-switcher-stub').exists()).toBe(true)
+ })
+
+ it('passes groups prop to SidebarNav', () => {
+ const wrapper = mountSidebar()
+ const nav = wrapper.find('.sidebar-nav-stub')
+
+ // Our stub renders data-groups-count from the groups prop
+ expect(nav.attributes('data-groups-count')).toBe(String(testGroups.length))
+ })
+
+ // -------------------------------------------------------------------------
+ // Mobile Drawer wiring
+ // -------------------------------------------------------------------------
+
+ it('Drawer visible is true when shell.mobileOpen is true', async () => {
+ const wrapper = mountSidebar()
+ const shell = useShellUiStore()
+
+ shell.mobileOpen = true
+
+ await wrapper.vm.$nextTick()
+
+ // The Drawer stub renders data-visible from its visible prop
+ const drawer = wrapper.find('.drawer-stub')
+
+ expect(drawer.attributes('data-visible')).toBe('true')
+ })
+
+ it('Drawer visible is false when shell.mobileOpen is false', async () => {
+ const wrapper = mountSidebar()
+ const shell = useShellUiStore()
+
+ shell.mobileOpen = false
+
+ await wrapper.vm.$nextTick()
+
+ const drawer = wrapper.find('.drawer-stub')
+
+ expect(drawer.attributes('data-visible')).toBe('false')
+ })
+
+ it('emitting update:visible false from Drawer calls shell.setMobileOpen(false)', async () => {
+ const wrapper = mountSidebar()
+ const shell = useShellUiStore()
+
+ shell.mobileOpen = true
+
+ const setMobileOpenSpy = vi.spyOn(shell, 'setMobileOpen')
+
+ // Simulate the Drawer closing (v-model:visible setter)
+ await wrapper.findComponent(DrawerStub).vm.$emit('update:visible', false)
+
+ expect(setMobileOpenSpy).toHaveBeenCalledWith(false)
+ })
+
+ it('emitting update:visible true from Drawer calls shell.setMobileOpen(true)', async () => {
+ const wrapper = mountSidebar()
+ const shell = useShellUiStore()
+
+ shell.mobileOpen = false
+
+ const setMobileOpenSpy = vi.spyOn(shell, 'setMobileOpen')
+
+ await wrapper.findComponent(DrawerStub).vm.$emit('update:visible', true)
+
+ expect(setMobileOpenSpy).toHaveBeenCalledWith(true)
+ })
+})
diff --git a/apps/app/src/components-v2/layout/__tests__/SidebarHeader.spec.ts b/apps/app/src/components-v2/layout/__tests__/SidebarHeader.spec.ts
new file mode 100644
index 00000000..f4460a6f
--- /dev/null
+++ b/apps/app/src/components-v2/layout/__tests__/SidebarHeader.spec.ts
@@ -0,0 +1,195 @@
+/**
+ * SidebarHeader.spec.ts — unit tests for the non-trivial logic in SidebarHeader.
+ *
+ * Strategy: mount with @vue/test-utils + createPinia. Child components (Icon)
+ * and @vueuse/core's useMediaQuery are stubbed so we test only:
+ * 1. Collapse-button desktop path → shell.toggleSidebar() called.
+ * 2. Collapse-button mobile path → shell.setMobileOpen(false) called.
+ * 3. Collapsed state hides wordmark + pill; keep collapse button.
+ *
+ * The unit project (src/ __tests__ glob) runs in happy-dom with globals: true
+ * so `describe/it/expect/vi` are available without explicit imports.
+ */
+
+import { createPinia, setActivePinia } from 'pinia'
+import { mount } from '@vue/test-utils'
+import { ref } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { useShellUiStore } from '@/stores/useShellUiStore'
+
+// ---------------------------------------------------------------------------
+// Mock @vueuse/core so we can control `isMobile` per test
+// ---------------------------------------------------------------------------
+
+// We expose a reactive ref that individual tests can flip.
+const mockIsMobileRef = ref(false)
+
+vi.mock('@vueuse/core', () => ({
+ useMediaQuery: () => mockIsMobileRef,
+}))
+
+// ---------------------------------------------------------------------------
+// Import component AFTER mock is set up
+// ---------------------------------------------------------------------------
+
+// eslint-disable-next-line import/first -- intentional: mock must be declared first
+import SidebarHeader from '@/components-v2/layout/SidebarHeader.vue'
+
+// ---------------------------------------------------------------------------
+// Stubs — keep tests fast and focused on logic, not child rendering
+// ---------------------------------------------------------------------------
+
+const globalStubs = {
+ // Icon renders nothing; we just need the collapse button to be present
+ Icon: { template: ' ' },
+}
+
+function mountHeader() {
+ return mount(SidebarHeader, {
+ global: {
+ plugins: [createPinia()],
+ stubs: globalStubs,
+ },
+ })
+}
+
+describe('SidebarHeader', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+
+ // Reset mobile mock to desktop default before each test
+ mockIsMobileRef.value = false
+ })
+
+ // -------------------------------------------------------------------------
+ // Collapse button — desktop path
+ // -------------------------------------------------------------------------
+
+ it('desktop: collapse button calls toggleSidebar', async () => {
+ const wrapper = mountHeader()
+ const shell = useShellUiStore()
+ const toggleSpy = vi.spyOn(shell, 'toggleSidebar')
+
+ mockIsMobileRef.value = false
+
+ const btn = wrapper.find('button[aria-label]')
+
+ await btn.trigger('click')
+
+ expect(toggleSpy).toHaveBeenCalledOnce()
+ })
+
+ it('desktop: collapse button does NOT call setMobileOpen', async () => {
+ const wrapper = mountHeader()
+ const shell = useShellUiStore()
+ const setMobileOpenSpy = vi.spyOn(shell, 'setMobileOpen')
+
+ mockIsMobileRef.value = false
+
+ const btn = wrapper.find('button[aria-label]')
+
+ await btn.trigger('click')
+
+ expect(setMobileOpenSpy).not.toHaveBeenCalled()
+ })
+
+ // -------------------------------------------------------------------------
+ // Collapse button — mobile path
+ // -------------------------------------------------------------------------
+
+ it('mobile: collapse button calls setMobileOpen(false)', async () => {
+ mockIsMobileRef.value = true
+
+ const wrapper = mountHeader()
+ const shell = useShellUiStore()
+
+ shell.mobileOpen = true // simulate open drawer
+
+ const setMobileOpenSpy = vi.spyOn(shell, 'setMobileOpen')
+
+ const btn = wrapper.find('button[aria-label]')
+
+ await btn.trigger('click')
+
+ expect(setMobileOpenSpy).toHaveBeenCalledWith(false)
+ })
+
+ it('mobile: collapse button does NOT call toggleSidebar', async () => {
+ mockIsMobileRef.value = true
+
+ const wrapper = mountHeader()
+ const shell = useShellUiStore()
+ const toggleSpy = vi.spyOn(shell, 'toggleSidebar')
+
+ const btn = wrapper.find('button[aria-label]')
+
+ await btn.trigger('click')
+
+ expect(toggleSpy).not.toHaveBeenCalled()
+ })
+
+ // -------------------------------------------------------------------------
+ // Collapsed state hides wordmark + pill
+ // -------------------------------------------------------------------------
+
+ it('expanded: wordmark and pill are visible', () => {
+ const wrapper = mountHeader()
+ const shell = useShellUiStore()
+
+ shell.sidebarCollapsed = false
+
+ // The wordmark text appears directly in a
+ expect(wrapper.text()).toContain('Crewli')
+ expect(wrapper.text()).toContain('Beta')
+ })
+
+ it('collapsed: wordmark and pill are hidden', async () => {
+ const wrapper = mountHeader()
+ const shell = useShellUiStore()
+
+ shell.sidebarCollapsed = true
+
+ // Give Vue a tick to re-render after the store mutation
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.text()).not.toContain('Crewli')
+ expect(wrapper.text()).not.toContain('Beta')
+ })
+
+ it('collapsed: collapse button is still rendered', async () => {
+ const wrapper = mountHeader()
+ const shell = useShellUiStore()
+
+ shell.sidebarCollapsed = true
+
+ await wrapper.vm.$nextTick()
+
+ const btn = wrapper.find('button[aria-label]')
+
+ expect(btn.exists()).toBe(true)
+ })
+
+ it('collapsed: collapse button has aria-label "Expand sidebar"', async () => {
+ const wrapper = mountHeader()
+ const shell = useShellUiStore()
+
+ shell.sidebarCollapsed = true
+
+ await wrapper.vm.$nextTick()
+
+ const btn = wrapper.find('button[aria-label]')
+
+ expect(btn.attributes('aria-label')).toBe('Expand sidebar')
+ })
+
+ it('expanded: collapse button has aria-label "Collapse sidebar"', () => {
+ const wrapper = mountHeader()
+ const shell = useShellUiStore()
+
+ shell.sidebarCollapsed = false
+
+ const btn = wrapper.find('button[aria-label]')
+
+ expect(btn.attributes('aria-label')).toBe('Collapse sidebar')
+ })
+})