From 4e9eeb99c4b417fdc8e09678795b7b33af0f462f Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 16 May 2026 19:23:00 +0200 Subject: [PATCH 01/23] fix(lint): mode:'file' for the components-foundation Icon.vue bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan-1 Task-4 added { type:'components-foundation', pattern: 'src/components/Icon.vue' } without mode:'file'. eslint-plugin-boundaries defaults to folder mode, so the single-file pattern never matched and Icon.vue fell through to the generic `components` catch-all — breaking the sanctioned components-v2 -> Icon bridge (RFC AD-G5) for every v2 shell component. Plan-1's boundary test only exercised the forms/** folder-glob edge so the gap was latent. Adds mode:'file' + a regression test locking the components-v2 -> Icon.vue edge. Co-Authored-By: Claude Opus 4.7 --- apps/app/.eslintrc.cjs | 8 +++++++- apps/app/tests/unit/boundaries-v2.spec.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/app/.eslintrc.cjs b/apps/app/.eslintrc.cjs index 5d74d343..ee51fb29 100644 --- a/apps/app/.eslintrc.cjs +++ b/apps/app/.eslintrc.cjs @@ -306,7 +306,13 @@ 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/**' }, { type: 'layouts', pattern: 'src/layouts/**' }, diff --git a/apps/app/tests/unit/boundaries-v2.spec.ts b/apps/app/tests/unit/boundaries-v2.spec.ts index e50ec52a..8b1b7be7 100644 --- a/apps/app/tests/unit/boundaries-v2.spec.ts +++ b/apps/app/tests/unit/boundaries-v2.spec.ts @@ -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', + '', + ) + + expect(errs).toHaveLength(0) + }) + it('allows components-v2 → components-foundation (FormField bridge)', async () => { const errs = await boundaryErrors( 'src/components-v2/forms/Demo.vue', From 8a8e419ed117a5d8fb96f524e548679eb441c94c Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 16 May 2026 19:23:10 +0200 Subject: [PATCH 02/23] feat(gui-v2): port SidebarNav to TypeScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports crewli-starter's sidebar nav into the SPA as production TS: V2NavGroup/V2NavItem types, a pure toV2NavGroups adapter wrapped by useV2Nav(items) (composables zone can't import @/navigation, so the v1 nav array is passed in — the layout supplies orgNavItems in Task 7), a pure isNavItemActive helper, and SidebarNav.vue (props-only, router-driven nav, route-based active state, collapsed mode, main.css translated to Tailwind inline). 16 unit tests. Icon import is allowed via the components-foundation bridge (no eslint-disable). Co-Authored-By: Claude Opus 4.7 --- .../src/components-v2/layout/SidebarNav.vue | 128 ++++++++++++++++++ .../layout/__tests__/sidebarNavActive.spec.ts | 46 +++++++ .../components-v2/layout/sidebarNavActive.ts | 37 +++++ .../composables/__tests__/useV2Nav.spec.ts | 77 +++++++++++ apps/app/src/composables/useV2Nav.ts | 95 +++++++++++++ apps/app/src/types/v2/nav.ts | 14 ++ 6 files changed, 397 insertions(+) create mode 100644 apps/app/src/components-v2/layout/SidebarNav.vue create mode 100644 apps/app/src/components-v2/layout/__tests__/sidebarNavActive.spec.ts create mode 100644 apps/app/src/components-v2/layout/sidebarNavActive.ts create mode 100644 apps/app/src/composables/__tests__/useV2Nav.spec.ts create mode 100644 apps/app/src/composables/useV2Nav.ts create mode 100644 apps/app/src/types/v2/nav.ts diff --git a/apps/app/src/components-v2/layout/SidebarNav.vue b/apps/app/src/components-v2/layout/SidebarNav.vue new file mode 100644 index 00000000..adbb276d --- /dev/null +++ b/apps/app/src/components-v2/layout/SidebarNav.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/apps/app/src/components-v2/layout/__tests__/sidebarNavActive.spec.ts b/apps/app/src/components-v2/layout/__tests__/sidebarNavActive.spec.ts new file mode 100644 index 00000000..82ca06d2 --- /dev/null +++ b/apps/app/src/components-v2/layout/__tests__/sidebarNavActive.spec.ts @@ -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) + }) +}) diff --git a/apps/app/src/components-v2/layout/sidebarNavActive.ts b/apps/app/src/components-v2/layout/sidebarNavActive.ts new file mode 100644 index 00000000..9305bacc --- /dev/null +++ b/apps/app/src/components-v2/layout/sidebarNavActive.ts @@ -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}-`) +} diff --git a/apps/app/src/composables/__tests__/useV2Nav.spec.ts b/apps/app/src/composables/__tests__/useV2Nav.spec.ts new file mode 100644 index 00000000..dd7a4e9b --- /dev/null +++ b/apps/app/src/composables/__tests__/useV2Nav.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { toV2NavGroups } 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 is derived from the route name (kebab already for dotted names)', () => { + 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) + }) +}) diff --git a/apps/app/src/composables/useV2Nav.ts b/apps/app/src/composables/useV2Nav.ts new file mode 100644 index 00000000..ec1aa57a --- /dev/null +++ b/apps/app/src/composables/useV2Nav.ts @@ -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, + 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 } { + const groups = computed(() => toV2NavGroups(items)) + + return { groups } +} diff --git a/apps/app/src/types/v2/nav.ts b/apps/app/src/types/v2/nav.ts new file mode 100644 index 00000000..18c9b4a3 --- /dev/null +++ b/apps/app/src/types/v2/nav.ts @@ -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[] +} From 80551eeb98d53c89e99e8a418da77cf6eeda782a Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 16 May 2026 19:23:18 +0200 Subject: [PATCH 03/23] chore(types): sync auto-imports.d.ts for useV2Nav composable unplugin-auto-import scans src/composables/, so the new useV2Nav added a global + vue-module declaration. auto-imports.d.ts is tracked; keep it in sync (same precedent as Plan 1's useRightDrawer sync). Co-Authored-By: Claude Opus 4.7 --- apps/app/auto-imports.d.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/app/auto-imports.d.ts b/apps/app/auto-imports.d.ts index dbef1523..e8e1439d 100644 --- a/apps/app/auto-imports.d.ts +++ b/apps/app/auto-imports.d.ts @@ -160,6 +160,7 @@ declare global { 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'] @@ -353,6 +354,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'] @@ -544,6 +546,7 @@ declare module 'vue' { readonly toReactive: UnwrapRef readonly toRef: UnwrapRef readonly toRefs: UnwrapRef + readonly toV2NavGroups: UnwrapRef readonly toValue: UnwrapRef readonly triggerRef: UnwrapRef readonly tryOnBeforeMount: UnwrapRef @@ -729,6 +732,7 @@ declare module 'vue' { readonly useTrunc: UnwrapRef readonly useUrlSearchParams: UnwrapRef readonly useUserMedia: UnwrapRef + readonly useV2Nav: UnwrapRef readonly useVModel: UnwrapRef readonly useVModels: UnwrapRef readonly useVibrate: UnwrapRef From 8444ea74433926da32821bd3c3c013659ed4483f Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 16 May 2026 19:39:56 +0200 Subject: [PATCH 04/23] fix(gui-v2): SidebarNav uses RouterLink (a11y) + review-nit cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FIX 1: Replace @@ -231,26 +233,20 @@ function selectOrg(ws: WorkspaceDisplay): void { From f0f9cb7e361ed2f47b7d5838659b7e4d7747fae4 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 16 May 2026 20:29:18 +0200 Subject: [PATCH 07/23] feat(gui-v2): decompose AppSidebar into SidebarHeader + AppSidebar Ports crewli-starter's monolithic AppSidebar.vue into two typed production components: SidebarHeader (the .brand block) and AppSidebar (composing SidebarHeader + SidebarNav + WorkspaceSwitcher). AppSidebar renders a permanent