feat(gui-v2): Plan 2 shell components — AppSidebar, SidebarNav, WorkspaceSwitcher, AppTopbar, RightDrawer, AppDialog, Storybook stories, layouts-v2 boundary zone

This commit is contained in:
2026-05-17 13:31:05 +02:00
39 changed files with 4251 additions and 29 deletions

View File

@@ -252,9 +252,16 @@ module.exports = {
// (components-foundation) but NOT any other v1 component zone.
// No v1 `from` rule lists components-v2/pages-v2 → back-porting
// is structurally impossible (RFC-WS-GUI-REDESIGN AD-G5).
// layouts-v2 (src/layouts/*V2*.vue, e.g. OrganizerLayoutV2) is
// the v2 shell-composition zone: SAME v2 capability as pages-v2
// (may import components-v2 + navigation) so AD-G2's
// "OrganizerLayoutV2 wraps AppShellV2" holds, WITHOUT widening
// the v1 `layouts` zone (still cannot reach components-v2 →
// AD-G5 isolation intact). Locked by tests/unit/boundaries-v2.spec.ts.
{ from: 'components-foundation', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'components-foundation'] },
{ from: 'components-v2', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'components-v2', 'components-foundation'] },
{ from: 'pages-v2', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'navigation', 'components-v2', 'components-foundation', 'layouts', 'plugins'] },
{ from: 'layouts-v2', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'navigation', 'components-v2', 'components-foundation', 'layouts', 'plugins'] },
{ from: 'components', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'components', 'components-shared', 'components-organizer'] },
{ from: 'layouts', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'stores-portal', 'navigation', 'components', 'components-shared', 'components-portal', 'components-organizer', 'layouts'] },
@@ -306,9 +313,23 @@ module.exports = {
// same `type` so both src/components/forms/** and src/components/Icon.vue
// are captured before the generic `components` catch-all.
{ type: 'components-foundation', pattern: 'src/components/forms/**' },
{ type: 'components-foundation', pattern: 'src/components/Icon.vue' },
// mode:'file' is REQUIRED for a single-file pattern. Without it
// eslint-plugin-boundaries matches in the default 'folder' mode,
// so 'src/components/Icon.vue' never matches and Icon.vue falls
// through to the generic `components` catch-all below — breaking
// the sanctioned components-v2 → Icon bridge (RFC AD-G5). The
// forms/** entry above is a folder glob so it is unaffected.
{ type: 'components-foundation', pattern: 'src/components/Icon.vue', mode: 'file' },
{ type: 'components-v2', pattern: 'src/components-v2/**' },
{ type: 'components', pattern: 'src/components/**' },
// layouts-v2 MUST precede the generic `layouts` element: first
// match wins. The single `*` does not cross `/`, so this matches
// only top-level v2 layout files (src/layouts/OrganizerLayoutV2.vue)
// and NOT src/layouts/components/AppShellV2.vue (subdir → stays
// `layouts`, which is correct: AppShellV2 imports only stores).
// mode:'file' is REQUIRED for a file-glob element (same reason as
// the Icon.vue bridge above) — RFC AD-G5 / boundaries-v2.spec.ts.
{ type: 'layouts-v2', pattern: 'src/layouts/*V2*.vue', mode: 'file' },
{ type: 'layouts', pattern: 'src/layouts/**' },
{ type: 'pages-register', pattern: 'src/pages/register/**' },
{ type: 'pages-portal', pattern: 'src/pages/portal/**' },

View File

@@ -1,28 +1,41 @@
import type { Preview } from '@storybook/vue3-vite'
import { setup } from '@storybook/vue3-vite'
import { createMemoryHistory, createRouter } from 'vue-router'
import { installPrimeVue } from '../src/plugins/primevue'
// Side-effect: bootstrap the Tabler set so @iconify/vue <Icon> renders
// real SVG in Storybook (mirrors main.ts; preview.ts is the SB entry).
import '../src/plugins/iconify'
import '../src/assets/styles/tailwind.css'
const noop = { template: '<div />' }
const routeNames = [
'dashboard', 'events', 'organisation', 'members',
'organisation-companies', 'organisation-form-failures',
'organisation-settings', 'platform', 'platform-organisations',
'platform-users', 'platform-form-failures', 'platform-activity-log',
]
export const storyRouter = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: noop },
...routeNames.map(name => ({ path: `/${name}`, name, component: noop })),
{ path: '/:pathMatch(.*)*', name: 'catchall', component: noop },
],
})
setup((app) => {
installPrimeVue(app)
app.use(storyRouter)
})
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
docs: {
toc: true,
},
a11y: {
test: 'todo',
},
controls: { matchers: { color: /(background|color)$/i, date: /Date$/i } },
docs: { toc: true },
a11y: { test: 'todo' },
},
}

View File

@@ -136,10 +136,12 @@ declare global {
const refThrottled: typeof import('@vueuse/core')['refThrottled']
const refWithControl: typeof import('@vueuse/core')['refWithControl']
const regexValidator: typeof import('./src/@core/utils/validators')['regexValidator']
const registerDrawerComponent: typeof import('./src/composables/drawerRegistry')['registerDrawerComponent']
const registerPlugins: typeof import('./src/@core/utils/plugins')['registerPlugins']
const registerPlugins_: typeof import('./src/@core/utils/plugins')['registerPlugins_']
const requiredValidator: typeof import('./src/@core/utils/validators')['requiredValidator']
const resolveComponent: typeof import('vue')['resolveComponent']
const resolveDrawerComponent: typeof import('./src/composables/drawerRegistry')['resolveDrawerComponent']
const resolvePostLoginTarget: typeof import('./src/utils/postLoginRedirect')['resolvePostLoginTarget']
const resolveRef: typeof import('@vueuse/core')['resolveRef']
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
@@ -156,10 +158,12 @@ declare global {
const templateRef: typeof import('@vueuse/core')['templateRef']
const throttledRef: typeof import('@vueuse/core')['throttledRef']
const throttledWatch: typeof import('@vueuse/core')['throttledWatch']
const toBreadcrumbItems: typeof import('./src/composables/useBreadcrumb')['toBreadcrumbItems']
const toRaw: typeof import('vue')['toRaw']
const toReactive: typeof import('@vueuse/core')['toReactive']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toV2NavGroups: typeof import('./src/composables/useV2Nav')['toV2NavGroups']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
@@ -195,6 +199,7 @@ declare global {
const useBase64: typeof import('@vueuse/core')['useBase64']
const useBattery: typeof import('@vueuse/core')['useBattery']
const useBluetooth: typeof import('@vueuse/core')['useBluetooth']
const useBreadcrumb: typeof import('./src/composables/useBreadcrumb')['useBreadcrumb']
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel']
const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation']
@@ -353,6 +358,7 @@ declare global {
const useTrunc: typeof import('@vueuse/math')['useTrunc']
const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams']
const useUserMedia: typeof import('@vueuse/core')['useUserMedia']
const useV2Nav: typeof import('./src/composables/useV2Nav')['useV2Nav']
const useVModel: typeof import('@vueuse/core')['useVModel']
const useVModels: typeof import('@vueuse/core')['useVModels']
const useVibrate: typeof import('@vueuse/core')['useVibrate']
@@ -521,9 +527,11 @@ declare module 'vue' {
readonly refThrottled: UnwrapRef<typeof import('@vueuse/core')['refThrottled']>
readonly refWithControl: UnwrapRef<typeof import('@vueuse/core')['refWithControl']>
readonly regexValidator: UnwrapRef<typeof import('./src/@core/utils/validators')['regexValidator']>
readonly registerDrawerComponent: UnwrapRef<typeof import('./src/composables/drawerRegistry')['registerDrawerComponent']>
readonly registerPlugins: UnwrapRef<typeof import('./src/@core/utils/plugins')['registerPlugins']>
readonly requiredValidator: UnwrapRef<typeof import('./src/@core/utils/validators')['requiredValidator']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly resolveDrawerComponent: UnwrapRef<typeof import('./src/composables/drawerRegistry')['resolveDrawerComponent']>
readonly resolvePostLoginTarget: UnwrapRef<typeof import('./src/utils/postLoginRedirect')['resolvePostLoginTarget']>
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>
@@ -540,10 +548,12 @@ declare module 'vue' {
readonly templateRef: UnwrapRef<typeof import('@vueuse/core')['templateRef']>
readonly throttledRef: UnwrapRef<typeof import('@vueuse/core')['throttledRef']>
readonly throttledWatch: UnwrapRef<typeof import('@vueuse/core')['throttledWatch']>
readonly toBreadcrumbItems: UnwrapRef<typeof import('./src/composables/useBreadcrumb')['toBreadcrumbItems']>
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
readonly toReactive: UnwrapRef<typeof import('@vueuse/core')['toReactive']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toV2NavGroups: UnwrapRef<typeof import('./src/composables/useV2Nav')['toV2NavGroups']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']>
@@ -577,6 +587,7 @@ declare module 'vue' {
readonly useBase64: UnwrapRef<typeof import('@vueuse/core')['useBase64']>
readonly useBattery: UnwrapRef<typeof import('@vueuse/core')['useBattery']>
readonly useBluetooth: UnwrapRef<typeof import('@vueuse/core')['useBluetooth']>
readonly useBreadcrumb: UnwrapRef<typeof import('./src/composables/useBreadcrumb')['useBreadcrumb']>
readonly useBreakpoints: UnwrapRef<typeof import('@vueuse/core')['useBreakpoints']>
readonly useBroadcastChannel: UnwrapRef<typeof import('@vueuse/core')['useBroadcastChannel']>
readonly useBrowserLocation: UnwrapRef<typeof import('@vueuse/core')['useBrowserLocation']>
@@ -729,6 +740,7 @@ declare module 'vue' {
readonly useTrunc: UnwrapRef<typeof import('@vueuse/math')['useTrunc']>
readonly useUrlSearchParams: UnwrapRef<typeof import('@vueuse/core')['useUrlSearchParams']>
readonly useUserMedia: UnwrapRef<typeof import('@vueuse/core')['useUserMedia']>
readonly useV2Nav: UnwrapRef<typeof import('./src/composables/useV2Nav')['useV2Nav']>
readonly useVModel: UnwrapRef<typeof import('@vueuse/core')['useVModel']>
readonly useVModels: UnwrapRef<typeof import('@vueuse/core')['useVModels']>
readonly useVibrate: UnwrapRef<typeof import('@vueuse/core')['useVibrate']>

View File

@@ -0,0 +1,128 @@
<script setup lang="ts">
/**
* AppSidebar — composes SidebarHeader + SidebarNav + WorkspaceSwitcher.
*
* Two rendering modes (RFC §4):
*
* DESKTOP (≥ lg / 1024px): a permanent <aside> (hidden lg:flex column).
* Width responds to sidebarCollapsed: expanded → w-64 (256px), collapsed → w-16 (64px).
* These match crewli-starter's --sidebar-w / --sidebar-w-collapsed CSS variables.
*
* MOBILE (< lg): a PrimeVue <Drawer> bound to shell.mobileOpen via a writable
* computed (`mobileVisible`). The Drawer contains the same 3 children as the
* <aside> — a small amount of template duplication kept intentional for
* readability (no render-fn or dynamic component indirection needed at this scale).
*
* The Drawer is conditionally MOUNTED (v-if="isMobile") rather than hidden via
* a CSS class. PrimeVue Drawer teleports its overlay to <body> via an internal
* Portal; a `lg:hidden` class on the component node does NOT suppress the
* teleported overlay. By unmounting on desktop we guarantee no modal backdrop /
* focus-trapped dialog can appear on wide viewports regardless of mobileOpen state.
*
* nav groups arrive as a prop — this file must NOT import @/navigation directly
* (components-v2 import boundary; the layout page supplies nav data).
*
* 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
* for keyboard/mouse users and avoids the bespoke fixed-position Teleport mechanism.
* Tracked for re-evaluation if custom tooltip styling is required later.
*
* CSS translation (main.css → Tailwind):
* .sidebar (desktop) → flex flex-col overflow-hidden bg-[var(--p-surface-card)]
* border-e border-[var(--p-content-border-color)] z-40
* transition-[width] duration-200
* w-64 (expanded) | w-16 (collapsed)
* .sidebar (mobile) → full-height column inside the Drawer
* .drawer-open (blur) → handled by parent layout; not in scope here
*/
import { computed } from 'vue'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import Drawer from 'primevue/drawer'
import { useShellUiStore } from '@/stores/useShellUiStore'
import type { V2NavGroup } from '@/types/v2/nav'
import SidebarHeader from '@/components-v2/layout/SidebarHeader.vue'
import SidebarNav from '@/components-v2/layout/SidebarNav.vue'
import WorkspaceSwitcher from '@/components-v2/layout/WorkspaceSwitcher.vue'
defineProps<{
/**
* Nav groups passed in from the layout — components-v2 files may NOT
* import from @/navigation; the parent layout supplies this.
*/
groups: V2NavGroup[]
}>()
const shell = useShellUiStore()
// Shared breakpoint signal — must match SidebarHeader and the CSS `lg:` boundary (1024px).
// useBreakpoints(breakpointsTailwind).smaller('lg') is true below 1024px, equivalent to
// max-width: 1023px. Both components import from the same Tailwind constant so the
// desktop/mobile boundary cannot drift.
const isMobile = useBreakpoints(breakpointsTailwind).smaller('lg')
/**
* Writable computed so we can use v-model:visible on PrimeVue Drawer
* without mutating the store ref directly.
*/
const mobileVisible = computed<boolean>({
get: () => shell.mobileOpen,
set: (v: boolean) => shell.setMobileOpen(v),
})
</script>
<template>
<!--
DESKTOP: permanent <aside>, hidden below lg, flex column above lg.
Width transitions between w-64 (expanded) and w-16 (collapsed).
`hidden lg:flex` is effective here because <aside> is not teleported.
-->
<!-- 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="shell.sidebarCollapsed ? 'w-16' : 'w-64'"
>
<SidebarHeader />
<SidebarNav
:groups="groups"
:collapsed="shell.sidebarCollapsed"
/>
<WorkspaceSwitcher :collapsed="shell.sidebarCollapsed" />
</aside>
<!--
MOBILE: PrimeVue Drawer, conditionally MOUNTED only when isMobile is true.
v-if (not v-show / CSS) is required because PrimeVue Drawer teleports its
overlay to <body> a CSS class on the component node does NOT reach the
teleported portal. Unmounting on desktop ensures the overlay and focus-trap
are never created on wide viewports, regardless of shell.mobileOpen state.
The Drawer's own overlay handles backdrop / close-on-outside-click.
position="left" gives the sidebar-style panel. pt (passthrough) removes
Drawer's default padding so our children control their own spacing exactly
as on desktop.
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.
-->
<!-- mobile -->
<Drawer
v-if="isMobile"
v-model:visible="mobileVisible"
position="left"
class="!w-64"
:pt="{
content: { class: 'flex flex-col p-0 overflow-hidden h-full' },
header: { class: 'hidden' },
}"
>
<SidebarHeader />
<SidebarNav
:groups="groups"
:collapsed="false"
/>
<WorkspaceSwitcher :collapsed="false" />
</Drawer>
</template>

View File

@@ -0,0 +1,464 @@
<script setup lang="ts">
/**
* AppTopbar — production port of crewli-starter AppTopbar.vue.
*
* Wiring:
* - Hamburger → shell.setMobileOpen(true) (mobile only, lg:hidden)
* - Brand: static mark + wordmark (Tailwind from .topbar-brand/.mark/.wordmark)
* - Breadcrumb: PrimeVue <Breadcrumb> fed from useBreadcrumb(), route-driven
* - Mobile workspace button: org gradient via computeOrgGradient() + initials
* - Search: static InputText with ⌘K hint (no backend, chrome only)
* - Density toggle: shell.setDensity() with flipped value
* - Theme toggle: shell.setTheme() with flipped value
* - Notifications: stubbed with count=0 — useNotificationStore is a toast/snackbar
* queue (visible/message/type) not a notification feed; real feed is not
* foundation scope.
* TODO TECH-WS-GUI-REDESIGN: real notification feed — not foundation scope
* - User menu: PrimeVue Menu with #start slot for user header; Sign out → authStore.logout()
* - All click-outside: PrimeVue Popover/Menu built-in dismissal (no document.addEventListener)
*
* Notifications decision (A7): useNotificationStore exposes only
* { visible, message, type, timeout, show, hide } — a toast/snackbar queue,
* not a persistent notification feed with unread counts or list items.
* Decision: STUB at count=0, empty list, with TODO comment above.
*
* Icon convention: import Icon from '@/components/Icon.vue', <Icon name="tabler-x" :size="N" />
*
* Styling: crewli-starter CSS translated to Tailwind inline.
* <style scoped> used only for:
* 1. topbar-mark-shadow — inset box-shadow (no Tailwind utility at this granularity, RFC §7.4)
* 2. ws-mobile-btn-shadow — same inset shadow justification
*/
import Avatar from 'primevue/avatar'
import Breadcrumb from 'primevue/breadcrumb'
import InputText from 'primevue/inputtext'
import Menu from 'primevue/menu'
import OverlayBadge from 'primevue/overlaybadge'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { MenuItem } from 'primevue/menuitem'
import Icon from '@/components/Icon.vue'
import { useBreadcrumb } from '@/composables/useBreadcrumb'
import { useAuthStore } from '@/stores/useAuthStore'
import { useShellUiStore } from '@/stores/useShellUiStore'
import { computeOrgGradient } from '@/utils/v2/gradient'
// ---------------------------------------------------------------------------
// Stores / router
// ---------------------------------------------------------------------------
const shell = useShellUiStore()
const authStore = useAuthStore()
const router = useRouter()
// ---------------------------------------------------------------------------
// Breadcrumb — route-driven via useBreadcrumb()
// ---------------------------------------------------------------------------
const { items: breadcrumbItems } = useBreadcrumb()
/**
* Map BreadcrumbItem[] → PrimeVue MenuItem[].
*
* The installed PrimeVue Breadcrumb (BreadcrumbItem.vue) renders
* `<a :href="item.url || '#'">` and calls `item.command` on click.
* It does NOT honour a `route` key — router-link is never invoked.
*
* Fix: non-last items navigate via `command: () => router.push(item.to)` (client-side,
* no full reload, no href="#"). Last/current item has no `to` from useBreadcrumb()
* → no command → non-interactive.
*/
const breadcrumbModel = computed<MenuItem[]>(() =>
breadcrumbItems.value.map(item => {
const base: MenuItem = { label: item.label }
if (item.to !== undefined) {
base.command = () => {
router.push(item.to!)
}
}
return base
}),
)
// ---------------------------------------------------------------------------
// User initials derived from full_name
// ---------------------------------------------------------------------------
const userInitials = computed<string>(() => {
const name = authStore.user?.full_name?.trim() ?? ''
if (!name)
return '?'
const words = name.split(/\s+/)
return words.length >= 2
? (words[0][0] + words[words.length - 1][0]).toUpperCase()
: name.slice(0, 2).toUpperCase()
})
// ---------------------------------------------------------------------------
// Mobile workspace button
// ---------------------------------------------------------------------------
const mobileOrgGradient = computed<[string, string]>(() => {
const id = authStore.currentOrganisation?.id ?? ''
return computeOrgGradient(id)
})
const mobileOrgInitials = computed<string>(() => {
const name = authStore.currentOrganisation?.name?.trim() ?? ''
if (!name)
return '?'
const words = name.split(/\s+/)
return words.length >= 2
? (words[0][0] + words[1][0]).toUpperCase()
: name.slice(0, 2).toUpperCase()
})
// ---------------------------------------------------------------------------
// Density toggle
// ---------------------------------------------------------------------------
function toggleDensity(): void {
shell.setDensity(shell.density === 'comfortable' ? 'compact' : 'comfortable')
}
const densityAriaLabel = computed<string>(() =>
shell.density === 'comfortable' ? 'Switch to compact' : 'Switch to comfortable',
)
const densityIcon = computed<string>(() =>
shell.density === 'comfortable'
? 'tabler-layout-distribute-vertical'
: 'tabler-layout-distribute-horizontal',
)
// ---------------------------------------------------------------------------
// Theme toggle
// ---------------------------------------------------------------------------
function toggleTheme(): void {
shell.setTheme(shell.theme === 'dark' ? 'light' : 'dark')
}
const themeAriaLabel = computed<string>(() =>
shell.theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode',
)
const themeIcon = computed<string>(() =>
shell.theme === 'dark' ? 'tabler-sun' : 'tabler-moon',
)
// ---------------------------------------------------------------------------
// Notifications — STUBBED (see file header for A7 decision)
// TODO TECH-WS-GUI-REDESIGN: real notification feed — not foundation scope
// ---------------------------------------------------------------------------
const notifCount = 0
const notifPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
function toggleNotifPopover(event: MouseEvent): void {
notifPopoverRef.value?.toggle(event)
}
// ---------------------------------------------------------------------------
// User menu — PrimeVue Menu (replaces crewli-starter manual mousedown listener)
// ---------------------------------------------------------------------------
const userMenuRef = ref<InstanceType<typeof Menu> | null>(null)
function toggleUserMenu(event: MouseEvent): void {
userMenuRef.value?.toggle(event)
}
const userMenuItems = computed<MenuItem[]>(() => [
{
items: [
{
label: 'Profile',
icon: 'tabler-user',
command: () => {
// TODO TECH-WS-GUI-REDESIGN: routes not yet defined
},
},
{
label: 'Account settings',
icon: 'tabler-settings',
command: () => {
// TODO TECH-WS-GUI-REDESIGN: routes not yet defined
},
},
{
label: 'Workspace settings',
icon: 'tabler-adjustments',
command: () => {
// TODO TECH-WS-GUI-REDESIGN: routes not yet defined
},
},
],
},
{
items: [
{
label: 'Keyboard shortcuts',
icon: 'tabler-keyboard',
command: () => {
// TODO TECH-WS-GUI-REDESIGN: routes not yet defined
},
},
{
label: 'Help & support',
icon: 'tabler-help',
command: () => {
// TODO TECH-WS-GUI-REDESIGN: routes not yet defined
},
},
],
},
{
items: [
{
label: 'Sign out',
icon: 'tabler-logout',
command: () => {
authStore.logout()
},
},
],
},
])
</script>
<template>
<!--
.topbar: sticky top-0, h-[var(--topbar-h)], flex items-center, gap-3,
bg-[var(--surface)], border-b border-[var(--border)], z-30, px-5
-->
<header class="sticky top-0 z-30 flex h-[var(--topbar-h,56px)] items-center gap-3 border-b border-[var(--p-content-border-color)] bg-[var(--p-content-background)] px-5">
<!-- Left group: hamburger + brand + breadcrumb -->
<div class="flex items-center gap-[6px]">
<!--
Hamburger mobile only (lg:hidden).
.hamburger: display none at >=lg, display inline-flex at <768px
.icon-btn: 38x38, rounded, transparent bg, fg-muted color, hover bg-hover
-->
<button
type="button"
class="lg:hidden 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="Open menu"
@click="shell.setMobileOpen(true)"
>
<Icon
name="tabler-menu-2"
:size="20"
/>
</button>
<!--
Brand hidden on mobile, shown >=lg.
.topbar-brand: display none default, display inline-flex >=lg
.mark: 28x28, rounded-lg, gradient bg, white text, font-bold 13px
.wordmark: font-bold 16px, tracking-tight
-->
<div class="hidden lg:inline-flex items-center gap-2">
<!--
Topbar mark dynamic gradient cannot be a static Tailwind class.
Inline style justified (RFC §7.4). Box-shadow via scoped CSS
(inset-shadow has no Tailwind utility at this granularity).
-->
<div
class="topbar-mark-shadow inline-flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg text-[13px] font-bold text-white"
style="background: linear-gradient(135deg, var(--p-primary-500, #0d9488), var(--p-primary-700, #0f766e))"
aria-hidden="true"
>
C
</div>
<span class="text-[16px] font-bold tracking-[-0.01em] text-[var(--p-text-color)]">
Crewli
</span>
</div>
<!--
Breadcrumb PrimeVue Breadcrumb, route-driven.
.breadcrumb: flex, items-center, gap-[6px], text-[13px], color fg-muted, flex-wrap nowrap, overflow-hidden
Hidden on mobile per crewli-starter (<768px: display:none!important)
-->
<nav
class="hidden lg:flex items-center"
aria-label="Breadcrumb"
>
<Breadcrumb
:model="breadcrumbModel"
class="border-0 bg-transparent p-0 text-[13px]"
/>
</nav>
</div>
<!-- Right group: ws-mobile-btn, search, density, theme, notifications, user -->
<div class="ms-auto flex items-center gap-[6px]">
<!--
Mobile workspace button visible only <lg.
.ws-mobile-btn: display none (>= 1024px). On mobile: display flex.
38x38, rounded, color #fff, font-bold 12px, gradient bg via inline :style
(dynamic hex pair RFC §7.4). Box-shadow via scoped CSS.
-->
<button
v-if="authStore.currentOrganisation"
type="button"
class="ws-mobile-btn-shadow lg:hidden inline-flex h-[38px] w-[38px] flex-shrink-0 items-center justify-center rounded-[var(--p-border-radius)] border-0 text-[12px] font-bold text-white"
:style="{
background: `linear-gradient(135deg, ${mobileOrgGradient[0]}, ${mobileOrgGradient[1]})`,
}"
:aria-label="`Workspace: ${authStore.currentOrganisation.name}`"
>
{{ mobileOrgInitials }}
</button>
<!--
Search static chrome, no backend.
.search: position relative, w-[320px], max-w-full
.search input: h-[38px], px-[12px] ps-[36px], bg-surface-alt, border rounded
.search .kbd: absolute right-2, text-[11px], px-[6px] py-[2px], border rounded
Hidden on smallest viewports per crewli-starter (<768px: width 0 / display:none)
-->
<div class="relative hidden sm:block w-[240px] lg:w-[320px] max-w-full">
<Icon
name="tabler-search"
:size="16"
class="pointer-events-none absolute left-[11px] top-1/2 -translate-y-1/2 text-[var(--p-text-muted-color)]"
/>
<InputText
class="h-[38px] w-full rounded-[var(--p-border-radius)] border border-transparent bg-[var(--p-content-hover-background)] ps-[36px] pe-[52px] text-[var(--p-text-color)] placeholder:text-[var(--p-text-muted-color)] focus:border-[var(--p-primary-color)] focus:bg-[var(--p-content-background)] focus:shadow-[0_0_0_3px_var(--p-primary-50)] focus:outline-none transition-[border-color,background,box-shadow] duration-150"
placeholder="Search artists, crew, events..."
:pt="{ root: { 'data-search-input': '' } }"
/>
<span class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 rounded-[var(--p-border-radius-sm,4px)] border border-[var(--p-content-border-color)] bg-[var(--p-content-background)] px-[6px] py-[2px] font-mono text-[11px] text-[var(--p-text-muted-color)]">
K
</span>
</div>
<!--
Density toggle.
.icon-btn: 38x38, inline-flex, centered, rounded, transparent, fg-muted, hover bg-hover
-->
<button
type="button"
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"
>
<Icon
:name="densityIcon"
:size="18"
/>
</button>
<!-- Theme toggle. -->
<button
type="button"
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="themeAriaLabel"
@click="toggleTheme"
>
<Icon
:name="themeIcon"
:size="18"
/>
</button>
<!--
Notifications stubbed bell with OverlayBadge.
PrimeVue Popover replaces crewli-starter's manual document.addEventListener('mousedown', …)
TODO TECH-WS-GUI-REDESIGN: real notification feed — not foundation scope
-->
<OverlayBadge
:value="notifCount > 0 ? String(notifCount) : undefined"
severity="danger"
>
<button
type="button"
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="Notifications"
@click="toggleNotifPopover"
>
<Icon
name="tabler-bell"
:size="18"
/>
</button>
</OverlayBadge>
<Popover ref="notifPopoverRef">
<div class="min-w-[320px] p-4 text-[13px] text-[var(--p-text-muted-color)]">
No notifications
</div>
</Popover>
<!--
User menu — PrimeVue Avatar + Menu.
Avatar: 32x32, rounded-full, initials, gradient bg.
Menu: PrimeVue Menu replaces crewli-starter's manual click-outside listener.
#start slot used for user info header.
-->
<Avatar
:label="userInitials"
shape="circle"
class="cursor-pointer"
:pt="{ root: { style: 'background: linear-gradient(135deg, #f472b6, var(--p-primary-500, #0d9488)); color: #fff;' } }"
aria-label="User menu"
@click="toggleUserMenu"
/>
<Menu
ref="userMenuRef"
:model="userMenuItems"
popup
>
<template #start>
<div class="flex items-center gap-3 px-4 py-3 border-b border-[var(--p-content-border-color)]">
<Avatar
:label="userInitials"
shape="circle"
:pt="{ root: { style: 'background: linear-gradient(135deg, #f472b6, var(--p-primary-500, #0d9488)); color: #fff; width:40px; height:40px; font-size:14px;' } }"
/>
<div class="min-w-0">
<div class="truncate text-[13.5px] font-semibold text-[var(--p-text-color)]">
{{ authStore.user?.full_name ?? '' }}
</div>
<div class="truncate text-[12px] text-[var(--p-text-muted-color)]">
{{ authStore.user?.email ?? '' }}
</div>
</div>
</div>
</template>
</Menu>
</div>
</header>
</template>
<style scoped>
/**
* FIX 1: topbar-mark-shadow — inset directional box-shadow.
* Tailwind has no inset-shadow utility at this depth/direction granularity → RFC §7.4.
*/
.topbar-mark-shadow {
box-shadow: inset 0 -2px 0 rgb(0 0 0 / 10%);
}
/**
* FIX 2: ws-mobile-btn-shadow — same inset shadow justification as above.
* Background gradient is set via inline :style (dynamic hex pair from computeOrgGradient).
*/
.ws-mobile-btn-shadow {
box-shadow: inset 0 -2px 0 rgb(0 0 0 / 10%);
}
</style>

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
/**
* RightDrawer — the global right-side drawer shell driven entirely by
* `useRightDrawer()` / `useShellUiStore().drawer`. No open/title/flush props
* (Decision A4): the caller passes chrome keys (`title`, `flush`) inside the
* `props` object when calling `useRightDrawer().open('Name', { title, flush, ...bodyProps })`.
*
* PrimeVue <Drawer position="right"> provides the scrim, escape-key dismiss,
* overlay-click close, and focus-trap. The writable computed `drawerVisible`
* wires v-model:visible to useRightDrawer().isOpen / .close() without
* mutating the store ref directly.
*
* Body component lifecycle:
* - `component.value` (string | null) is resolved via resolveDrawerComponent().
* - Unknown / unregistered names → null → graceful empty state (no crash).
* This is the expected state in the foundation scope; real drawer-body
* components register themselves via registerDrawerComponent() from their
* own feature zone (components-v2, pages-v2, etc.) at mount time.
* - Chrome keys `title` and `flush` are stripped from `bodyProps` and NOT
* forwarded to the body component — they are consumed at the shell level only.
*
* #actions slot:
* Parent layouts may inject header action buttons (e.g. an edit icon) via the
* named `#actions` slot. In the store-driven model this slot is typically unused,
* but it is kept for composed layouts that wrap <RightDrawer> and need to add
* persistent header controls without duplicating the chrome.
*
* CSS translation (crewli-starter main.css → Tailwind):
* .drawer → handled by PrimeVue Drawer + :pt passthrough
* .drawer-head → flex items-center gap-3 px-4 py-3 border-b ...
* .drawer-head .title → flex-1 font-medium text-sm truncate
* .drawer-head .actions → ms-auto flex items-center gap-1
* .drawer-body (flush) → p-0 (edge-to-edge)
* .drawer-body (normal)→ p-4 overflow-y-auto
* .icon-btn → rounded-md p-1 hover:bg-[var(--p-content-hover-background)]
* transition-colors flex items-center justify-center
*/
import { computed } from 'vue'
import Drawer from 'primevue/drawer'
import Icon from '@/components/Icon.vue'
import { useRightDrawer } from '@/composables/useRightDrawer'
const { isOpen, component, props, close, resolveDrawerComponent } = useRightDrawer()
/**
* Writable computed so PrimeVue Drawer can use v-model:visible without
* directly mutating the Pinia store ref.
*/
const drawerVisible = computed<boolean>({
get: () => isOpen.value,
set: (v: boolean) => {
if (!v)
close()
},
})
/**
* Chrome keys read from drawerProps WITHOUT using `any`.
* `typeof` narrowing instead of a cast keeps TypeScript strict.
*/
const title = computed(() =>
typeof props.value.title === 'string' ? props.value.title : '',
)
const flush = computed(() => props.value.flush === true)
/**
* Body props: `props.value` minus the chrome keys `title` and `flush`.
* These are NOT forwarded to the body component — they are consumed by the
* drawer shell. All other keys are passed through as-is.
*
* Key-filter approach avoids unused-variable linting errors from destructuring
* (the project's varsIgnorePattern only allows purely-underscore names).
*/
const CHROME_KEYS = new Set(['title', 'flush'])
const bodyProps = computed(() =>
Object.fromEntries(
Object.entries(props.value).filter(([k]) => !CHROME_KEYS.has(k)),
),
)
/**
* Resolved body component from the registry. Null when the component name is
* unregistered (expected in foundation scope) — the template renders a graceful
* empty state in that case.
*/
const Body = computed(() => resolveDrawerComponent(component.value))
</script>
<template>
<Drawer
v-model:visible="drawerVisible"
position="right"
:pt="{
/*
* Strip PrimeVue's default header so we render our own with the
* close button, title, and #actions slot in a single controlled row.
* content: remove default padding so body region controls its own spacing.
*/
header: { class: 'hidden' },
content: { class: 'flex flex-col p-0 overflow-hidden h-full' },
}"
>
<!-- Drawer header: close button + title + optional #actions slot -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-[var(--p-content-border-color)] flex-shrink-0">
<button
type="button"
aria-label="Close"
class="rounded-md p-1 hover:bg-[var(--p-content-hover-background)] transition-colors flex items-center justify-center"
@click="close"
>
<Icon
name="tabler-x"
:size="18"
/>
</button>
<div class="flex-1 font-medium text-sm truncate">
{{ title }}
</div>
<!--
#actions slot parent layouts may inject header action buttons here.
In the pure store-driven model this slot is typically empty.
-->
<div class="ms-auto flex items-center gap-1">
<slot name="actions" />
</div>
</div>
<!-- Drawer body: dynamic component or graceful empty state -->
<div
class="flex-1 overflow-y-auto"
:class="flush ? 'p-0' : 'p-4'"
>
<!--
Body is rendered only when a registered component is resolved.
When Body.value is null (unknown / not-yet-registered name), we render
a minimal empty state this must NOT crash on null (resolveDrawerComponent
guarantees null for unknown names).
-->
<!--
:key on the drawer-body name forces a full remount when the open
drawer switches from one component to another (open A open B),
so component A's internal state never leaks into component B.
Same-name reopen relies on the body reacting to prop changes
(normal Vue); revisit if a real body ever needs a hard reset there.
-->
<component
:is="Body"
v-if="Body !== null"
:key="component ?? ''"
v-bind="bodyProps"
/>
<!--
Graceful empty state — shown when the component name is unregistered.
Expected in foundation scope; body components self-register at mount.
-->
<p
v-else
class="text-sm text-[var(--p-text-muted-color)]"
>
Geen inhoud
</p>
</div>
</Drawer>
</template>

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
/**
* SidebarHeader — ported from crewli-starter AppSidebar.vue `.brand` block.
*
* Renders the logo mark ("C"), "Crewli" wordmark, "Beta" pill, and the
* sidebar collapse-toggle button. All state is read from / written to
* useShellUiStore — no props needed.
*
* Collapse-button behaviour (mirrors crewli-starter `handleCollapse`):
* - Mobile (<lg, i.e. max-width: 1023px): the drawer is the active sidebar
* → call setMobileOpen(false) to close it.
* - Desktop (>=lg): toggle the persistent sidebar between expanded/collapsed
* → call toggleSidebar().
*
* CSS translation (main.css → Tailwind):
* .brand → flex items-center gap-[10px] h-[var(--topbar-h,56px)]
* px-4 border-b border-[var(--p-content-border-color)]
* flex-shrink-0 relative
* .brand.collapsed → justify-center (via :class when sidebarCollapsed)
* .mark → w-8 h-8 flex-shrink-0 rounded-lg
* bg-gradient-to-br from-primary-500 to-primary-700
* inline-flex items-center justify-center
* text-white font-bold text-sm
* .wordmark → font-bold text-base tracking-[-0.01em] whitespace-nowrap
* .pill → 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]
* .sidebar-collapse → 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)]
*
* <style scoped> is used for the inset box-shadow on .mark because
* Tailwind has no inset directional shadow utility at this granularity
* (per RFC §7.4 — last resort, with justification comment).
*/
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import Icon from '@/components/Icon.vue'
import { useShellUiStore } from '@/stores/useShellUiStore'
const shell = useShellUiStore()
// Use the canonical Tailwind lg breakpoint (1024px) — consistent with AppSidebar's
// v-if="isMobile" guard so both components agree on the desktop/mobile boundary.
const isMobile = useBreakpoints(breakpointsTailwind).smaller('lg')
function handleCollapseClick(): void {
if (isMobile.value) {
// Mobile: the Drawer is the active sidebar — close it
shell.setMobileOpen(false)
}
else {
// Desktop: toggle the persistent sidebar width
shell.toggleSidebar()
}
}
</script>
<template>
<div
class="flex items-center gap-[10px] h-[56px] border-b border-[var(--p-content-border-color)] flex-shrink-0 relative transition-[padding,justify-content] duration-200"
:class="shell.sidebarCollapsed ? 'justify-center px-0' : 'px-4'"
>
<!-- Logo mark: "C" in a gradient square -->
<span class="mark w-8 h-8 flex-shrink-0 rounded-lg bg-gradient-to-br from-primary-500 to-primary-700 inline-flex items-center justify-center text-white font-bold text-sm">
C
</span>
<!-- Wordmark + Beta pill: hidden when sidebar is collapsed -->
<span
v-if="!shell.sidebarCollapsed"
class="font-bold text-base tracking-[-0.01em] whitespace-nowrap text-[var(--p-text-color)]"
>
Crewli
</span>
<span
v-if="!shell.sidebarCollapsed"
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>
<!-- Collapse-toggle button: always visible; acts as expand affordance when collapsed -->
<button
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"
:class="shell.sidebarCollapsed ? '!ms-0' : ''"
:aria-label="shell.sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'"
type="button"
@click="handleCollapseClick"
>
<Icon
:name="shell.sidebarCollapsed ? 'tabler-chevron-right' : 'tabler-chevron-left'"
:size="16"
/>
</button>
</div>
</template>
<style scoped>
/*
* The .mark inset box-shadow (inset 0 -2px 0 rgba(0,0,0,0.08)) has no
* Tailwind equivalent — Tailwind's shadow utilities only support outer shadows
* and cannot express inset directional shadows at this opacity. Last resort per
* RFC §7.4.
*/
.mark {
box-shadow: inset 0 -2px 0 rgb(0 0 0 / 8%);
}
</style>

View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import type { RouteLocationRaw } from 'unplugin-vue-router'
import Icon from '@/components/Icon.vue'
import { isNavItemActive } from '@/components-v2/layout/sidebarNavActive'
import type { V2NavGroup, V2NavItem } from '@/types/v2/nav'
defineProps<{
groups: V2NavGroup[]
collapsed: boolean
}>()
const route = useRoute()
function checkActive(item: V2NavItem): boolean {
return isNavItemActive(item, route.name)
}
// Cast V2NavItem.to (vue-router generic RouteLocationRaw) to the typed version
// expected by RouterLinkTyped. V2NavItem.to values are always named-route objects
// from the RouteNamedMap so the cast is sound.
function itemTo(item: V2NavItem): RouteLocationRaw {
return item.to as RouteLocationRaw
}
</script>
<template>
<!--
.nav flex-1 min-h-0 overflow-y-auto py-3.5 px-2.5 [scrollbar-width:thin]
.nav-group mt-[18px] (only from the second group onward, via CSS sibling selector
Tailwind cannot express `+ .nav-group { margin-top }` on a class without
a group-based trick; using first:mt-0 / not-first:mt-[18px] via the
:not(:first-child) pseudo equivalent `[&:not(:first-child)]:mt-[18px]`)
.nav-label text-[11px] font-semibold text-surface-500 dark:text-surface-400
uppercase tracking-[0.06em] px-2.5 pb-1.5 whitespace-nowrap overflow-hidden
.nav-item flex items-center gap-3 py-[9px] px-2.5 rounded-md
text-surface-600 dark:text-surface-300 text-[13.5px] font-medium
transition-[background,color] duration-150 whitespace-nowrap relative
cursor-pointer w-full text-start min-w-0
.nav-item:hover hover:bg-surface-100 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-surface-0
.nav-item.active bg-primary-50 dark:bg-primary-950 text-primary-600 dark:text-primary-400 font-semibold
.nav-item.active::before the left accent bar inexpressible in Tailwind without a plugin
(position:absolute left:-10px, custom width:3px, height:18px,
background:primary, transform:translateY(-50%)). Using <style scoped>.
.nav-item .iconify text-[18px] flex-shrink-0 (handled by Icon's own sizing via :size)
.count → ms-auto text-[11px] font-semibold bg-surface-100 dark:bg-surface-800
text-surface-500 dark:text-surface-400 px-1.5 py-px rounded-full
.nav-item.active .count → bg-primary-600 dark:bg-primary-500 text-white
-->
<nav class="flex-1 min-h-0 overflow-y-auto py-3.5 px-2.5 [scrollbar-width:thin]">
<div
v-for="(group, gi) in groups"
:key="gi"
class="[&:not(:first-child)]:mt-[18px]"
>
<!-- Group label: hidden in collapsed mode -->
<div
v-if="group.label && !collapsed"
class="text-[11px] font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-[0.06em] px-2.5 pb-1.5 whitespace-nowrap overflow-hidden"
>
{{ group.label }}
</div>
<RouterLink
v-for="item in group.items"
:key="item.id"
v-slot="{ href, navigate }"
:to="itemTo(item)"
custom
>
<a
:href="href"
class="flex items-center gap-3 py-[9px] rounded-md text-[13.5px] font-medium transition-[background,color] duration-150 whitespace-nowrap relative cursor-pointer w-full text-start min-w-0"
:class="[
collapsed ? 'justify-center px-0' : 'px-2.5',
checkActive(item)
? 'nav-item-active bg-primary-50 dark:bg-primary-950 text-primary-600 dark:text-primary-400 font-semibold'
: 'text-surface-600 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-surface-0',
]"
:aria-current="checkActive(item) ? 'page' : undefined"
:aria-label="item.label"
:title="collapsed ? item.label : undefined"
@click="navigate"
>
<Icon
:name="item.icon"
:size="18"
class="flex-shrink-0"
/>
<!-- Text label: hidden in collapsed mode -->
<span
v-if="!collapsed"
class="overflow-hidden text-ellipsis min-w-0"
>
{{ item.label }}
</span>
<!-- Count badge: hidden in collapsed mode -->
<span
v-if="item.count != null && !collapsed"
class="ms-auto text-[11px] font-semibold px-1.5 py-px rounded-full"
:class="[
checkActive(item)
? 'bg-primary-600 dark:bg-primary-500 text-white'
: 'bg-surface-100 dark:bg-surface-800 text-surface-500 dark:text-surface-400',
]"
>
{{ item.count }}
</span>
</a>
</RouterLink>
</div>
</nav>
</template>
<style scoped>
/*
* The active left-accent bar (.nav-item.active::before in crewli-starter main.css).
* The bar sits at left:-10px — outside the row boundary. The parent <nav> is an
* overflow-y-auto scroll container which clips cross-axis overflow, so a Tailwind
* before: utility would be clipped by that parent overflow context. A <style scoped>
* pseudo-element keeps the exception self-contained (customization order: Tailwind →
* pt API → Aura → <style scoped> last resort with comment).
*/
.nav-item-active::before {
content: "";
position: absolute;
left: -10px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 18px;
background: var(--p-primary-500, theme('colors.primary.500', #6366f1));
border-radius: 0 3px 3px 0;
}
</style>

View File

@@ -0,0 +1,252 @@
<script setup lang="ts">
/**
* WorkspaceSwitcher — ported from crewli-starter WorkspaceSwitcher.vue.
*
* Data: read-only from useAuthStore (organisations, currentOrganisation,
* setActiveOrganisation). This component owns NO org state — RFC AD-G4.
*
* Popover: PrimeVue <Popover> replaces the manual document.mousedown
* click-outside listener from crewli-starter. Toggle via popoverRef.toggle($event).
*
* Icons: Crewli Icon.vue convention — name="tabler-x" :size="N".
*
* Styling: crewli-starter CSS selectors translated to Tailwind utilities inline.
* One <style scoped> block covers the two exceptions documented below.
*/
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import Icon from '@/components/Icon.vue'
import { useAuthStore } from '@/stores/useAuthStore'
import { computeOrgGradient } from '@/utils/v2/gradient'
import type { Organisation } from '@/types/auth'
defineProps<{
/**
* When true (collapsed sidebar), hide the name/sub meta text and
* show only the logo square — mirrors crewli-starter's collapsed prop.
*/
collapsed?: boolean
}>()
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
const authStore = useAuthStore()
// ---------------------------------------------------------------------------
// Derived current-workspace display object
// ---------------------------------------------------------------------------
interface WorkspaceDisplay {
id: string
initials: string
name: string
/** The role string is the relevant context identifier in Crewli. */
sub: string
gradient: [string, string]
}
function buildDisplay(org: Organisation): WorkspaceDisplay {
const words = org.name.trim().split(/\s+/)
const initials
= (words.length >= 2
? (words[0][0] + words[1][0]).toUpperCase()
: org.name.slice(0, 2).toUpperCase()) || '?'
return {
id: org.id,
initials,
name: org.name,
sub: org.role,
gradient: computeOrgGradient(org.id),
}
}
const current = computed<WorkspaceDisplay | null>(() => {
const org = authStore.currentOrganisation
return org ? buildDisplay(org) : null
})
// Sorted list: active org first, then the rest alphabetically
const allOrgs = computed<WorkspaceDisplay[]>(() => {
const currentId = authStore.currentOrganisation?.id
return [...authStore.organisations]
.sort((a, b) => {
if (a.id === currentId)
return -1
if (b.id === currentId)
return 1
return a.name.localeCompare(b.name)
})
.map(buildDisplay)
})
// ---------------------------------------------------------------------------
// Popover plumbing — PrimeVue Popover replaces the manual mousedown listener
// ---------------------------------------------------------------------------
const popoverRef = ref<InstanceType<typeof Popover> | null>(null)
function toggle(event: MouseEvent): void {
popoverRef.value?.toggle(event)
}
function selectOrg(ws: WorkspaceDisplay): void {
if (ws.id === authStore.currentOrganisation?.id) {
popoverRef.value?.hide()
return
}
authStore.setActiveOrganisation(ws.id)
popoverRef.value?.hide()
}
</script>
<template>
<div class="relative flex-shrink-0 border-t border-[var(--p-content-border-color)] p-[10px]">
<!-- Trigger button -->
<!-- .ws-switcher .trigger: flex, items-center, gap, w-full, px/py, rounded, border, bg-transparent, color, transition -->
<button
class="flex w-full items-center gap-[10px] rounded-[var(--p-border-radius)] border border-transparent bg-transparent px-[10px] py-[8px] text-[var(--p-text-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)]"
:class="[
collapsed ? 'justify-center' : '',
]"
aria-haspopup="true"
@click="toggle"
>
<!-- Logo square (gradient background is bespoke: dynamic hex pair cannot be a static Tailwind class RFC §7.4 justified inline-style) -->
<span
v-if="current"
class="ws-logo-square w-8 h-8 flex-shrink-0 rounded-[var(--p-border-radius)] inline-flex items-center justify-center text-white font-bold text-[12px]"
:style="{ background: `linear-gradient(135deg, ${current.gradient[0]}, ${current.gradient[1]})` }"
>
{{ current.initials }}
</span>
<!-- Meta: name + sub (hidden in collapsed mode) -->
<!-- .ws-switcher .meta: flex-1, min-w-0, flex-col, line-height, text-left -->
<span
v-if="!collapsed && current"
class="flex flex-1 min-w-0 flex-col text-left leading-[1.2]"
>
<!-- .ws-switcher .meta .name: text-[13.5px], font-semibold, truncate -->
<span class="truncate text-[13.5px] font-semibold text-[var(--p-text-color)]">
{{ current.name }}
</span>
<!-- .ws-switcher .meta .sub: text-[11.5px], muted, truncate -->
<span class="truncate text-[11.5px] text-[var(--p-text-muted-color)]">
{{ current.sub }}
</span>
</span>
<!-- Chevron (.ws-switcher .chev: color fg-subtle, flex-shrink-0) -->
<Icon
v-if="!collapsed"
name="tabler-chevron-down"
:size="14"
class="flex-shrink-0 text-[var(--p-text-muted-color)]"
/>
</button>
<!-- PrimeVue Popover replaces crewli-starter's manual document.mousedown click-outside -->
<Popover ref="popoverRef">
<!-- popover-head: px-[16px] py-[14px], border-bottom, flex, items-center, justify-between -->
<div class="flex items-center justify-between border-b border-[var(--p-content-border-color)] px-[16px] py-[14px]">
<!-- .popover-head .title -->
<span class="text-[15px] font-bold tracking-[-0.01em]">Workspaces</span>
<!-- .popover-head .link — TODO TECH-WS-GUI-REDESIGN: no manage-workspaces route yet -->
<span class="cursor-pointer text-[13px] font-medium text-[var(--p-primary-color)]">Manage</span>
</div>
<!-- .pop-ws .list: p-[6px] -->
<div class="min-w-[280px] p-[6px]">
<!-- .pop-ws .opt: grid 3-col, gap, p, rounded, cursor-pointer -->
<!-- TODO TECH-WS-GUI-REDESIGN: full listbox/menu ARIA (role, roving tabindex, arrow-key nav) — tracked -->
<button
v-for="ws in allOrgs"
:key="ws.id"
type="button"
class="grid w-full grid-cols-[36px_1fr_auto] cursor-pointer items-center gap-[12px] rounded-[var(--p-border-radius)] border-0 bg-transparent p-[10px] text-start hover:bg-[var(--p-content-hover-background)]"
:class="[
ws.id === current?.id ? 'bg-[var(--p-primary-50)]' : '',
]"
:aria-current="current?.id === ws.id ? 'true' : undefined"
@click="selectOrg(ws)"
>
<!-- Org logo — larger variant (36px) with dynamic gradient (same inline-style justification as trigger) -->
<span
class="ws-logo-square-lg w-9 h-9 flex-shrink-0 rounded-[var(--p-border-radius)] inline-flex items-center justify-center text-white font-bold text-[13px]"
:style="{ background: `linear-gradient(135deg, ${ws.gradient[0]}, ${ws.gradient[1]})` }"
>{{ ws.initials }}</span>
<!-- Name + sub stack -->
<span>
<!-- .pop-ws .opt .name -->
<div class="text-[14px] font-semibold text-[var(--p-text-color)]">{{ ws.name }}</div>
<!-- .pop-ws .opt .sub -->
<div class="mt-[2px] text-[12.5px] text-[var(--p-text-muted-color)]">{{ ws.sub }}</div>
</span>
<!-- Check mark for active org (.pop-ws .opt .check-mark) -->
<Icon
v-if="ws.id === current?.id"
name="tabler-check"
:size="18"
class="text-[var(--p-primary-color)]"
/>
<!-- Spacer when not current (keeps grid alignment) -->
<span
v-else
class="w-[18px]"
/>
</button>
</div>
<!-- Footer (.pop-ws .foot: p-[8px], border-top, flex, gap-[4px]) -->
<div class="flex gap-[4px] border-t border-[var(--p-content-border-color)] p-[8px]">
<!-- TODO TECH-WS-GUI-REDESIGN: create-workspace route not yet defined -->
<button class="inline-flex flex-1 h-[36px] items-center justify-center gap-[6px] rounded-[var(--p-border-radius)] border-0 bg-transparent text-[13px] font-medium text-[var(--p-text-color)] hover:bg-[var(--p-content-hover-background)]">
<Icon
name="tabler-plus"
:size="14"
/>
New workspace
</button>
<!-- TODO TECH-WS-GUI-REDESIGN: invite route not yet defined -->
<button class="inline-flex flex-1 h-[36px] items-center justify-center gap-[6px] rounded-[var(--p-border-radius)] border-0 bg-transparent text-[13px] font-medium text-[var(--p-text-color)] hover:bg-[var(--p-content-hover-background)]">
<Icon
name="tabler-user-plus"
:size="14"
/>
Invite
</button>
</div>
</Popover>
</div>
</template>
<style scoped>
/**
* FIX 2: ws-logo-square — width/height moved to Tailwind (w-8 h-8 on the element).
* Tailwind has no inset directional box-shadow utility at this granularity →
* scoped CSS last resort per RFC §7.4.
*/
.ws-logo-square {
box-shadow: inset 0 -2px 0 rgb(0 0 0 / 10%);
}
/**
* FIX 2: ws-logo-square-lg — width/height moved to Tailwind (w-9 h-9 on the element).
* Same box-shadow exception as above (inset shadow has no Tailwind equivalent).
* Background gradient set via inline :style on the element (dynamic hex pair).
*/
.ws-logo-square-lg {
box-shadow: inset 0 -2px 0 rgb(0 0 0 / 10%);
}
</style>

View File

@@ -0,0 +1,258 @@
/**
* 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).
* 5. Drawer is NOT rendered when isMobile=false (desktop); IS rendered when isMobile=true.
* 6. Desktop <aside> applies correct width class based on sidebarCollapsed.
*
* Stubs: Drawer is stubbed with a simple slot passthrough so we can inspect
* whether its `visible` prop is correctly bound to the store.
*
* @vueuse/core is mocked so we can control isMobile per test context.
*/
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'
import AppSidebar from '@/components-v2/layout/AppSidebar.vue'
import type { V2NavGroup } from '@/types/v2/nav'
// ---------------------------------------------------------------------------
// Mock @vueuse/core so we can control `isMobile` per test.
// AppSidebar uses useBreakpoints(breakpointsTailwind).smaller('lg').
// Vitest hoists vi.mock() above all imports, so the mock is registered
// before AppSidebar's transitive @vueuse/core import resolves; the
// factory only dereferences mockIsMobileRef inside .smaller(), invoked
// at component-mount time (well after this const initialises).
// ---------------------------------------------------------------------------
const mockIsMobileRef = ref(false)
vi.mock('@vueuse/core', () => ({
breakpointsTailwind: {},
useBreakpoints: () => ({
smaller: (_: string) => mockIsMobileRef,
}),
}))
// ---------------------------------------------------------------------------
// 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: '<div class="drawer-stub" :data-visible="visible"><slot /></div>',
}
const globalStubs = {
Drawer: DrawerStub,
SidebarHeader: { name: 'SidebarHeader', template: '<div class="sidebar-header-stub" />' },
SidebarNav: {
name: 'SidebarNav',
props: ['groups', 'collapsed'],
template: '<div class="sidebar-nav-stub" :data-collapsed="collapsed" :data-groups-count="groups.length" />',
},
WorkspaceSwitcher: {
name: 'WorkspaceSwitcher',
props: ['collapsed'],
template: '<div class="workspace-switcher-stub" />',
},
}
function mountSidebar(groups: V2NavGroup[] = testGroups) {
return mount(AppSidebar, {
props: { groups },
global: {
plugins: [createPinia()],
stubs: globalStubs,
},
})
}
describe('AppSidebar', () => {
beforeEach(() => {
setActivePinia(createPinia())
// Default to desktop (isMobile=false) so most tests exercise the stable path
mockIsMobileRef.value = false
})
// -------------------------------------------------------------------------
// 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))
})
// -------------------------------------------------------------------------
// Drawer v-if: mount/unmount based on isMobile
// -------------------------------------------------------------------------
it('desktop (isMobile=false): Drawer is NOT rendered', () => {
mockIsMobileRef.value = false
const wrapper = mountSidebar()
// v-if="isMobile" means the Drawer stub must be absent on desktop
expect(wrapper.find('.drawer-stub').exists()).toBe(false)
})
it('mobile (isMobile=true): Drawer IS rendered', async () => {
mockIsMobileRef.value = true
const wrapper = mountSidebar()
await wrapper.vm.$nextTick()
expect(wrapper.find('.drawer-stub').exists()).toBe(true)
})
// -------------------------------------------------------------------------
// Desktop <aside> width class based on sidebarCollapsed
// -------------------------------------------------------------------------
it('desktop: aside has w-64 class when sidebar is expanded', async () => {
mockIsMobileRef.value = false
const wrapper = mountSidebar()
const shell = useShellUiStore()
shell.sidebarCollapsed = false
await wrapper.vm.$nextTick()
expect(wrapper.find('aside').classes()).toContain('w-64')
expect(wrapper.find('aside').classes()).not.toContain('w-16')
})
it('desktop: aside has w-16 class when sidebar is collapsed', async () => {
mockIsMobileRef.value = false
const wrapper = mountSidebar()
const shell = useShellUiStore()
shell.sidebarCollapsed = true
await wrapper.vm.$nextTick()
expect(wrapper.find('aside').classes()).toContain('w-16')
expect(wrapper.find('aside').classes()).not.toContain('w-64')
})
// -------------------------------------------------------------------------
// Mobile Drawer wiring (only exercised when isMobile=true)
// -------------------------------------------------------------------------
it('mobile: Drawer visible is true when shell.mobileOpen is true', async () => {
mockIsMobileRef.value = true
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('mobile: Drawer visible is false when shell.mobileOpen is false', async () => {
mockIsMobileRef.value = true
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('mobile: emitting update:visible false from Drawer calls shell.setMobileOpen(false)', async () => {
mockIsMobileRef.value = true
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('mobile: emitting update:visible true from Drawer calls shell.setMobileOpen(true)', async () => {
mockIsMobileRef.value = true
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)
})
})

View File

@@ -0,0 +1,289 @@
/**
* AppTopbar.spec.ts
*
* Strategy: mount with createPinia(); stub all PrimeVue components so we
* test only the wiring of store actions to user interactions.
*
* Assertions:
* 1. Hamburger click → shell.setMobileOpen(true)
* 2. Theme toggle click → shell.setTheme with flipped value (light→dark, dark→light)
* 3. Density toggle click → shell.setDensity with flipped value
* 4. User-menu Sign out command → authStore.logout called
* 5. Breadcrumb model mapping:
* - non-last items carry a `command` that calls router.push with the item's `to`
* - last item has NO `command` (non-interactive) and NO `route` key (FIX A regression)
*
* useBreadcrumb() calls useRoute() internally. We provide a minimal
* vue-router mock via vi.mock so the composable has a route to call.
* useRoute is exposed as a vi.fn() so individual tests can override the
* matched records without re-importing the module.
*/
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 { useAuthStore } from '@/stores/useAuthStore'
import AppTopbar from '@/components-v2/layout/AppTopbar.vue'
// ---------------------------------------------------------------------------
// Mock vue-router (useBreadcrumb calls useRoute internally).
// vi.hoisted() initialises these vi.fn() instances before the vi.mock()
// factory runs. Vitest hoists vi.hoisted() + vi.mock() above all imports,
// so the mock is registered before AppTopbar's transitive vue-router
// import resolves — physical import order here is irrelevant to that.
// ---------------------------------------------------------------------------
const { mockRouterPush, mockUseRoute } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
mockUseRoute: vi.fn(() => ({ matched: [] as unknown[] })),
}))
vi.mock('vue-router', () => ({
useRoute: mockUseRoute,
useRouter: () => ({
push: mockRouterPush,
}),
}))
// ---------------------------------------------------------------------------
// Stubs for PrimeVue components
// ---------------------------------------------------------------------------
const globalStubs = {
Breadcrumb: { name: 'Breadcrumb', props: ['model'], template: '<nav class="breadcrumb-stub" />' },
InputText: { name: 'InputText', template: '<input class="input-text-stub" />' },
OverlayBadge: { name: 'OverlayBadge', props: ['value', 'severity'], template: '<div class="overlay-badge-stub"><slot /></div>' },
Popover: {
name: 'Popover',
template: '<div class="popover-stub"><slot /></div>',
methods: { toggle: vi.fn(), hide: vi.fn() },
},
Avatar: {
name: 'Avatar',
props: ['label', 'shape', 'pt'],
template: '<div class="avatar-stub" @click="$emit(\'click\', $event)">{{ label }}</div>',
emits: ['click'],
},
Menu: {
name: 'Menu',
props: ['model', 'popup'],
template: '<div class="menu-stub"><slot name="start" /></div>',
methods: { toggle: vi.fn(), hide: vi.fn() },
},
Icon: { name: 'Icon', props: ['name', 'size'], template: '<span class="icon-stub" :data-name="name" />' },
}
// ---------------------------------------------------------------------------
// Mount helper
// ---------------------------------------------------------------------------
function mountTopbar() {
return mount(AppTopbar, {
global: {
plugins: [createPinia()],
stubs: globalStubs,
},
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('AppTopbar', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockRouterPush.mockReset()
mockUseRoute.mockReset()
// Default: empty matched array (no breadcrumb items)
mockUseRoute.mockReturnValue({ matched: [] })
})
// -------------------------------------------------------------------------
// Hamburger → setMobileOpen(true)
// -------------------------------------------------------------------------
it('hamburger button click calls shell.setMobileOpen(true)', async () => {
const wrapper = mountTopbar()
const shell = useShellUiStore()
const spy = vi.spyOn(shell, 'setMobileOpen')
// The hamburger is the first button (aria-label="Open menu")
const hamburger = wrapper.find('button[aria-label="Open menu"]')
expect(hamburger.exists()).toBe(true)
await hamburger.trigger('click')
expect(spy).toHaveBeenCalledWith(true)
})
// -------------------------------------------------------------------------
// Theme toggle → setTheme with flipped value
// -------------------------------------------------------------------------
it('theme toggle flips light → dark', async () => {
const wrapper = mountTopbar()
const shell = useShellUiStore()
shell.theme = 'light'
const spy = vi.spyOn(shell, 'setTheme')
const themeBtn = wrapper.find('button[aria-label="Switch to dark mode"]')
expect(themeBtn.exists()).toBe(true)
await themeBtn.trigger('click')
expect(spy).toHaveBeenCalledWith('dark')
})
it('theme toggle flips dark → light', async () => {
const wrapper = mountTopbar()
const shell = useShellUiStore()
shell.theme = 'dark'
const spy = vi.spyOn(shell, 'setTheme')
// After setting dark we need nextTick so the aria-label computed updates
await wrapper.vm.$nextTick()
const themeBtn = wrapper.find('button[aria-label="Switch to light mode"]')
expect(themeBtn.exists()).toBe(true)
await themeBtn.trigger('click')
expect(spy).toHaveBeenCalledWith('light')
})
// -------------------------------------------------------------------------
// Density toggle → setDensity with flipped value
// -------------------------------------------------------------------------
it('density toggle flips comfortable → compact', async () => {
const wrapper = mountTopbar()
const shell = useShellUiStore()
shell.density = 'comfortable'
const spy = vi.spyOn(shell, 'setDensity')
const densityBtn = wrapper.find('button[aria-label="Switch to compact"]')
expect(densityBtn.exists()).toBe(true)
await densityBtn.trigger('click')
expect(spy).toHaveBeenCalledWith('compact')
})
it('density toggle flips compact → comfortable', async () => {
const wrapper = mountTopbar()
const shell = useShellUiStore()
shell.density = 'compact'
const spy = vi.spyOn(shell, 'setDensity')
await wrapper.vm.$nextTick()
const densityBtn = wrapper.find('button[aria-label="Switch to comfortable"]')
expect(densityBtn.exists()).toBe(true)
await densityBtn.trigger('click')
expect(spy).toHaveBeenCalledWith('comfortable')
})
// -------------------------------------------------------------------------
// Sign out → authStore.logout()
// -------------------------------------------------------------------------
it('Sign out menu item command calls authStore.logout()', async () => {
const wrapper = mountTopbar()
const authStore = useAuthStore()
// Spy on logout — stub the async to resolve immediately
const logoutSpy = vi.spyOn(authStore, 'logout').mockResolvedValue()
// Access the computed userMenuItems exposed on the component's vm
// The menu items are passed as :model to the stubbed Menu component.
// We need to find the Sign out item and call its command directly.
const menuStub = wrapper.findComponent({ name: 'Menu' })
expect(menuStub.exists()).toBe(true)
// The model prop is the computed userMenuItems array
const model = menuStub.props('model') as Array<{ items: Array<{ label: string; command?: () => void }> }>
// Find Sign out in the nested items groups
const signOutItem = model
.flatMap(group => group.items ?? [])
.find(item => item.label === 'Sign out')
expect(signOutItem).toBeDefined()
expect(typeof signOutItem?.command).toBe('function')
signOutItem?.command?.()
expect(logoutSpy).toHaveBeenCalledOnce()
})
// -------------------------------------------------------------------------
// FIX A regression: breadcrumb model mapping uses command+router.push,
// NOT the `route` key that this PrimeVue Breadcrumb version ignores.
//
// BreadcrumbItem.vue renders <a :href="item.url || '#'"> and calls
// item.command on click — it never reads `route`. This test would have
// caught the broken mapping that set `route` instead of `command`.
// -------------------------------------------------------------------------
it('breadcrumb model: non-last items have command that calls router.push; last item has no command and no route key', async () => {
// Provide two matched records so we get a non-last item (with `to`) and
// a last item (current, no `to`).
mockUseRoute.mockReturnValue({
matched: [
{ meta: { breadcrumb: 'Dashboard' }, name: 'dashboard', path: '/dashboard' },
{ meta: { breadcrumb: 'Events' }, name: 'events', path: '/events' },
],
})
const wrapper = mountTopbar()
const breadcrumbStub = wrapper.findComponent({ name: 'Breadcrumb' })
expect(breadcrumbStub.exists()).toBe(true)
const model = breadcrumbStub.props('model') as Array<{
label: string
command?: () => void
route?: unknown
}>
// Should have two items produced by toBreadcrumbItems
expect(model).toHaveLength(2)
const [firstItem, lastItem] = model as [typeof model[0], typeof model[0]]
// Non-last item: must carry `command`, must NOT carry `route`
expect(firstItem.label).toBe('Dashboard')
expect(typeof firstItem.command).toBe('function')
expect('route' in firstItem).toBe(false)
// Invoking command must call router.push with the item's resolved `to` path
firstItem.command!()
expect(mockRouterPush).toHaveBeenCalledOnce()
expect(mockRouterPush).toHaveBeenCalledWith('/dashboard')
// Last/current item: must have NO command (non-interactive), NO `route` key
expect(lastItem.label).toBe('Events')
expect(lastItem.command).toBeUndefined()
expect('route' in lastItem).toBe(false)
})
})

View File

@@ -0,0 +1,260 @@
/**
* RightDrawer.spec.ts — unit tests for the global right-side drawer shell.
*
* Strategy: mount RightDrawer with @vue/test-utils, stub PrimeVue <Drawer>
* so we avoid the full overlay/teleport machinery in jsdom. The stub exposes
* the same `visible` prop and emits `update:visible` so we can test the
* writable-computed v-model:visible wiring against the real Pinia store.
*
* What is tested (real store/composable, not stub theater):
* 1. Drawer visible when store drawer is open, title renders.
* 2. Emitting update:visible=false closes the store drawer.
* 3. flush:true in drawerProps → body has p-0 class (no padding).
* 4. flush:false/absent → body has p-4 class (padded).
* 5. Unknown component name (nothing registered) → graceful empty state, no throw.
* 6. Registered stub component renders in body; non-chrome props forwarded.
*
* PrimeVue Drawer stub: mirrors only the contract surface used by RightDrawer —
* `visible` prop + `update:visible` emit + default slot passthrough.
*
* Icon is stubbed to a simple span so jsdom doesn't choke on Iconify SVG fetch.
*/
import { createPinia, setActivePinia } from 'pinia'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it } from 'vitest'
import { defineComponent } from 'vue'
import { registerDrawerComponent } from '@/composables/drawerRegistry'
import { useShellUiStore } from '@/stores/useShellUiStore'
import RightDrawer from '@/components-v2/layout/RightDrawer.vue'
// ---------------------------------------------------------------------------
// Unique prefix per run — drawerRegistry is a module singleton; avoid leakage.
// ---------------------------------------------------------------------------
const PREFIX = `rd-spec-${Date.now()}-`
// ---------------------------------------------------------------------------
// Stubs
// ---------------------------------------------------------------------------
/**
* DrawerStub mirrors the PrimeVue Drawer contract used by RightDrawer:
* - `visible` prop (Boolean)
* - emits `update:visible` on close (v-model:visible contract)
* - renders its default slot so header/body children are mounted
*/
const DrawerStub = defineComponent({
name: 'Drawer',
props: {
visible: { type: Boolean, default: false },
position: { type: String, default: 'right' },
pt: { type: Object, default: () => ({}) },
},
emits: ['update:visible'],
template: `
<div class="drawer-stub" :data-visible="visible">
<slot />
</div>
`,
})
/**
* IconStub — prevents Iconify SVG lookups in jsdom.
*/
const IconStub = defineComponent({
name: 'Icon',
props: ['name', 'size'],
template: '<span class="icon-stub" :data-icon="name" />',
})
// ---------------------------------------------------------------------------
// Mount helper
// ---------------------------------------------------------------------------
function mountDrawer() {
return mount(RightDrawer, {
global: {
plugins: [createPinia()],
stubs: {
Drawer: DrawerStub,
Icon: IconStub,
},
},
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('RightDrawer', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
// -------------------------------------------------------------------------
// 1. Store open → Drawer visible, title renders
// -------------------------------------------------------------------------
it('renders Drawer as visible when store drawer is open', async () => {
const wrapper = mountDrawer()
const shell = useShellUiStore()
shell.openDrawer('SomePanel', { title: 'Hello' })
await wrapper.vm.$nextTick()
const drawer = wrapper.find('.drawer-stub')
expect(drawer.attributes('data-visible')).toBe('true')
})
it('renders the title from drawerProps', async () => {
const wrapper = mountDrawer()
const shell = useShellUiStore()
shell.openDrawer('SomePanel', { title: 'Hello' })
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Hello')
})
it('Drawer is not visible when store drawer is closed', async () => {
const wrapper = mountDrawer()
// store starts closed by default
await wrapper.vm.$nextTick()
const drawer = wrapper.find('.drawer-stub')
expect(drawer.attributes('data-visible')).toBe('false')
})
// -------------------------------------------------------------------------
// 2. Emitting update:visible=false → store drawer closed
// -------------------------------------------------------------------------
it('emitting update:visible=false closes the store drawer', async () => {
const wrapper = mountDrawer()
const shell = useShellUiStore()
shell.openDrawer('SomePanel', { title: 'Test' })
await wrapper.vm.$nextTick()
// Simulate PrimeVue Drawer's v-model:visible setter called with false
// (e.g. user clicks overlay or presses Escape)
await wrapper.findComponent(DrawerStub).vm.$emit('update:visible', false)
await wrapper.vm.$nextTick()
expect(shell.drawer.isOpen).toBe(false)
})
// -------------------------------------------------------------------------
// 3 & 4. flush prop → padding class
// -------------------------------------------------------------------------
it('body has p-0 class when flush is true', async () => {
const wrapper = mountDrawer()
const shell = useShellUiStore()
shell.openDrawer('SomePanel', { title: 'Flush', flush: true })
await wrapper.vm.$nextTick()
// The body div is the scrollable container (class includes flex-1 overflow-y-auto)
const bodyDiv = wrapper.find('.flex-1.overflow-y-auto')
expect(bodyDiv.classes()).toContain('p-0')
expect(bodyDiv.classes()).not.toContain('p-4')
})
it('body has p-4 class when flush is false or absent', async () => {
const wrapper = mountDrawer()
const shell = useShellUiStore()
shell.openDrawer('SomePanel', { title: 'Padded' })
await wrapper.vm.$nextTick()
const bodyDiv = wrapper.find('.flex-1.overflow-y-auto')
expect(bodyDiv.classes()).toContain('p-4')
expect(bodyDiv.classes()).not.toContain('p-0')
})
// -------------------------------------------------------------------------
// 5. Unknown component name → graceful empty state, no throw
// -------------------------------------------------------------------------
it('renders graceful empty state for an unregistered component name', async () => {
const wrapper = mountDrawer()
const shell = useShellUiStore()
shell.openDrawer(`${PREFIX}NotRegistered`, { title: 'Missing' })
await wrapper.vm.$nextTick()
// Should show the empty state text, not throw
expect(wrapper.text()).toContain('Geen inhoud')
})
// -------------------------------------------------------------------------
// 6. Registered component renders; non-chrome props forwarded
// -------------------------------------------------------------------------
it('renders a registered body component and forwards non-chrome props', async () => {
const BodyStub = defineComponent({
props: { someId: { type: String, default: '' } },
template: '<div class="body-stub" :data-id="someId" />',
})
const name = `${PREFIX}BodyStub`
registerDrawerComponent(name, BodyStub)
const wrapper = mountDrawer()
const shell = useShellUiStore()
// `title` and `flush` are chrome keys — they must NOT reach BodyStub.
// `someId` is a body prop — it MUST be forwarded.
shell.openDrawer(name, { title: 'With Body', flush: false, someId: 'abc-123' })
await wrapper.vm.$nextTick()
const body = wrapper.find('.body-stub')
expect(body.exists()).toBe(true)
expect(body.attributes('data-id')).toBe('abc-123')
// Graceful-empty-state text must NOT appear when a component is rendered
expect(wrapper.text()).not.toContain('Geen inhoud')
})
// -------------------------------------------------------------------------
// 7. Switching the open drawer component swaps the body cleanly
// (the :key="component" remount guard — opening B while A is open
// unmounts A and mounts B; A's DOM/state must not linger).
// -------------------------------------------------------------------------
it('remounts the body when the open drawer switches to another component', async () => {
const BodyA = defineComponent({ template: '<div class="body-a">A</div>' })
const BodyB = defineComponent({ template: '<div class="body-b">B</div>' })
const nameA = `${PREFIX}BodyA`
const nameB = `${PREFIX}BodyB`
registerDrawerComponent(nameA, BodyA)
registerDrawerComponent(nameB, BodyB)
const wrapper = mountDrawer()
const shell = useShellUiStore()
shell.openDrawer(nameA, { title: 'A' })
await wrapper.vm.$nextTick()
expect(wrapper.find('.body-a').exists()).toBe(true)
expect(wrapper.find('.body-b').exists()).toBe(false)
// Switch to B while the drawer is still open — :key change forces a
// full remount so A is gone and B is mounted (no stale A instance).
shell.openDrawer(nameB, { title: 'B' })
await wrapper.vm.$nextTick()
expect(wrapper.find('.body-b').exists()).toBe(true)
expect(wrapper.find('.body-a').exists()).toBe(false)
})
})

View File

@@ -0,0 +1,196 @@
/**
* 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 useBreakpoints 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'
import SidebarHeader from '@/components-v2/layout/SidebarHeader.vue'
// ---------------------------------------------------------------------------
// Mock @vueuse/core so we can control `isMobile` per test.
// The component uses useBreakpoints(breakpointsTailwind).smaller('lg').
// Vitest hoists vi.mock() above all imports, so the mock is registered
// before SidebarHeader's transitive @vueuse/core import resolves; the
// factory only dereferences mockIsMobileRef inside .smaller(), invoked
// at component-mount time (well after this const initialises).
// ---------------------------------------------------------------------------
const mockIsMobileRef = ref(false)
vi.mock('@vueuse/core', () => ({
breakpointsTailwind: {},
useBreakpoints: () => ({
smaller: (_: string) => mockIsMobileRef,
}),
}))
// ---------------------------------------------------------------------------
// 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: '<span class="icon-stub" />' },
}
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 <span>
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')
})
})

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { isNavItemActive } from '@/components-v2/layout/sidebarNavActive'
import type { V2NavItem } from '@/types/v2/nav'
function makeItem(name: string): V2NavItem {
return { id: name, label: name, icon: 'tabler-home', to: { name } }
}
describe('isNavItemActive', () => {
it('returns true when the current route name exactly matches', () => {
expect(isNavItemActive(makeItem('dashboard'), 'dashboard')).toBe(true)
})
it('returns false when the current route name differs', () => {
expect(isNavItemActive(makeItem('dashboard'), 'events')).toBe(false)
})
it('returns false when currentRouteName is null', () => {
expect(isNavItemActive(makeItem('dashboard'), null)).toBe(false)
})
it('returns false when currentRouteName is undefined', () => {
expect(isNavItemActive(makeItem('dashboard'), undefined)).toBe(false)
})
it('returns true for nested routes: item name is a prefix of current route (dash-separated)', () => {
// e.g. item "organisation" should be active on route "organisation-settings"
expect(isNavItemActive(makeItem('organisation'), 'organisation-settings')).toBe(true)
})
it('does not match partial-word prefixes: "org" should NOT match "organisation-settings"', () => {
expect(isNavItemActive(makeItem('org'), 'organisation-settings')).toBe(false)
})
it('handles symbol route names: returns false (not string — no match)', () => {
const sym = Symbol('dashboard')
expect(isNavItemActive(makeItem('dashboard'), sym)).toBe(false)
})
it('item with to as { name: string } matches correctly', () => {
const item: V2NavItem = { id: 'events', label: 'Events', icon: 'tabler-calendar', to: { name: 'events' } }
expect(isNavItemActive(item, 'events')).toBe(true)
})
})

View File

@@ -0,0 +1,37 @@
import type { V2NavItem } from '@/types/v2/nav'
/**
* Pure helper — determines whether a V2NavItem is "active" given the current
* route name.
*
* Active rules (simplest correct definition):
* 1. Exact match: currentRouteName === item.to.name
* 2. Prefix match for nested routes: currentRouteName starts with
* item.to.name + '-' (e.g. item "organisation" is active on
* "organisation-settings"). The dash boundary prevents "org" from
* spuriously matching "organisation-settings".
*
* Only `to` values that are a plain object with a string `name` property are
* compared — string/array `to` values always return false (router-push
* style not used in v1 nav).
*/
export function isNavItemActive(
item: V2NavItem,
currentRouteName: string | symbol | null | undefined,
): boolean {
if (typeof currentRouteName !== 'string')
return false
const to = item.to
if (typeof to !== 'object' || to === null || Array.isArray(to))
return false
const itemName = (to as { name?: unknown }).name
if (typeof itemName !== 'string')
return false
return currentRouteName === itemName
|| currentRouteName.startsWith(`${itemName}-`)
}

View File

@@ -0,0 +1,189 @@
<script setup lang="ts">
/**
* AppDialog — reusable modal dialog wrapper built on PrimeVue <Dialog>.
*
* Replaces crewli-starter's hand-rolled AppModal.vue pattern (Teleport +
* manual .scrim div + window.addEventListener('keydown') Escape handler).
* PrimeVue Dialog provides the overlay (scrim), focus-trap, Escape-to-close,
* teleport-to-body, and ARIA attributes — none of that machinery lives here.
*
* ## v-model:open contract
* The caller binds `v-model:open` (a Boolean). Internally, a writable
* computed `visible` maps:
* get → props.open
* set (v) → emit('update:open', v)
* PrimeVue Dialog is wired with `v-model:visible="visible"`. When the user
* closes via overlay-click or Escape, Dialog emits update:visible=false → the
* computed setter fires → we emit update:open=false → caller clears its ref.
*
* A bare `close` event is also emitted on every close so callers that prefer
* an event listener pattern don't have to watch the prop. It carries no
* payload (no boolean needed — it always means "closed").
*
* ## Slot contract
* default — scrollable body (always present)
* #tabs — rendered between header and body ONLY if the caller provides it
* #footer — rendered at the bottom ONLY if the caller provides it
* The header (title + optional sub + close button) is always rendered by this
* component; PrimeVue's own header slot is suppressed via :pt.
*
* ## Accessible name
* Because PrimeVue's built-in header is suppressed (`header: { class: 'hidden' }`),
* its default `aria-labelledby` would point to an empty hidden element, leaving
* the dialog without an accessible name. We give the custom <h2> a stable
* useId() id and override the root `aria-labelledby` to it via :pt.root (which
* mergeProps-overrides PrimeVue's internal value). When no title is provided
* the override is omitted (a titleless dialog is an edge the caller is expected
* to name via its own content; documented limitation).
*
* ## CSS translation (crewli-starter main.css → Tailwind + :pt)
* .modal-host → PrimeVue Dialog handles centering + padding internally
* .modal (border/bg/shadow/radius) → :pt root class overrides
* .modal-head → flex items-start justify-between gap-4 px-6 py-5 border-b
* .modal-title → text-[17px] font-bold tracking-tight leading-tight m-0
* .modal-sub → text-[13px] text-[var(--p-text-muted-color)] mt-1
* .modal-body → px-6 py-5 overflow-y-auto flex-1
* .modal-foot → px-5 py-3.5 border-t bg-[var(--p-content-hover-background)]
* flex justify-end gap-2 rounded-b-[var(--p-border-radius-lg)]
* .icon-btn (close btn) → inline-flex items-center justify-center w-[38px] h-[38px]
* rounded-[var(--p-border-radius)] border-0 bg-transparent
* text-[var(--p-text-muted-color)]
* hover:bg-[var(--p-content-hover-background)]
* hover:text-[var(--p-text-color)]
* transition-colors focus-visible:outline-2
* focus-visible:outline-[var(--p-primary-color)]
* focus-visible:outline-offset-1
*
* ## :pt justification
* PrimeVue Dialog's default header/content/footer slots come with their own
* padding and layout that conflicts with the crewli-starter spec. We suppress
* the default header entirely (`header: { class: 'hidden' }`) and render our
* own. The content wrapper gets `p-0 flex flex-col overflow-hidden` so the
* body region controls its own padding and scroll independently. The root gets
* the border, radius, and shadow tokens from the design system, plus the
* aria-labelledby override described above.
*/
import { computed, useId } from 'vue'
import Dialog from 'primevue/dialog'
import Icon from '@/components/Icon.vue'
const props = defineProps<{
open: boolean
title?: string
sub?: string
width?: string
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'close': []
}>()
/**
* Writable computed wiring PrimeVue Dialog's v-model:visible to our v-model:open.
* get → reads props.open (the caller's truth)
* set → emits update:open so the caller can clear its ref (v-model contract)
* and also emits close for event-listener callers
*/
const visible = computed<boolean>({
get: () => props.open,
set: (v: boolean) => {
emit('update:open', v)
if (!v)
emit('close')
},
})
/**
* Stable, document-unique id for the custom <h2>, used to wire the dialog's
* accessible name (see "Accessible name" note above).
*/
const titleId = useId()
/**
* Passthrough object. Extracted from the template so the root section can
* conditionally carry the aria-labelledby override (only meaningful when a
* title — and therefore the <h2 :id="titleId"> — is rendered).
*/
const dialogPt = computed(() => ({
root: {
class: [
'border border-[var(--p-content-border-color)]',
'bg-[var(--p-content-background)]',
'rounded-[var(--p-border-radius-lg)]',
'shadow-[var(--p-overlay-modal-shadow)]',
'max-h-[calc(100vh-48px)]',
'flex flex-col',
'overflow-hidden',
].join(' '),
...(props.title ? { 'aria-labelledby': titleId } : {}),
},
header: { class: 'hidden' },
content: { class: 'p-0 flex flex-col overflow-hidden flex-1' },
footer: { class: 'hidden' },
}))
</script>
<template>
<Dialog
v-model:visible="visible"
modal
dismissable-mask
:style="props.width ? { width: props.width } : { width: 'min(680px, 100%)' }"
:pt="dialogPt"
>
<!-- Modal header: title + optional sub + close button -->
<div class="flex items-start justify-between gap-4 px-6 py-5 border-b border-[var(--p-content-border-color)] flex-shrink-0">
<div>
<h2
v-if="props.title"
:id="titleId"
class="text-[17px] font-bold tracking-tight leading-tight m-0"
>
{{ props.title }}
</h2>
<div
v-if="props.sub"
class="text-[13px] text-[var(--p-text-muted-color)] mt-1"
>
{{ props.sub }}
</div>
</div>
<button
type="button"
aria-label="Close"
class="inline-flex items-center justify-center w-[38px] h-[38px] rounded-[var(--p-border-radius)] border-0 bg-transparent text-[var(--p-text-muted-color)] hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] transition-colors focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-1 cursor-pointer flex-shrink-0"
@click="visible = false"
>
<Icon
name="tabler-x"
:size="18"
/>
</button>
</div>
<!-- Optional tabs region: rendered only when the caller provides #tabs -->
<div
v-if="$slots.tabs"
class="flex-shrink-0"
>
<slot name="tabs" />
</div>
<!-- Scrollable body: default slot -->
<div class="px-6 py-5 overflow-y-auto flex-1">
<slot />
</div>
<!-- Optional footer: rendered only when the caller provides #footer -->
<div
v-if="$slots.footer"
class="px-5 py-3.5 border-t border-[var(--p-content-border-color)] bg-[var(--p-content-hover-background)] flex justify-end gap-2 rounded-b-[var(--p-border-radius-lg)] flex-shrink-0"
>
<slot name="footer" />
</div>
</Dialog>
</template>

View File

@@ -0,0 +1,283 @@
/**
* AppDialog.spec.ts — unit tests for the AppDialog reusable modal wrapper.
*
* Strategy: mount AppDialog with @vue/test-utils, stub PrimeVue <Dialog>
* so we avoid the full overlay/teleport/focus-trap machinery in jsdom.
* The DialogStub mirrors the v-model:visible contract and passes through
* all named slots so the inner header/tabs/body/footer DOM is fully mounted
* and inspectable.
*
* What is tested (real component logic, not stub theater):
* 1. open=true → Dialog visible; open=false → not visible.
* 2. title + sub render in the header when provided.
* 3. sub absent → no sub element in the DOM.
* 4. Dialog emits update:visible=false → AppDialog emits update:open=false.
* 5. Close button click → emits update:open=false.
* 6. #footer slot: provided → footer region renders; absent → no footer.
* 7. #tabs slot: provided → tabs region renders; absent → no tabs region.
* 8. Default slot content renders in the body.
*/
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { defineComponent, h } from 'vue'
import AppDialog from '@/components-v2/shared/AppDialog.vue'
// ---------------------------------------------------------------------------
// Stubs
// ---------------------------------------------------------------------------
/**
* DialogStub mirrors the PrimeVue Dialog contract used by AppDialog:
* - `visible` prop (Boolean) driven by v-model:visible
* - emits `update:visible` to simulate overlay/Escape close
* - renders all named slots + default slot so the inner DOM is mounted
*
* We expose a `data-visible` attribute so tests can assert Dialog visibility
* without inspecting internal Vue internals.
*/
const DialogStub = defineComponent({
// Not `name: 'Dialog'` — <dialog> is a native HTML element and
// vue/no-reserved-component-names (error) forbids it. VTU matches this
// stub via the `stubs: { Dialog: DialogStub }` key (AppDialog's import
// name) and `findComponent(DialogStub)` by reference, NOT this `name`,
// so renaming it here is behaviour-neutral.
name: 'DialogStub',
props: {
visible: { type: Boolean, default: false },
modal: { type: Boolean, default: false },
dismissableMask: { type: Boolean, default: false },
pt: { type: Object, default: () => ({}) },
style: { type: Object, default: () => ({}) },
},
emits: ['update:visible'],
template: `
<div class="dialog-stub" :data-visible="String(visible)">
<slot name="default" />
</div>
`,
})
/**
* IconStub — prevents Iconify SVG lookups in jsdom.
*/
const IconStub = defineComponent({
name: 'Icon',
props: ['name', 'size'],
template: '<span class="icon-stub" :data-icon="name" />',
})
// ---------------------------------------------------------------------------
// Mount helper
// ---------------------------------------------------------------------------
interface MountOptions {
open?: boolean
title?: string
sub?: string
width?: string
slots?: Record<string, () => ReturnType<typeof h>>
}
function mountDialog(options: MountOptions = {}) {
const { open = false, title, sub, width, slots = {} } = options
return mount(AppDialog, {
props: { open, title, sub, width },
slots,
global: {
stubs: {
Dialog: DialogStub,
Icon: IconStub,
},
},
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('AppDialog', () => {
// -------------------------------------------------------------------------
// 1. open prop drives Dialog visibility
// -------------------------------------------------------------------------
it('passes visible=true to Dialog when open=true', () => {
const wrapper = mountDialog({ open: true })
const dialog = wrapper.find('.dialog-stub')
expect(dialog.attributes('data-visible')).toBe('true')
})
it('passes visible=false to Dialog when open=false', () => {
const wrapper = mountDialog({ open: false })
const dialog = wrapper.find('.dialog-stub')
expect(dialog.attributes('data-visible')).toBe('false')
})
// -------------------------------------------------------------------------
// 2. title + sub render in the header
// -------------------------------------------------------------------------
it('renders the title when provided', () => {
const wrapper = mountDialog({ open: true, title: 'My Dialog Title' })
expect(wrapper.text()).toContain('My Dialog Title')
})
it('renders the sub text when provided', () => {
const wrapper = mountDialog({ open: true, title: 'Title', sub: 'Helpful subtitle' })
expect(wrapper.text()).toContain('Helpful subtitle')
// The sub element is the .mt-1 div under the header — assert it actually exists
expect(wrapper.find('.mt-1').exists()).toBe(true)
})
it('passes the width prop through to the Dialog style', () => {
const wrapper = mountDialog({ open: true, width: '400px' })
const dialog = wrapper.findComponent(DialogStub)
expect(dialog.props('style')).toEqual({ width: '400px' })
})
it('applies the default width when no width prop is given', () => {
const wrapper = mountDialog({ open: true })
const dialog = wrapper.findComponent(DialogStub)
expect(dialog.props('style')).toEqual({ width: 'min(680px, 100%)' })
})
// -------------------------------------------------------------------------
// 3. sub absent → no sub element
// -------------------------------------------------------------------------
it('does not render the sub element when sub is absent', () => {
const wrapper = mountDialog({ open: true, title: 'Title Only' })
// There should be no text for a subtitle
expect(wrapper.text()).not.toContain('Helpful subtitle')
// The v-if="props.sub" element should not render
// We look for a div with mt-1 class which is the sub element
const subEl = wrapper.find('.mt-1')
expect(subEl.exists()).toBe(false)
})
// -------------------------------------------------------------------------
// 4. Dialog emits update:visible=false → AppDialog emits update:open=false
// -------------------------------------------------------------------------
it('emits update:open=false AND close when Dialog emits update:visible=false', async () => {
const wrapper = mountDialog({ open: true })
await wrapper.findComponent(DialogStub).vm.$emit('update:visible', false)
await wrapper.vm.$nextTick()
const openEvents = wrapper.emitted('update:open')
const closeEvents = wrapper.emitted('close')
expect(openEvents).toBeTruthy()
expect(openEvents![0]).toEqual([false])
expect(closeEvents).toBeTruthy()
expect(closeEvents![0]).toEqual([])
// Contract: update:open fires before close (JSDoc-documented order)
const order = wrapper.emitted()
const keys = Object.keys(order)
expect(keys.indexOf('update:open')).toBeLessThan(keys.indexOf('close'))
})
// -------------------------------------------------------------------------
// 5. Close button click → emits update:open=false
// -------------------------------------------------------------------------
it('emits update:open=false AND close when the close button is clicked', async () => {
const wrapper = mountDialog({ open: true })
const closeBtn = wrapper.find('button[aria-label="Close"]')
expect(closeBtn.exists()).toBe(true)
await closeBtn.trigger('click')
await wrapper.vm.$nextTick()
const openEvents = wrapper.emitted('update:open')
const closeEvents = wrapper.emitted('close')
expect(openEvents).toBeTruthy()
expect(openEvents![0]).toEqual([false])
expect(closeEvents).toBeTruthy()
expect(closeEvents![0]).toEqual([])
})
// -------------------------------------------------------------------------
// 6. #footer slot
// -------------------------------------------------------------------------
it('renders the footer region when #footer slot is provided', () => {
const wrapper = mountDialog({
open: true,
slots: {
footer: () => h('span', { class: 'footer-content' }, 'Save'),
},
})
expect(wrapper.find('.footer-content').exists()).toBe(true)
})
it('does not render a footer region when #footer slot is absent', () => {
const wrapper = mountDialog({ open: true })
// The footer div has `justify-end` + `gap-2` + `border-t` classes
// It should not exist when no footer slot is provided
const footerEl = wrapper.find('.justify-end.gap-2')
expect(footerEl.exists()).toBe(false)
})
// -------------------------------------------------------------------------
// 7. #tabs slot
// -------------------------------------------------------------------------
it('renders the tabs region when #tabs slot is provided', () => {
const wrapper = mountDialog({
open: true,
slots: {
tabs: () => h('div', { class: 'tabs-content' }, 'Tab 1'),
},
})
expect(wrapper.find('.tabs-content').exists()).toBe(true)
})
it('does not render the tabs region when #tabs slot is absent', () => {
const wrapper = mountDialog({ open: true })
// The tabs wrapper div is the only flex-shrink-0 div that wraps the tabs slot
// It should not be in the DOM when no tabs slot is provided
expect(wrapper.find('.tabs-content').exists()).toBe(false)
})
// -------------------------------------------------------------------------
// 8. Default slot content renders in the body
// -------------------------------------------------------------------------
it('renders default slot content in the scrollable body', () => {
const wrapper = mountDialog({
open: true,
slots: {
default: () => h('p', { class: 'body-content' }, 'Body text here'),
},
})
const body = wrapper.find('.body-content')
expect(body.exists()).toBe(true)
expect(body.text()).toBe('Body text here')
})
})

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { defineComponent } from 'vue'
import { registerDrawerComponent, resolveDrawerComponent } from '@/composables/drawerRegistry'
// Minimal component stubs — no template needed for registry identity checks.
const StubA = defineComponent({ template: '<div />' })
const StubB = defineComponent({ template: '<div />' })
// The registry is a module singleton. Each test registers its own names so
// tests remain independent without needing a reset hook.
describe('drawerRegistry', () => {
const PREFIX = `test-${Date.now()}-` // unique per run to avoid cross-test leakage
it('resolves null for an unregistered name', () => {
expect(resolveDrawerComponent(`${PREFIX}unknown`)).toBeNull()
})
it('resolves null for null input', () => {
expect(resolveDrawerComponent(null)).toBeNull()
})
it('resolves null for undefined input', () => {
expect(resolveDrawerComponent(undefined)).toBeNull()
})
it('resolves null for empty string', () => {
expect(resolveDrawerComponent('')).toBeNull()
})
it('returns the registered component', () => {
registerDrawerComponent(`${PREFIX}StubA`, StubA)
expect(resolveDrawerComponent(`${PREFIX}StubA`)).toBe(StubA)
})
it('re-registering overwrites the previous component', () => {
const name = `${PREFIX}overwrite`
registerDrawerComponent(name, StubA)
registerDrawerComponent(name, StubB)
expect(resolveDrawerComponent(name)).toBe(StubB)
})
it('independent names do not collide', () => {
registerDrawerComponent(`${PREFIX}A`, StubA)
registerDrawerComponent(`${PREFIX}B`, StubB)
expect(resolveDrawerComponent(`${PREFIX}A`)).toBe(StubA)
expect(resolveDrawerComponent(`${PREFIX}B`)).toBe(StubB)
})
})

View File

@@ -0,0 +1,151 @@
import { describe, expect, it } from 'vitest'
import { toBreadcrumbItems } from '@/composables/useBreadcrumb'
import type { BreadcrumbRouteRecord } from '@/composables/useBreadcrumb'
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const withTitle = (title: string, path: string): BreadcrumbRouteRecord => ({
meta: { title },
path,
})
const withBreadcrumb = (breadcrumb: string, path: string): BreadcrumbRouteRecord => ({
meta: { breadcrumb },
path,
})
const withBoth = (title: string, breadcrumb: string, path: string): BreadcrumbRouteRecord => ({
meta: { title, breadcrumb },
path,
})
const withNeither = (path: string): BreadcrumbRouteRecord => ({
meta: {},
path,
})
const withNoMeta = (path: string): BreadcrumbRouteRecord => ({
path,
})
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('toBreadcrumbItems', () => {
it('returns empty array for empty matched', () => {
expect(toBreadcrumbItems([])).toEqual([])
})
it('filters out records that have neither meta.title nor meta.breadcrumb', () => {
const matched: BreadcrumbRouteRecord[] = [
withNeither('/'),
withNoMeta('/events'),
withTitle('Events', '/events'),
]
const items = toBreadcrumbItems(matched)
expect(items).toHaveLength(1)
expect(items[0].label).toBe('Events')
})
it('uses meta.breadcrumb as label when set', () => {
const matched: BreadcrumbRouteRecord[] = [
withBreadcrumb('Home base', '/'),
withBreadcrumb('Events overview', '/events'),
]
const items = toBreadcrumbItems(matched)
expect(items[0].label).toBe('Home base')
expect(items[1].label).toBe('Events overview')
})
it('uses meta.title as label when meta.breadcrumb is absent', () => {
const matched: BreadcrumbRouteRecord[] = [
withTitle('Dashboard', '/dashboard'),
withTitle('Settings', '/settings'),
]
const items = toBreadcrumbItems(matched)
expect(items[0].label).toBe('Dashboard')
expect(items[1].label).toBe('Settings')
})
it('prefers meta.breadcrumb over meta.title when both are present', () => {
const matched: BreadcrumbRouteRecord[] = [
withBoth('Long Title', 'Short', '/events'),
withTitle('Current', '/events/detail'),
]
const items = toBreadcrumbItems(matched)
expect(items[0].label).toBe('Short')
})
it('last item has no `to` (current page)', () => {
const matched: BreadcrumbRouteRecord[] = [
withTitle('Events', '/events'),
withTitle('Detail', '/events/detail'),
]
const items = toBreadcrumbItems(matched)
expect(items).toHaveLength(2)
expect(items[1].to).toBeUndefined()
})
it('non-last items carry a `to` link', () => {
const matched: BreadcrumbRouteRecord[] = [
withTitle('Home', '/'),
withTitle('Events', '/events'),
withTitle('Detail', '/events/detail'),
]
const items = toBreadcrumbItems(matched)
expect(items[0].to).toBe('/')
expect(items[1].to).toBe('/events')
expect(items[2].to).toBeUndefined()
})
it('single eligible record: label set, no `to`', () => {
const matched: BreadcrumbRouteRecord[] = [
withTitle('Dashboard', '/dashboard'),
]
const items = toBreadcrumbItems(matched)
expect(items).toHaveLength(1)
expect(items[0].label).toBe('Dashboard')
expect(items[0].to).toBeUndefined()
})
it('falls back to name-based RouteLocationRaw when path is absent', () => {
const matched: BreadcrumbRouteRecord[] = [
{ meta: { title: 'Home' }, name: 'home' },
{ meta: { title: 'Current' }, name: 'current' },
]
const items = toBreadcrumbItems(matched)
expect(items[0].to).toEqual({ name: 'home' })
expect(items[1].to).toBeUndefined()
})
it('records with no meta at all are filtered out', () => {
const matched: BreadcrumbRouteRecord[] = [
{ path: '/layout' },
withTitle('Dashboard', '/dashboard'),
]
const items = toBreadcrumbItems(matched)
expect(items).toHaveLength(1)
expect(items[0].label).toBe('Dashboard')
})
})

View File

@@ -1,8 +1,13 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { defineComponent } from 'vue'
import { createPinia, setActivePinia } from 'pinia'
import { registerDrawerComponent } from '@/composables/drawerRegistry'
import { useRightDrawer } from '@/composables/useRightDrawer'
import { useShellUiStore } from '@/stores/useShellUiStore'
// Unique prefix per run to avoid cross-test leakage from the module singleton registry.
const PREFIX = `rdr-spec-${Date.now()}-`
describe('useRightDrawer', () => {
beforeEach(() => setActivePinia(createPinia()))
@@ -24,4 +29,16 @@ describe('useRightDrawer', () => {
expect(isOpen.value).toBe(false)
expect(useShellUiStore().drawer.isOpen).toBe(false)
})
it('resolveDrawerComponent delegates to the registry', () => {
const StubComponent = defineComponent({ template: '<div />' })
const name = `${PREFIX}StubComponent`
registerDrawerComponent(name, StubComponent)
const { resolveDrawerComponent } = useRightDrawer()
expect(resolveDrawerComponent(name)).toBe(StubComponent)
expect(resolveDrawerComponent(`${PREFIX}unknown`)).toBeNull()
})
})

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import { toV2NavGroups, useV2Nav } from '@/composables/useV2Nav'
import type { V2NavItem } from '@/types/v2/nav'
// Hand-built fixture — does NOT depend on the real orgNavItems contents.
const fixture = [
{ title: 'Alpha', to: { name: 'alpha' }, icon: { icon: 'tabler-home' } },
{ title: 'Beta', to: { name: 'beta' }, icon: { icon: 'tabler-bell' } },
{ heading: 'Group One' },
{ title: 'Gamma', to: { name: 'gamma' }, icon: { icon: 'tabler-star' }, count: 5 },
{ heading: 'Group Two' },
{ title: 'Delta', to: { name: 'delta' }, icon: { icon: 'tabler-settings' } },
] as const
describe('toV2NavGroups', () => {
it('places items before the first heading into a leading group with label ""', () => {
const groups = toV2NavGroups(fixture)
expect(groups[0].label).toBe('')
expect(groups[0].items).toHaveLength(2)
})
it('starts a new group when a heading entry is encountered', () => {
const groups = toV2NavGroups(fixture)
expect(groups).toHaveLength(3)
expect(groups[1].label).toBe('Group One')
expect(groups[2].label).toBe('Group Two')
})
it('maps item fields correctly: id, label, icon, to', () => {
const groups = toV2NavGroups(fixture)
const alpha = groups[0].items[0] as V2NavItem
expect(alpha.id).toBe('alpha')
expect(alpha.label).toBe('Alpha')
expect(alpha.icon).toBe('tabler-home')
expect(alpha.to).toEqual({ name: 'alpha' })
})
it('passes count through when present', () => {
const groups = toV2NavGroups(fixture)
const gamma = groups[1].items[0] as V2NavItem
expect(gamma.count).toBe(5)
})
it('leaves count undefined when absent', () => {
const groups = toV2NavGroups(fixture)
const alpha = groups[0].items[0] as V2NavItem
expect(alpha.count).toBeUndefined()
})
it('id equals the v1 route name (already kebab-case; no normalisation applied)', () => {
const groups = toV2NavGroups(fixture)
const delta = groups[2].items[0] as V2NavItem
expect(delta.id).toBe('delta')
})
it('returns empty groups array for an empty input', () => {
expect(toV2NavGroups([])).toEqual([])
})
it('items-only input (no headings) returns a single leading group', () => {
const onlyItems = [
{ title: 'A', to: { name: 'a' }, icon: { icon: 'tabler-a' } },
] as const
const groups = toV2NavGroups(onlyItems)
expect(groups).toHaveLength(1)
expect(groups[0].label).toBe('')
expect(groups[0].items).toHaveLength(1)
})
})
describe('useV2Nav', () => {
it('returns a computed whose .value equals toV2NavGroups(items)', () => {
const { groups } = useV2Nav(fixture)
expect(groups.value).toEqual(toV2NavGroups(fixture))
})
it('consecutive headings produce an empty-items group then the next group', () => {
const consecutiveHeadings = [
{ heading: 'First' },
{ heading: 'Second' },
{ title: 'Alpha', to: { name: 'alpha' }, icon: { icon: 'tabler-home' } },
] as const
const groups = toV2NavGroups(consecutiveHeadings)
expect(groups).toHaveLength(2)
expect(groups[0].label).toBe('First')
expect(groups[0].items).toHaveLength(0)
expect(groups[1].label).toBe('Second')
expect(groups[1].items).toHaveLength(1)
})
})

View File

@@ -0,0 +1,32 @@
import type { Component } from 'vue'
// Module-private registry map. No static component imports — callers
// register their own components via registerDrawerComponent() from a zone
// that is allowed to import components (components-v2, layouts, pages-v2,
// etc.). This keeps drawerRegistry inside the `composables` boundary zone
// which may NOT import any component zone (RFC-WS-GUI-REDESIGN AD-G5,
// ESLint boundaries matrix). The registry ships empty; real drawer-body
// components register themselves after mount from their own feature zone.
const registry = new Map<string, Component>()
/**
* Register a drawer-body component under a string name.
* Calling again with the same name overwrites the previous registration.
* Call this from the component's owning feature zone (components-v2, layouts,
* pages-v2, …) — never from inside composables/drawerRegistry.ts itself.
*/
export function registerDrawerComponent(name: string, component: Component): void {
registry.set(name, component)
}
/**
* Resolve a previously registered component by name.
* Returns null for unknown names, empty strings, null, or undefined —
* callers must guard the null return (e.g. render a graceful empty state).
*/
export function resolveDrawerComponent(name: string | null | undefined): Component | null {
if (!name)
return null
return registry.get(name) ?? null
}

View File

@@ -0,0 +1,95 @@
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import type { ComputedRef } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** A single breadcrumb item. `to` is absent for the current (last) item. */
export interface BreadcrumbItem {
label: string
to?: RouteLocationRaw
}
/**
* Minimal shape we need from a matched route record.
* Using a structural type keeps the pure helper testable without
* requiring full RouteLocationMatched fixtures in unit tests.
* RouteLocationMatched is structurally assignable to this interface so
* route.matched (RouteLocationMatched[]) passes the type-checker.
*/
export interface BreadcrumbRouteRecord {
meta?: Record<string, unknown> & {
breadcrumb?: string
title?: string
}
name?: string | symbol | null
path?: string
}
// ---------------------------------------------------------------------------
// Pure helper — testable without a live router
// ---------------------------------------------------------------------------
/**
* Convert an array of matched route records into breadcrumb items.
*
* Rules:
* - Records without `meta.breadcrumb` OR `meta.title` are filtered out.
* - Label = `meta.breadcrumb` if set, else `meta.title`.
* - The last item is current — no `to` link.
* - All preceding items carry a `to` pointing at their `path` (or `name`
* when path is absent).
*/
export function toBreadcrumbItems(matched: readonly BreadcrumbRouteRecord[]): BreadcrumbItem[] {
const eligible = matched.filter(
r => r.meta?.breadcrumb !== undefined || r.meta?.title !== undefined,
)
return eligible.map((record, index): BreadcrumbItem => {
const label = (record.meta?.breadcrumb ?? record.meta?.title) as string
const isLast = index === eligible.length - 1
if (isLast)
return { label }
// Prefer path; fall back to name when path is absent/empty.
// NOTE: a non-last record whose `path` is a param template (e.g. `/events/:id`)
// would yield an unresolved-template `to` — acceptable in foundation scope because
// param routes are normally the last (current, no-`to`) segment.
// TODO TECH-WS-GUI-REDESIGN: resolve param paths if a non-leaf param route ever needs a crumb link
const to: RouteLocationRaw
= record.path
? record.path
: { name: record.name as string }
return { label, to }
})
}
// ---------------------------------------------------------------------------
// Composable — thin wrapper around useRoute()
// ---------------------------------------------------------------------------
export interface UseBreadcrumbReturn {
items: ComputedRef<BreadcrumbItem[]>
}
/**
* Composable that derives breadcrumb items from the current route's
* `matched` array. Reactive — updates automatically on navigation.
*
* Composables zone may NOT import from @/navigation — do not add
* route-level nav helpers here.
*/
export function useBreadcrumb(): UseBreadcrumbReturn {
const route = useRoute()
const items = computed<BreadcrumbItem[]>(() =>
toBreadcrumbItems(route.matched),
)
return { items }
}

View File

@@ -1,6 +1,7 @@
import { storeToRefs } from 'pinia'
import type { ComputedRef } from 'vue'
import type { Component, ComputedRef } from 'vue'
import { computed } from 'vue'
import { resolveDrawerComponent } from '@/composables/drawerRegistry'
import { useShellUiStore } from '@/stores/useShellUiStore'
// Thin facade over useShellUiStore.drawer (RFC-WS-GUI-REDESIGN AD-G4,
@@ -14,6 +15,7 @@ export interface UseRightDrawer {
props: ComputedRef<Record<string, unknown>>
open: (component: string, props?: Record<string, unknown>) => void
close: () => void
resolveDrawerComponent: (name: string | null | undefined) => Component | null
}
export function useRightDrawer(): UseRightDrawer {
@@ -26,5 +28,6 @@ export function useRightDrawer(): UseRightDrawer {
props: computed(() => drawer.value.props),
open: (component, props = {}) => store.openDrawer(component, props),
close: () => store.closeDrawer(),
resolveDrawerComponent,
}
}

View File

@@ -0,0 +1,95 @@
import { computed } from 'vue'
import type { ComputedRef } from 'vue'
import type { V2NavGroup, V2NavItem } from '@/types/v2/nav'
// ---------------------------------------------------------------------------
// Local discriminated union for v1 nav entries (no `any`)
// ---------------------------------------------------------------------------
interface V1NavHeading {
heading: string
}
interface V1NavLink {
title: string
to: { name: string }
icon: { icon: string }
count?: number
}
export type V1NavEntry = V1NavHeading | V1NavLink
function isHeading(entry: V1NavEntry): entry is V1NavHeading {
return 'heading' in entry
}
// ---------------------------------------------------------------------------
// Pure adapter — exported so it can be unit-tested without mounting
// ---------------------------------------------------------------------------
/**
* Folds a flat v1 nav array into V2NavGroup[].
*
* - A `{ heading }` entry closes the current group and opens a new one.
* - Items before the first heading are placed in a leading group with label ''.
* - A pure function with no side-effects.
*/
export function toV2NavGroups(items: readonly V1NavEntry[]): V2NavGroup[] {
if (items.length === 0)
return []
const groups: V2NavGroup[] = []
let current: V2NavGroup | null = null
for (const entry of items) {
if (isHeading(entry)) {
// Close current group (if any) and start a new named group.
if (current !== null)
groups.push(current)
current = { label: entry.heading, items: [] }
}
else {
// Ensure there is a current group (leading ungrouped section).
if (current === null)
current = { label: '', items: [] }
const navItem: V2NavItem = {
id: entry.to.name, // v1 route names are already kebab-case; no normalisation needed
label: entry.title,
icon: entry.icon.icon,
to: { name: entry.to.name },
...(entry.count !== undefined ? { count: entry.count } : {}),
}
current.items.push(navItem)
}
}
// Flush the last open group.
if (current !== null)
groups.push(current)
return groups
}
// ---------------------------------------------------------------------------
// Composable
// ---------------------------------------------------------------------------
/**
* Wraps toV2NavGroups in a computed ref.
*
* Accepts the raw v1 nav items as a parameter so the composable (composables
* boundary zone) does not need to import from @/navigation (navigation zone).
* Call-sites in layouts/pages — which ARE allowed to import navigation —
* pass orgNavItems directly:
*
* import { orgNavItems } from '@/navigation/vertical'
* const { groups } = useV2Nav(orgNavItems)
*/
export function useV2Nav(items: readonly V1NavEntry[]): { groups: ComputedRef<V2NavGroup[]> } {
const groups = computed(() => toV2NavGroups(items))
return { groups }
}

View File

@@ -1,26 +1,45 @@
<script setup lang="ts">
// OrganizerLayoutV2 — v2 layout file selected by
// definePage({ meta: { layout: 'OrganizerLayoutV2' } }) on pages-v2/**
// (RFC-WS-GUI-REDESIGN AD-G2). Plan 1: wires the skeleton + RouterView.
// A later plan fills the sidebar/topbar/drawer slots with ported
// PrimeVue shell pieces.
/**
* OrganizerLayoutV2 — v2 shell layout selected by
* definePage({ meta: { layout: 'OrganizerLayoutV2' } }) on pages-v2/**
* (RFC-WS-GUI-REDESIGN AD-G2).
*
* Plan 2 Task 7 — composition only: fills AppShellV2's named slots with
* the ported PrimeVue shell pieces (AppSidebar / AppTopbar / RightDrawer)
* and renders routed pages via <RouterView/> in the default slot.
*
* Nav data is sourced HERE: the layouts zone may import @/navigation,
* whereas components-v2 may NOT (import-boundary matrix). orgNavItems is
* folded into V2NavGroup[] by useV2Nav() and passed to AppSidebar :groups.
*
* No provide/inject: each shell piece reads its own state from
* useShellUiStore / useAuthStore (RFC AD-G4). This layout wires
* composition + nav data only; it owns no shell state.
*/
import { orgNavItems } from '@/navigation/vertical'
import { useV2Nav } from '@/composables/useV2Nav'
import AppShellV2 from '@/layouts/components/AppShellV2.vue'
import AppSidebar from '@/components-v2/layout/AppSidebar.vue'
import AppTopbar from '@/components-v2/layout/AppTopbar.vue'
import RightDrawer from '@/components-v2/layout/RightDrawer.vue'
const { groups } = useV2Nav(orgNavItems)
</script>
<template>
<AppShellV2>
<template #sidebar>
<nav class="w-72 border-r border-surface-200 p-4 dark:border-surface-800">
<span class="text-sm text-surface-500">Crewli v2</span>
</nav>
<AppSidebar :groups="groups" />
</template>
<template #topbar>
<header class="flex h-14 items-center border-b border-surface-200 px-6 dark:border-surface-800">
<span class="text-sm text-surface-500">v2 shell (skeleton)</span>
</header>
<AppTopbar />
</template>
<RouterView />
<template #drawer>
<RightDrawer />
</template>
</AppShellV2>
</template>

View File

@@ -0,0 +1,89 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Button from 'primevue/button'
import AppDialog from '@/components-v2/shared/AppDialog.vue'
/**
* AppDialog stories. PrimeVue Dialog is `modal` + teleports to <body>,
* so each story binds `v-model:open` to a ref seeded from `args.open`
* and renders the dialog open by default for autodocs visibility.
*/
const meta: Meta<typeof AppDialog> = {
title: 'v2 Shell/AppDialog',
component: AppDialog,
tags: ['autodocs'],
argTypes: {
open: { control: 'boolean' },
title: { control: 'text' },
sub: { control: 'text' },
width: { control: 'text' },
},
}
export default meta
type Story = StoryObj<typeof AppDialog>
/**
* Shared render: only the inner markup (default body + optional
* #footer / #tabs slots) varies between stories, so the v-model:open
* scaffold lives here once. `extra` registers any extra components the
* inner markup references (e.g. Button for the footer story).
*/
function dialogStory(
inner: string,
extra: Record<string, unknown> = {},
): Story['render'] {
return args => ({
components: { AppDialog, ...extra },
setup() {
const open = ref(args.open)
return { args, open }
},
template: `
<AppDialog v-model:open="open" :title="args.title" :sub="args.sub" :width="args.width">
${inner}
</AppDialog>
`,
})
}
export const Default: Story = {
args: { open: true, title: 'Edit shift' },
render: dialogStory('<p class="text-sm">Dialog body content goes here.</p>'),
}
export const WithSubtitle: Story = {
args: { open: true, title: 'Edit shift', sub: 'Saturday — Main Stage' },
render: dialogStory('<p class="text-sm">Dialog body with a subtitle in the header.</p>'),
}
export const WithFooter: Story = {
args: { open: true, title: 'Confirm action' },
render: dialogStory(
`<p class="text-sm">Are you sure you want to continue?</p>
<template #footer>
<Button label="Cancel" severity="secondary" @click="open = false" />
<Button label="Confirm" @click="open = false" />
</template>`,
{ Button },
),
}
export const WithTabs: Story = {
args: { open: true, title: 'Settings' },
render: dialogStory(
`<template #tabs>
<div class="flex gap-4 px-6 py-2 border-b border-[var(--p-content-border-color)] text-[13px]">
<span class="font-semibold text-[var(--p-primary-color)]">General</span>
<span class="text-[var(--p-text-muted-color)]">Advanced</span>
</div>
</template>
<p class="text-sm">Tab strip rendered between header and body.</p>`,
),
}
export const Wide: Story = {
args: { open: true, title: 'Wide dialog', width: '960px' },
render: dialogStory('<p class="text-sm">This dialog uses an explicit width of 960px.</p>'),
}

View File

@@ -0,0 +1,75 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { navFixture, orgA, userFixture, withPinia } from './_helpers'
import AppSidebar from '@/components-v2/layout/AppSidebar.vue'
import { useAuthStore } from '@/stores/useAuthStore'
import { useShellUiStore } from '@/stores/useShellUiStore'
/**
* AppSidebar composes SidebarHeader + SidebarNav + WorkspaceSwitcher and
* reads useShellUiStore (sidebarCollapsed / mobileOpen) plus, transitively
* via WorkspaceSwitcher, useAuthStore. Each story seeds both stores on a
* fresh Pinia. The desktop <aside> is `hidden lg:flex`, so view at a
* viewport ≥ 1024px to see the permanent column.
*/
const meta: Meta<typeof AppSidebar> = {
title: 'v2 Shell/AppSidebar',
component: AppSidebar,
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof AppSidebar>
export const Expanded: Story = {
args: { groups: navFixture },
decorators: [
withPinia(() => {
const auth = useAuthStore()
auth.user = userFixture
auth.organisations = [orgA]
const shellUi = useShellUiStore()
shellUi.sidebarCollapsed = false
}),
],
render: args => ({
components: { AppSidebar },
setup() {
return { args }
},
template: `
<div class="flex h-[600px]">
<AppSidebar :groups="args.groups" />
</div>
`,
}),
}
export const Collapsed: Story = {
args: { groups: navFixture },
decorators: [
withPinia(() => {
const auth = useAuthStore()
auth.user = userFixture
auth.organisations = [orgA]
const shellUi = useShellUiStore()
shellUi.sidebarCollapsed = true
}),
],
render: args => ({
components: { AppSidebar },
setup() {
return { args }
},
template: `
<div class="flex h-[600px]">
<AppSidebar :groups="args.groups" />
</div>
`,
}),
}

View File

@@ -0,0 +1,70 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { orgA, userFixture, withPinia } from './_helpers'
import AppTopbar from '@/components-v2/layout/AppTopbar.vue'
import { useAuthStore } from '@/stores/useAuthStore'
import { useShellUiStore } from '@/stores/useShellUiStore'
/**
* AppTopbar reads useShellUiStore (theme / density), useAuthStore (user +
* currentOrganisation) and useBreadcrumb (route-driven — router is global
* in preview.ts). Each story seeds both stores on a fresh Pinia.
*/
function seedAuth(): void {
const auth = useAuthStore()
auth.user = userFixture
auth.organisations = [orgA]
}
const meta: Meta<typeof AppTopbar> = {
title: 'v2 Shell/AppTopbar',
component: AppTopbar,
tags: ['autodocs'],
render: () => ({
components: { AppTopbar },
template: `
<div class="min-h-[200px]">
<AppTopbar />
</div>
`,
}),
}
export default meta
type Story = StoryObj<typeof AppTopbar>
export const Default: Story = {
decorators: [withPinia(seedAuth)],
}
/**
* Dark mode is scoped to the story's own subtree via a `.dark` wrapper
* (Aura darkModeSelector is the `.dark` class — see plugins/primevue).
* Mutating <html> instead would leak into every other story stacked on
* the same autodocs page.
*/
export const DarkTheme: Story = {
decorators: [
withPinia(() => {
seedAuth()
useShellUiStore().setTheme('dark')
}),
],
render: () => ({
components: { AppTopbar },
template: `
<div class="dark min-h-[200px] bg-[var(--p-content-background)]">
<AppTopbar />
</div>
`,
}),
}
export const CompactDensity: Story = {
decorators: [
withPinia(() => {
seedAuth()
useShellUiStore().setDensity('compact')
}),
],
}

View File

@@ -0,0 +1,84 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { defineComponent } from 'vue'
import { withPinia } from './_helpers'
import RightDrawer from '@/components-v2/layout/RightDrawer.vue'
import { registerDrawerComponent } from '@/composables/drawerRegistry'
import { useShellUiStore } from '@/stores/useShellUiStore'
/**
* RightDrawer is fully store-driven via useShellUiStore().drawer +
* the drawerRegistry singleton. Each seed registers a demo body under
* "SbDemo" (idempotent overwrite — registry is a module singleton) and
* optionally seeds openDrawer() so the teleported PrimeVue Drawer is
* visible. Chrome keys title/flush are passed in the props object.
*/
const SbDemoBody = defineComponent({
name: 'SbDemoBody',
template: '<div class="p-2 text-sm">Demo drawer body</div>',
})
const meta: Meta<typeof RightDrawer> = {
title: 'v2 Shell/RightDrawer',
component: RightDrawer,
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof RightDrawer>
export const Closed: Story = {
decorators: [
withPinia(() => {
registerDrawerComponent('SbDemo', SbDemoBody)
}),
],
render: () => ({
components: { RightDrawer },
template: `
<div class="min-h-[200px]">
<p class="text-sm text-[var(--p-text-muted-color)]">Drawer is closed (store seeded but openDrawer not called).</p>
<RightDrawer />
</div>
`,
}),
}
export const OpenWithBody: Story = {
decorators: [
withPinia(() => {
registerDrawerComponent('SbDemo', SbDemoBody)
const shellUi = useShellUiStore()
shellUi.openDrawer('SbDemo', { title: 'Details' })
}),
],
render: () => ({
components: { RightDrawer },
template: `
<div class="min-h-[200px]">
<RightDrawer />
</div>
`,
}),
}
export const Flush: Story = {
decorators: [
withPinia(() => {
registerDrawerComponent('SbDemo', SbDemoBody)
const shellUi = useShellUiStore()
shellUi.openDrawer('SbDemo', { title: 'Flush', flush: true })
}),
],
render: () => ({
components: { RightDrawer },
template: `
<div class="min-h-[200px]">
<RightDrawer />
</div>
`,
}),
}

View File

@@ -0,0 +1,71 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { useRouter } from 'vue-router'
import { navFixture } from './_helpers'
import SidebarNav from '@/components-v2/layout/SidebarNav.vue'
/**
* SidebarNav needs no Pinia, but vue-router is installed app-wide in
* preview.ts (the component uses useRoute + RouterLink). The
* WithActiveItem story pushes the `events` route in setup so the
* Evenementen item renders in its active state.
*/
const meta: Meta<typeof SidebarNav> = {
title: 'v2 Shell/SidebarNav',
component: SidebarNav,
tags: ['autodocs'],
argTypes: {
collapsed: { control: 'boolean' },
},
}
export default meta
type Story = StoryObj<typeof SidebarNav>
export const Expanded: Story = {
args: { groups: navFixture, collapsed: false },
render: args => ({
components: { SidebarNav },
setup() {
return { args }
},
template: `
<div class="w-64 bg-[var(--p-content-background)]">
<SidebarNav :groups="args.groups" :collapsed="args.collapsed" />
</div>
`,
}),
}
export const Collapsed: Story = {
args: { groups: navFixture, collapsed: true },
render: args => ({
components: { SidebarNav },
setup() {
return { args }
},
template: `
<div class="w-16 bg-[var(--p-content-background)]">
<SidebarNav :groups="args.groups" :collapsed="args.collapsed" />
</div>
`,
}),
}
export const WithActiveItem: Story = {
args: { groups: navFixture, collapsed: false },
render: args => ({
components: { SidebarNav },
setup() {
const router = useRouter()
router.push({ name: 'events' })
return { args }
},
template: `
<div class="w-64 bg-[var(--p-content-background)]">
<SidebarNav :groups="args.groups" :collapsed="args.collapsed" />
</div>
`,
}),
}

View File

@@ -0,0 +1,68 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { orgA, orgB, orgC, userFixture, withPinia } from './_helpers'
import WorkspaceSwitcher from '@/components-v2/layout/WorkspaceSwitcher.vue'
import { useAuthStore } from '@/stores/useAuthStore'
/**
* WorkspaceSwitcher reads useAuthStore (organisations + computed
* currentOrganisation). Each story gets a fresh Pinia via withPinia and
* seeds the auth store by direct assignment after the store is created.
*/
const meta: Meta<typeof WorkspaceSwitcher> = {
title: 'v2 Shell/WorkspaceSwitcher',
component: WorkspaceSwitcher,
tags: ['autodocs'],
argTypes: {
collapsed: { control: 'boolean' },
},
render: args => ({
components: { WorkspaceSwitcher },
setup() {
return { args }
},
// Rail width tracks collapsed so both states read correctly.
template: `
<div class="bg-[var(--p-content-background)]" :class="args.collapsed ? 'w-16' : 'w-64'">
<WorkspaceSwitcher :collapsed="args.collapsed" />
</div>
`,
}),
}
export default meta
type Story = StoryObj<typeof WorkspaceSwitcher>
export const SingleOrg: Story = {
decorators: [
withPinia(() => {
const auth = useAuthStore()
auth.user = userFixture
auth.organisations = [orgA]
}),
],
}
export const MultiOrg: Story = {
decorators: [
withPinia(() => {
const auth = useAuthStore()
auth.user = userFixture
auth.organisations = [orgA, orgB, orgC]
}),
],
}
export const Collapsed: Story = {
args: { collapsed: true },
decorators: [
withPinia(() => {
const auth = useAuthStore()
auth.user = userFixture
auth.organisations = [orgA, orgB, orgC]
}),
],
}

View File

@@ -0,0 +1,84 @@
import { createPinia, setActivePinia } from 'pinia'
import type { Decorator } from '@storybook/vue3-vite'
import type { Organisation, User } from '@/types/auth'
import type { V2NavGroup } from '@/types/v2/nav'
/**
* Fresh Pinia per story + optional seeding. The seed fn runs AFTER
* setActivePinia so `useXxxStore()` inside it (and inside the rendered
* component) resolves to this story's isolated store instance.
*/
export function withPinia(seed?: () => void): Decorator {
return story => {
setActivePinia(createPinia())
seed?.()
return { components: { story }, template: '<story />' }
}
}
/**
* Minimal valid User fixture (every required key from `@/types/auth`
* User present and typed — no `any`). Shared so AppTopbar / AppSidebar /
* WorkspaceSwitcher seeds stay DRY.
*/
export const userFixture: User = {
id: 'usr_01',
first_name: 'Bert',
last_name: 'Hausmans',
full_name: 'Bert Hausmans',
date_of_birth: null,
email: 'bert@hausmans.nl',
phone: null,
timezone: 'Europe/Amsterdam',
locale: 'nl',
avatar: null,
}
/**
* Three Organisation fixtures (every required key from `@/types/auth`
* Organisation present). `currentOrganisation` is a computed that returns
* `organisations[0]` when no active id is set, so assigning
* `auth.organisations = [orgA]` makes orgA the current workspace.
*/
export const orgA: Organisation = {
id: 'org_a',
name: 'Festival Crew NL',
slug: 'festival-crew-nl',
role: 'org_admin',
}
export const orgB: Organisation = {
id: 'org_b',
name: 'Stadspark Events',
slug: 'stadspark-events',
role: 'org_member',
}
export const orgC: Organisation = {
id: 'org_c',
name: 'Volunteer Collective',
slug: 'volunteer-collective',
role: 'event_manager',
}
/** Convenience single-org list for store seeds. */
export const orgFixture: Organisation[] = [orgA]
export const navFixture: V2NavGroup[] = [
{
label: '',
items: [
{ id: 'dashboard', label: 'Dashboard', icon: 'tabler-smart-home', to: { name: 'dashboard' } },
{ id: 'events', label: 'Evenementen', icon: 'tabler-calendar-event', to: { name: 'events' }, count: 3 },
],
},
{
label: 'Beheer',
items: [
{ id: 'organisation', label: 'Mijn Organisatie', icon: 'tabler-building', to: { name: 'organisation' } },
{ id: 'members', label: 'Leden', icon: 'tabler-users', to: { name: 'members' } },
{ id: 'organisation-settings', label: 'Instellingen', icon: 'tabler-settings', to: { name: 'organisation-settings' } },
],
},
]

View File

@@ -0,0 +1,14 @@
import type { RouteLocationRaw } from 'vue-router'
export interface V2NavItem {
id: string
label: string
icon: string // e.g. 'tabler-smart-home'
to: RouteLocationRaw
count?: number
}
export interface V2NavGroup {
label: string // '' for an ungrouped leading section
items: V2NavItem[]
}

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { GRADIENT_PALETTE, computeOrgGradient } from '../gradient'
/**
* TDD spec for computeOrgGradient.
* Written before the implementation — expected to fail until gradient.ts is created.
*/
describe('computeOrgGradient', () => {
it('is deterministic: same id always returns the identical pair', () => {
const id = '01jv1a2b3c4d5e6f7g8h9i0j'
const first = computeOrgGradient(id)
const second = computeOrgGradient(id)
expect(first).toEqual(second)
})
it('returns a 2-tuple [string, string]', () => {
const pair = computeOrgGradient('some-org-id')
expect(pair).toHaveLength(2)
expect(typeof pair[0]).toBe('string')
expect(typeof pair[1]).toBe('string')
})
it('returns hex strings (starts with # and has 7 chars)', () => {
const [a, b] = computeOrgGradient('abc')
expect(a).toMatch(/^#[0-9a-f]{6}$/i)
expect(b).toMatch(/^#[0-9a-f]{6}$/i)
})
it('returned colors are drawn from the declared palette', () => {
const flatPalette = GRADIENT_PALETTE.flat()
const [a, b] = computeOrgGradient('test-id')
expect(flatPalette).toContain(a)
expect(flatPalette).toContain(b)
})
it('different ids generally yield different gradient pairs', () => {
// Test a sample of 5 representative ids — at least 2 distinct pairs expected
const ids = ['org-1', 'org-2', 'org-3', 'org-4', 'org-5']
const pairs = ids.map(id => computeOrgGradient(id).join(','))
const unique = new Set(pairs)
expect(unique.size).toBeGreaterThan(1)
})
it('handles empty string without throwing', () => {
expect(() => computeOrgGradient('')).not.toThrow()
const pair = computeOrgGradient('')
expect(pair).toHaveLength(2)
})
it('the returned pair consists of the two values at the chosen palette entry', () => {
// Verify internal structure: both values come from the same palette entry
const id = 'crewli-org-ulid'
const [a, b] = computeOrgGradient(id)
const matchingEntry = GRADIENT_PALETTE.find(([p0, p1]) => p0 === a && p1 === b)
expect(matchingEntry).toBeDefined()
})
})

View File

@@ -0,0 +1,39 @@
/**
* computeOrgGradient — deterministic gradient pair for an organisation.
*
* Maps an org `id` string to one of 8 teal-adjacent colour pairs via a
* simple djb2-style character-code hash. Same id always returns the
* same pair; no external dependencies.
*
* Palette rationale: teal / cyan / emerald / sea-green family to stay
* on-brand with Crewli's teal primary. Each tuple is [from, to] for a
* 135° linear gradient (darker "to" keeps depth on the logo square).
*/
/** Exported so tests can assert membership without hard-coding values. */
export const GRADIENT_PALETTE: [string, string][] = [
['#0d9488', '#0f766e'], // teal-600 → teal-700
['#0891b2', '#0e7490'], // cyan-600 → cyan-700
['#059669', '#047857'], // emerald-600 → emerald-700
['#10b981', '#059669'], // emerald-500 → emerald-600
['#0284c7', '#0369a1'], // sky-600 → sky-700
['#14b8a6', '#0d9488'], // teal-500 → teal-600
['#06b6d4', '#0891b2'], // cyan-500 → cyan-600
['#34d399', '#10b981'], // emerald-400 → emerald-500
]
/**
* Returns a `[fromHex, toHex]` colour pair deterministically derived
* from `id`. Handles empty string (hash stays 0; maps to palette[0]).
*/
export function computeOrgGradient(id: string): [string, string] {
// djb2-style hash: accumulate across char codes
let hash = 5381
for (let i = 0; i < id.length; i++) {
// Equivalent to hash * 33 ^ charCode, kept 32-bit safe via >>> 0
hash = ((hash << 5) + hash + id.charCodeAt(i)) >>> 0
}
const index = hash % GRADIENT_PALETTE.length
return GRADIENT_PALETTE[index]
}

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import { defineComponent } from 'vue'
import OrganizerLayoutV2 from '@/layouts/OrganizerLayoutV2.vue'
import AppShellV2 from '@/layouts/components/AppShellV2.vue'
// Stub the 3 leaf shell components: this test verifies COMPOSITION
// (right component in right slot + nav data forwarded), not their
// internals (those have their own unit tests). Stubs keep jsdom free
// of PrimeVue teleport/overlay/breakpoint machinery.
const AppSidebarStub = defineComponent({
name: 'AppSidebarStub',
props: { groups: { type: Array, default: () => [] } },
template: '<aside data-testid="sidebar-stub" :data-groups-count="groups.length" />',
})
const AppTopbarStub = defineComponent({
name: 'AppTopbarStub',
template: '<header data-testid="topbar-stub" />',
})
const RightDrawerStub = defineComponent({
name: 'RightDrawerStub',
template: '<aside data-testid="drawer-stub" />',
})
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', name: 'home', component: defineComponent({ template: '<div data-testid="page" />' }) }],
})
async function mountLayout() {
router.push('/')
await router.isReady()
return mount(OrganizerLayoutV2, {
global: {
plugins: [createPinia(), router],
stubs: {
AppSidebar: AppSidebarStub,
AppTopbar: AppTopbarStub,
RightDrawer: RightDrawerStub,
},
},
})
}
describe('OrganizerLayoutV2 (wired shell)', () => {
it('composes AppShellV2 with the real shell components in each slot', async () => {
const wrapper = await mountLayout()
expect(wrapper.findComponent(AppShellV2).exists()).toBe(true)
expect(wrapper.find('[data-testid="appshell-v2"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="sidebar-stub"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="topbar-stub"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="drawer-stub"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="page"]').exists()).toBe(true)
})
it('forwards orgNavItems folded into V2NavGroup[] to AppSidebar :groups', async () => {
const wrapper = await mountLayout()
const sidebar = wrapper.find('[data-testid="sidebar-stub"]')
// orgNavItems has link entries + a {heading:'Beheer'} → useV2Nav folds
// into ≥1 group. A non-zero count proves the nav chain is wired.
expect(Number(sidebar.attributes('data-groups-count'))).toBeGreaterThan(0)
})
it('no longer renders the Plan-1 skeleton placeholders', async () => {
const wrapper = await mountLayout()
expect(wrapper.text()).not.toContain('skeleton')
expect(wrapper.text()).not.toContain('Crewli v2')
})
})

View File

@@ -38,6 +38,18 @@ describe('boundaries — v2 zones', () => {
expect(errs).toHaveLength(0)
})
it('allows components-v2 → components-foundation (Icon.vue bridge, single-file mode:file)', async () => {
// Regression lock: the single-file element needs mode:'file' or
// Icon.vue falls through to the generic `components` catch-all and
// every v2 shell component's Icon import breaks (RFC AD-G5 bridge).
const errs = await boundaryErrors(
'src/components-v2/layout/SidebarNav.vue',
'<script setup lang="ts">import Icon from \'@/components/Icon.vue\'</script><template><Icon name="x" /></template>',
)
expect(errs).toHaveLength(0)
})
it('allows components-v2 → components-foundation (FormField bridge)', async () => {
const errs = await boundaryErrors(
'src/components-v2/forms/Demo.vue',
@@ -55,4 +67,38 @@ describe('boundaries — v2 zones', () => {
expect(errs.length).toBeGreaterThan(0)
})
// -------------------------------------------------------------------------
// layouts-v2 zone (RFC AD-G5): the v2 shell layout (src/layouts/*V2*.vue)
// gets pages-v2-equivalent v2 capability so AD-G2 ("OrganizerLayoutV2
// wraps AppShellV2") holds — WITHOUT widening the v1 `layouts` zone.
// These lock both halves: the new edge AND the preserved isolation.
// -------------------------------------------------------------------------
it('allows layouts-v2 → components-v2 (AD-G5: v2 shell composition)', async () => {
const errs = await boundaryErrors(
'src/layouts/OrganizerLayoutV2.vue',
'<script setup lang="ts">import AppSidebar from \'@/components-v2/layout/AppSidebar.vue\'</script><template><AppSidebar /></template>',
)
expect(errs).toHaveLength(0)
})
it('allows layouts-v2 → navigation (v2 layout sources nav data)', async () => {
const errs = await boundaryErrors(
'src/layouts/OrganizerLayoutV2.vue',
'<script setup lang="ts">import { orgNavItems } from \'@/navigation/vertical\'</script><template><div>{{ orgNavItems.length }}</div></template>',
)
expect(errs).toHaveLength(0)
})
it('forbids v1 layouts → components-v2 (AD-G5 isolation preserved)', async () => {
const errs = await boundaryErrors(
'src/layouts/OrganizerLayout.vue',
'<script setup lang="ts">import AppSidebar from \'@/components-v2/layout/AppSidebar.vue\'</script><template><AppSidebar /></template>',
)
expect(errs.length).toBeGreaterThan(0)
})
})

View File

@@ -30,9 +30,19 @@ Tailwind + FormField + DataTable conventions) remain binding.
`useAuthStore`/`useOrganisationStore`. One new `useShellUiStore` holds
only sidebar/theme/density + right-drawer state. `provide`/`inject`
from crewli-starter is replaced per-port (no `inject()` survives).
- **AD-G5 — Boundaries.** New `components-v2`/`pages-v2` zones; the only
v1→v2 bridge is a narrow `components-foundation` zone (FormField,
Icon). No back-porting (structurally enforced).
- **AD-G5 — Boundaries.** New `components-v2`/`pages-v2`/`layouts-v2`
zones; the only v1→v2 bridge is a narrow `components-foundation` zone
(FormField, Icon). No back-porting (structurally enforced). The v2
shell layout (`src/layouts/*V2*.vue`, e.g. `OrganizerLayoutV2`) is the
`layouts-v2` zone — same v2 capability as `pages-v2` (may import
`components-v2` + `navigation`) so AD-G2's "`OrganizerLayoutV2` wraps
`AppShellV2`" holds. The v1 `layouts` zone is unchanged and still
cannot import `components-v2`, so v2 isolation is preserved (only
top-level `*V2*.vue` layout files gain v2 capability;
`src/layouts/components/AppShellV2.vue` stays in `layouts` since it
imports only `stores`). Locked by `tests/unit/boundaries-v2.spec.ts`
(`layouts-v2 → components-v2` allowed; v1 `layouts → components-v2`
forbidden).
- **AD-G6 — Testing.** TEST-INFRA-001 (✅ Resolved) Playwright-CT +
visual foundation is kept as the CI gate; Storybook a11y is
complementary. v2 visual baselines are captured from the v2 component