Files
crewli/dev-docs/superpowers/plans/2026-05-16-gui-redesign-foundation.md
bert.hausmans 01b0930679 docs: add GUI-redesign foundation implementation plan (Plan 1 of 5)
RFC + bootable /v2/ vertical slice (spec §9 deliverable 1). TDD task
breakdown: v2RouteName guard, routesFolder wiring, boundary zones,
definePage ESLint rule, useShellUiStore, useRightDrawer, OrganizerLayoutV2
+ AppShellV2 skeleton, /v2/dashboard boot proof. Plans 2-5 outlined.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 00:48:58 +02:00

46 KiB
Raw Permalink Blame History

Crewli GUI Redesign — Foundation Plan 1 (RFC + bootable /v2/ slice)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Land the new project RFC (superseding F4aF4d) and the structural foundation so that /v2/dashboard boots through a new OrganizerLayoutV2 + AppShellV2 skeleton, with route-name collision prevention, boundary zones, layout-meta enforcement, and the v2 UI state layer — all green under lint/typecheck/test/build.

Architecture: Parallel /v2/* route tree (own routesFolder with a v2- route-name prefix), a new OrganizerLayoutV2 selected via definePage({ meta: { layout: 'OrganizerLayoutV2' } }) (enforced by a custom ESLint rule), a Tailwind-grid AppShellV2 skeleton with named slot regions (PrimeVue shell pieces arrive in Plan 2), a single useShellUiStore for sidebar/theme/density/right-drawer state, and a thin useRightDrawer() facade over it. No v1 code is touched except additive config.

Tech Stack: Vue 3 <script setup lang="ts">, unplugin-vue-router, vite-plugin-vue-meta-layouts, Pinia, eslint-plugin-boundaries 6.0.2, eslint-plugin-local-rules, Vitest (unit/component projects), Playwright Component Testing, Tailwind v4.

Plan location note: saved under dev-docs/superpowers/plans/ (not the skill default docs/superpowers/plans/) because docs/ is Crewli's VitePress user-docs site; dev-docs/ is the developer-doc convention and matches where the spec lives.

Source spec: dev-docs/superpowers/specs/2026-05-15-crewli-starter-gui-redesign-design.md (approved, review rounds 12 applied).

Scope: This is Plan 1 of 5. It delivers the RFC + spec §9 deliverable 1. Plans 25 (shell pieces, Tier-1 primitives + DraggableBlock, template layer, full Storybook catalog) are outlined at the end and authored after Plan 1 lands.


File Structure (Plan 1)

Created:

  • dev-docs/RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md — new project RFC, supersedes F4aF4d
  • apps/app/src/plugins/1.router/v2RouteName.ts — pure route-name prefix helper
  • apps/app/src/plugins/1.router/__tests__/v2RouteName.spec.ts — its unit test
  • apps/app/eslint-local-rules.cjs — local ESLint plugin entry
  • apps/app/eslint-rules/require-v2-layout-meta.cjs — the custom rule
  • apps/app/eslint-rules/__tests__/require-v2-layout-meta.spec.ts — RuleTester test
  • apps/app/tests/unit/boundaries-v2.spec.ts — ESLint-API test proving the new zones
  • apps/app/src/stores/useShellUiStore.ts — v2 shell UI state
  • apps/app/src/stores/__tests__/useShellUiStore.spec.ts
  • apps/app/src/composables/useRightDrawer.ts — facade over the store
  • apps/app/src/composables/__tests__/useRightDrawer.spec.ts
  • apps/app/src/layouts/OrganizerLayoutV2.vue — v2 layout (MetaLayouts target dir)
  • apps/app/src/layouts/components/AppShellV2.vue — Tailwind-grid skeleton
  • apps/app/tests/component/layouts/AppShellV2.spec.ts — mount test
  • apps/app/src/pages-v2/dashboard.vue — empty boot-proof page
  • apps/app/tests/playwright-ct/v2/appshell-boot.spec.ts — CT smoke

Modified:

  • apps/app/vite.config.ts:23-30routesFolder array + getRouteName extension
  • apps/app/.eslintrc.cjsboundaries/elements + boundaries/element-types + plugins + overrides
  • apps/app/package.json — add eslint-plugin-local-rules devDependency
  • dev-docs/RFC-WS-FRONTEND-PRIMEVUE.md — supersession banner on F4aF4d
  • dev-docs/PRIMEVUE_COMPONENTS.md — pointer to the new RFC

Task 1: New project RFC + supersession pointers

Files:

  • Create: dev-docs/RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md

  • Modify: dev-docs/RFC-WS-FRONTEND-PRIMEVUE.md (add banner above the ### F4 — Component migration heading)

  • Modify: dev-docs/PRIMEVUE_COMPONENTS.md (add pointer near the top status block)

  • Step 1: Create the RFC file

Create dev-docs/RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md with this content:

# RFC-WS-GUI-REDESIGN-CREWLI-STARTER — crewli-starter as design source

| Field | Value |
|---|---|
| **Status** | Approved (2026-05-16) |
| **Supersedes** | F4aF4d component-migration sub-packages of RFC-WS-FRONTEND-PRIMEVUE |
| **Design spec** | `dev-docs/superpowers/specs/2026-05-15-crewli-starter-gui-redesign-design.md` |
| **Impl plans** | `dev-docs/superpowers/plans/2026-05-16-gui-redesign-foundation.md` (Plan 1 of 5) |

## 1. What changes vs RFC-WS-FRONTEND-PRIMEVUE

The F4aF4d strategy ("translate legacy Vuetify pages 1:1 to PrimeVue,
preserve UX") is **superseded**. The new strategy: `crewli-starter/` is
the design source of truth; v2 pages are built fresh under `/v2/*`
parallel routes and migrated page-by-page; v1 stays inert until per-page
cutover. All F4 *architectural* decisions (AD-1..AD-12: PrimeVue + Aura +
Tailwind + FormField + DataTable conventions) remain binding.

## 2. Binding architectural decisions (carried from the spec)

- **AD-G1 — Parallel routes.** `pages-v2/` mounts at `/v2/*` via a second
  `routesFolder`; v2 route NAMES are `v2-` prefixed (collision guard).
- **AD-G2 — Layout.** `OrganizerLayoutV2` wraps `AppShellV2`; every
  `pages-v2/**` page declares `definePage({ meta: { layout:
  'OrganizerLayoutV2' } })`, enforced by a custom ESLint rule.
- **AD-G3 — Fidelity.** PrimeVue-first; custom CSS only for genuinely
  bespoke visuals (DraggableBlock layout, WorkspaceSwitcher visual,
  shell chrome). Generic elements accept the PrimeVue look.
- **AD-G4 — State.** No `useWorkspaceStore`. Org/context data reuses
  `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-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
  after human parity-check vs crewli-starter. CT specs are standalone
  (no `@storybook/test-runner`).
- **AD-G7 — Portal.** Frontend SPA prefix is `/portal/*` (already true
  in repo); observability binds on `route.meta.context==='portal'`, not
  path; `/api/v1/p/*` is a separate untouched backend layer. Portal v2
  is a later sprint with its own `PortalLayoutV2`.
- **AD-G8 — Cutover.** Per page: move `pages-v2/X``pages/X`, rewrite
  links, strip `v2-` name prefix, delete dead v1. Final cutover: folder
  renames + revert router/boundaries config + delete v1 shell + Vuetify.

## 3. Sequencing

Plan 1: RFC + structural foundation (bootable `/v2/` slice).
Plan 2: shell pieces (AppSidebar/AppTopbar/SidebarNav/WorkspaceSwitcher/
RightDrawer/AppDialog).
Plan 3: Tier-1 primitives + DraggableBlock + their Storybook stories.
Plan 4: template layer (List/Form/Detail/Dashboard/StateBlock).
Plan 5: full PrimeVue standard catalog stories + theme/density toolbar.
Then: Smart-Filter sub-sprint → Page-1 (events list) → subsequent trees.

## 4. Out of scope

Backend changes; v1 deletion before cutover; back-porting; domain
modules (Timetable/Cue/SectionBuilder migrate with their owning page);
Flatpickr/vue-i18n/DatePicker (inherit RFC-WS-FRONTEND-PRIMEVUE).
  • Step 2: Add the supersession banner to the old RFC

In dev-docs/RFC-WS-FRONTEND-PRIMEVUE.md, find the line ### F4 — Component migration (56 days, 4 sub-packages) and insert immediately above it:

> **⚠️ SUPERSEDED (2026-05-16):** F4aF4d below are superseded by
> `dev-docs/RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md`. The page-migration
> strategy changed (crewli-starter is now the design source, parallel
> `/v2/*` routes, page-by-page cutover). AD-1..AD-12 remain binding.
> F2/F3/F5/F6 are unaffected.
  • Step 3: Add the pointer to PRIMEVUE_COMPONENTS.md

In dev-docs/PRIMEVUE_COMPONENTS.md, directly under the **Status:** line near the top, add:

> **GUI redesign:** the page-migration phase is governed by
> `dev-docs/RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md` (supersedes F4aF4d).
> This component reference still applies to all v2 work.
  • Step 4: Verify and commit

Run: git -C /Users/berthausmans/Documents/Development/crewli diff --stat Expected: 3 files changed (1 new RFC, 2 modified docs).

cd /Users/berthausmans/Documents/Development/crewli
git add dev-docs/RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md dev-docs/RFC-WS-FRONTEND-PRIMEVUE.md dev-docs/PRIMEVUE_COMPONENTS.md
git commit -m "docs: add GUI-redesign RFC superseding F4a-F4d"

Task 2: v2RouteName pure helper (route-name collision guard)

Files:

  • Create: apps/app/src/plugins/1.router/v2RouteName.ts

  • Test: apps/app/src/plugins/1.router/__tests__/v2RouteName.spec.ts

  • Step 1: Write the failing test

Create apps/app/src/plugins/1.router/__tests__/v2RouteName.spec.ts:

import { describe, expect, it } from 'vitest'
import { v2RouteName } from '@/plugins/1.router/v2RouteName'

describe('v2RouteName', () => {
  it('prefixes names for routes under the v2 path', () => {
    expect(v2RouteName('dashboard', '/v2/dashboard')).toBe('v2-dashboard')
    expect(v2RouteName('events-id', '/v2/events/:id')).toBe('v2-events-id')
  })

  it('prefixes the bare v2 root', () => {
    expect(v2RouteName('v2', '/v2')).toBe('v2-v2')
    expect(v2RouteName('index', 'v2/')).toBe('v2-index')
  })

  it('leaves v1 route names untouched', () => {
    expect(v2RouteName('dashboard', '/dashboard')).toBe('dashboard')
    expect(v2RouteName('events', '/events')).toBe('events')
  })

  it('does not match a v1 path that merely starts with the letters v2', () => {
    expect(v2RouteName('v2x-thing', '/v2x-thing')).toBe('v2x-thing')
  })
})
  • Step 2: Run it to verify it fails

Run: cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run src/plugins/1.router/__tests__/v2RouteName.spec.ts Expected: FAIL — Failed to resolve import "@/plugins/1.router/v2RouteName".

  • Step 3: Write the implementation

Create apps/app/src/plugins/1.router/v2RouteName.ts:

/**
 * Route-NAME collision guard for the parallel /v2/* tree.
 *
 * unplugin-vue-router derives the route name from the file path relative
 * to its routesFolder, so `src/pages/events/index.vue` and
 * `src/pages-v2/events/index.vue` would BOTH yield name `events` — a
 * silent runtime collision (router.push({ name: 'events' }) becomes
 * ambiguous). The pages-v2 routesFolder carries `path: 'v2/'`, so every
 * v2 route's URL path is under `/v2`. Prefix the NAME with `v2-` for
 * those, leaving v1 names untouched. Stripped at final cutover.
 *
 * @param baseName  the kebab name already computed by getRouteName
 * @param routePath the node's URL path (leading slash optional)
 */
export function v2RouteName(baseName: string, routePath: string): string {
  const normalized = routePath.replace(/^\//, '')
  const isV2 = normalized === 'v2' || normalized.startsWith('v2/')

  return isV2 ? `v2-${baseName}` : baseName
}
  • Step 4: Run the test to verify it passes

Run: cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run src/plugins/1.router/__tests__/v2RouteName.spec.ts Expected: PASS — 4 tests.

  • Step 5: Commit
cd /Users/berthausmans/Documents/Development/crewli
git add apps/app/src/plugins/1.router/v2RouteName.ts apps/app/src/plugins/1.router/__tests__/v2RouteName.spec.ts
git commit -m "feat(router): add v2RouteName collision-guard helper"

Task 3: Wire routesFolder + getRouteName in vite.config.ts

Files:

  • Modify: apps/app/vite.config.ts:23-30

  • Step 1: Replace the VueRouter() block

In apps/app/vite.config.ts, replace exactly this block (lines 23-30):

    VueRouter({
      getRouteName: routeNode => {
        // Convert pascal case to kebab case
        return getPascalCaseRouteName(routeNode)
          .replace(/([a-z\d])([A-Z])/g, '$1-$2')
          .toLowerCase()
      },
    }),

with:

    VueRouter({
      // Parallel /v2/* tree (RFC-WS-GUI-REDESIGN AD-G1). The second
      // routesFolder prefixes every v2 URL with /v2/; v2RouteName then
      // prefixes the route NAME with `v2-` so file-name twins across
      // pages/ and pages-v2/ cannot collide. Reverted at final cutover.
      routesFolder: [
        { src: 'src/pages' },
        { src: 'src/pages-v2', path: 'v2/' },
      ],
      getRouteName: routeNode => {
        // Convert pascal case to kebab case
        const base = getPascalCaseRouteName(routeNode)
          .replace(/([a-z\d])([A-Z])/g, '$1-$2')
          .toLowerCase()

        // Defensive path read: unplugin-vue-router 0.8.8 TreeNode exposes
        // `.fullPath`; fall back to `.value.path` then '' so a future
        // plugin bump can't silently drop the v2- prefix. Step 5 below
        // empirically verifies the emitted name.
        const nodePath
          = (routeNode.fullPath
            ?? routeNode.value?.path
            ?? '') as string

        return v2RouteName(base, nodePath)
      },
    }),
  • Step 2: Add the helper import

In apps/app/vite.config.ts, the imports end at line 16 (import svgLoader from 'vite-svg-loader'). Add directly below it:

import { v2RouteName } from './src/plugins/1.router/v2RouteName'
  • Step 3: Create the boot-proof page so the dev server has a v2 route

Create apps/app/src/pages-v2/dashboard.vue (fleshed out further in Task 9; minimal here so the route exists):

<script setup lang="ts">
definePage({ meta: { layout: 'OrganizerLayoutV2', public: true } })
</script>

<template>
  <div data-testid="v2-dashboard-placeholder">v2 dashboard</div>
</template>
  • Step 4: Typecheck the config change

Run: cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vue-tsc --noEmit -p tsconfig.json 2>&1 | head -20 Expected: no new errors referencing vite.config.ts or v2RouteName. (routeNode.fullPath is a documented TreeNode field in unplugin-vue-router 0.8.x.)

  • Step 5: Verify the route is generated with the prefixed name

Run: cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vite build 2>&1 | tail -5 Expected: build succeeds. Then run: grep -rn "v2-dashboard\|/v2/dashboard" "$(find /Users/berthausmans/Documents/Development/crewli/apps/app -name 'typed-router.d.ts' | head -1)" Expected: an entry mapping path /v2/dashboard to route name v2-dashboard.

If the name is dashboard (no v2- prefix), the defensive path read returned empty — the installed unplugin-vue-router exposes the node path differently. Remediate before continuing: temporarily add console.log(JSON.stringify({ k: Object.keys(routeNode), fp: routeNode.fullPath, vp: routeNode.value?.path })) inside getRouteName, run pnpm exec vite build 2>&1 | grep v2, read the actual field carrying /v2/..., update the nodePath expression to read that field, remove the log, rebuild, re-grep. Do not proceed to Task 4 until typed-router.d.ts shows v2-dashboard.

  • Step 6: Commit
cd /Users/berthausmans/Documents/Development/crewli
git add apps/app/vite.config.ts apps/app/src/pages-v2/dashboard.vue
git commit -m "feat(router): mount pages-v2 at /v2/* with v2- name prefix"

Task 4: eslint-plugin-boundaries — v2 zones + matrix

Files:

  • Modify: apps/app/.eslintrc.cjs (boundaries/elements and boundaries/element-types)

  • Test: apps/app/tests/unit/boundaries-v2.spec.ts

  • Step 1: Write the failing test (ESLint Node API on fixtures)

Create apps/app/tests/unit/boundaries-v2.spec.ts:

import { describe, expect, it } from 'vitest'
import { ESLint } from 'eslint'

const eslint = new ESLint({ cwd: `${process.cwd()}` })

async function boundaryErrors(filePath: string, code: string) {
  const [result] = await eslint.lintText(code, { filePath })

  return result.messages.filter(m => m.ruleId === 'boundaries/element-types')
}

describe('boundaries — v2 zones', () => {
  it('allows pages-v2 → components-v2', async () => {
    const errs = await boundaryErrors(
      'src/pages-v2/dashboard.vue',
      `<script setup lang="ts">import X from '@/components-v2/shared/X.vue'</script><template><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',
      `<script setup lang="ts">import FormField from '@/components/forms/FormField.vue'</script><template><FormField /></template>`,
    )
    expect(errs).toHaveLength(0)
  })

  it('forbids v1 components → components-v2 (no back-porting)', async () => {
    const errs = await boundaryErrors(
      'src/components/organizer/Legacy.vue',
      `<script setup lang="ts">import X from '@/components-v2/shared/X.vue'</script><template><X /></template>`,
    )
    expect(errs.length).toBeGreaterThan(0)
  })
})
  • Step 2: Run it to verify it fails

Run: cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run tests/unit/boundaries-v2.spec.ts Expected: FAIL — the "forbids v1 → components-v2" case currently produces 0 errors (no zone exists, so the import is unclassified and allowed), so expect(errs.length).toBeGreaterThan(0) fails.

  • Step 3: Add the element zones

In apps/app/.eslintrc.cjs, find this exact line inside 'boundaries/elements':

      { type: 'components', pattern: 'src/components/**' },

Insert directly above it (so the narrow zones win — first-match-wins, order matters):

      // GUI-redesign v2 zones (RFC-WS-GUI-REDESIGN AD-G5). Declared
      // before the generic `components` catch-all. `components-foundation`
      // is the ONLY sanctioned v1→v2 bridge (FormField + Icon — audited
      // to live in the generic `components` zone, not components-shared).
      // eslint-plugin-boundaries 6.0.2 micromatch supports the brace
      // form below; if a future bump breaks it, split into two entries
      // with the same `type` (see RFC §14 fallback).
      { type: 'components-foundation', pattern: 'src/components/{forms/**,Icon.vue}' },
      { type: 'components-v2', pattern: 'src/components-v2/**' },

Then find this exact line:

      { type: 'pages', pattern: 'src/pages/**' },

Insert directly above it:

      { type: 'pages-v2', pattern: 'src/pages-v2/**' },
  • Step 4: Add the matrix rows

In apps/app/.eslintrc.cjs, find this exact line inside 'boundaries/element-types' rules:

        { from: 'components', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'components', 'components-shared', 'components-organizer'] },

Insert directly above it:

        // v2 zones. components-v2 may use the FormField/Icon bridge
        // (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).
        { 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'] },
  • Step 5: Run the test to verify it passes

Run: cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run tests/unit/boundaries-v2.spec.ts Expected: PASS — 3 tests. (If the brace-glob misbehaves on 6.0.2, replace the components-foundation element line with two lines — pattern: 'src/components/forms/**' and pattern: 'src/components/Icon.vue' — same type; re-run.)

  • Step 6: Confirm no regression on existing zones

Run: cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec eslint 'src/components/organizer/**/*.vue' --rule '{}' 2>&1 | tail -3 Expected: no new boundaries/element-types errors introduced by the additions (exit status unchanged from baseline).

  • Step 7: Commit
cd /Users/berthausmans/Documents/Development/crewli
git add apps/app/.eslintrc.cjs apps/app/tests/unit/boundaries-v2.spec.ts
git commit -m "feat(lint): add components-v2/pages-v2 boundary zones (no back-port)"

Task 5: Custom ESLint rule — require-v2-layout-meta

Files:

  • Create: apps/app/eslint-rules/require-v2-layout-meta.cjs

  • Create: apps/app/eslint-local-rules.cjs

  • Create: apps/app/eslint-rules/__tests__/require-v2-layout-meta.spec.ts

  • Modify: apps/app/package.json (add eslint-plugin-local-rules dev dep)

  • Modify: apps/app/.eslintrc.cjs (plugins + new overrides entry)

  • Step 1: Add the local-rules plugin dependency

Run: cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm add -D eslint-plugin-local-rules@3.0.2 Expected: package.json devDependencies gains "eslint-plugin-local-rules": "3.0.2".

  • Step 2: Write the failing rule test (RuleTester)

Create apps/app/eslint-rules/__tests__/require-v2-layout-meta.spec.ts:

import { createRequire } from 'node:module'
import { describe, it } from 'vitest'
import { RuleTester } from 'eslint'

// Project is ESLint 8.57 — RuleTester uses the legacy `parser` +
// `parserOptions` shape (NOT ESLint-9 `languageOptions`). createRequire
// gives us a CJS `require` inside this ESM test for the .cjs rule + the
// parser path.
const require = createRequire(import.meta.url)
const rule = require('../require-v2-layout-meta.cjs')

const ruleTester = new RuleTester({
  parser: require.resolve('vue-eslint-parser'),
  parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
})

describe('require-v2-layout-meta', () => {
  it('passes valid and rejects invalid', () => {
    ruleTester.run('require-v2-layout-meta', rule, {
      valid: [
        {
          filename: 'src/pages-v2/dashboard.vue',
          code: `<script setup lang="ts">definePage({ meta: { layout: 'OrganizerLayoutV2' } })</script><template><div/></template>`,
        },
        {
          // non-v2 file is ignored entirely
          filename: 'src/pages/dashboard.vue',
          code: `<script setup lang="ts">const x = 1</script><template><div/></template>`,
        },
      ],
      invalid: [
        {
          filename: 'src/pages-v2/dashboard.vue',
          code: `<script setup lang="ts">const x = 1</script><template><div/></template>`,
          errors: [{ messageId: 'missing' }],
        },
        {
          filename: 'src/pages-v2/events/index.vue',
          code: `<script setup lang="ts">definePage({ meta: { layout: 'default' } })</script><template><div/></template>`,
          errors: [{ messageId: 'wrongLayout' }],
        },
      ],
    })
  })
})
  • Step 3: Run it to verify it fails

Run: cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run eslint-rules/__tests__/require-v2-layout-meta.spec.ts Expected: FAIL — Cannot find module '../require-v2-layout-meta.cjs'.

  • Step 4: Write the rule

Create apps/app/eslint-rules/require-v2-layout-meta.cjs:

/**
 * Enforces that every src/pages-v2/**.vue page declares
 *   definePage({ meta: { layout: 'OrganizerLayoutV2' } })
 * (or 'PortalLayoutV2' for src/pages-v2/portal/**). Without this a v2
 * page silently falls back to the `default` layout — a no-error
 * wrong-shell bug. RFC-WS-GUI-REDESIGN AD-G2.
 */
'use strict'

module.exports = {
  meta: {
    type: 'problem',
    docs: { description: 'require definePage layout meta on pages-v2' },
    messages: {
      missing: 'pages-v2 page must call definePage({ meta: { layout: ... } }).',
      wrongLayout: 'pages-v2 layout must be {{expected}} (got {{actual}}).',
    },
    schema: [],
  },
  create(context) {
    const filename = (context.filename || context.getFilename() || '').replace(/\\/g, '/')
    if (!filename.includes('src/pages-v2/'))
      return {}

    const expected = filename.includes('src/pages-v2/portal/')
      ? 'PortalLayoutV2'
      : 'OrganizerLayoutV2'

    let sawDefinePage = false

    return {
      CallExpression(node) {
        if (node.callee.type !== 'Identifier' || node.callee.name !== 'definePage')
          return
        sawDefinePage = true

        const arg = node.arguments[0]
        const metaProp = arg && arg.type === 'ObjectExpression'
          ? arg.properties.find(p => p.key && p.key.name === 'meta')
          : null
        const layoutProp = metaProp && metaProp.value.type === 'ObjectExpression'
          ? metaProp.value.properties.find(p => p.key && p.key.name === 'layout')
          : null

        if (!layoutProp || layoutProp.value.value !== expected) {
          context.report({
            node,
            messageId: 'wrongLayout',
            data: { expected, actual: layoutProp ? String(layoutProp.value.value) : 'none' },
          })
        }
      },
      'Program:exit': function (node) {
        if (!sawDefinePage)
          context.report({ node, messageId: 'missing' })
      },
    }
  },
}
  • Step 5: Create the local-rules entry

Create apps/app/eslint-local-rules.cjs:

'use strict'

module.exports = {
  'require-v2-layout-meta': require('./eslint-rules/require-v2-layout-meta.cjs'),
}
  • Step 6: Run the rule test to verify it passes

Run: cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run eslint-rules/__tests__/require-v2-layout-meta.spec.ts Expected: PASS.

  • Step 7: Wire the rule into .eslintrc.cjs

In apps/app/.eslintrc.cjs, find the top-level plugins: [ array (≈ line 30) and add 'local-rules' as an element. Then add this entry to the top-level overrides: array:

    {
      files: ['src/pages-v2/**/*.vue'],
      rules: {
        'local-rules/require-v2-layout-meta': 'error',
      },
    },
  • Step 8: Verify the rule is active end-to-end

Run: cd /Users/berthausmans/Documents/Development/crewli/apps/app && printf '%s' "<script setup lang=\"ts\">const x=1</script><template><div/></template>" > /tmp/bad.vue && cp /tmp/bad.vue src/pages-v2/__rule_probe.vue && pnpm exec eslint src/pages-v2/__rule_probe.vue ; rm src/pages-v2/__rule_probe.vue Expected: ESLint reports local-rules/require-v2-layout-meta missing. (The probe file is removed by the same command.)

  • Step 9: Commit
cd /Users/berthausmans/Documents/Development/crewli
git add apps/app/eslint-rules apps/app/eslint-local-rules.cjs apps/app/.eslintrc.cjs apps/app/package.json apps/app/pnpm-lock.yaml
git commit -m "feat(lint): enforce definePage layout meta on pages-v2"

Task 6: useShellUiStore (sidebar / theme / density / right-drawer)

Files:

  • Create: apps/app/src/stores/useShellUiStore.ts

  • Test: apps/app/src/stores/__tests__/useShellUiStore.spec.ts

  • Step 1: Write the failing test

Create apps/app/src/stores/__tests__/useShellUiStore.spec.ts:

import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useShellUiStore } from '@/stores/useShellUiStore'

describe('useShellUiStore', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
    document.documentElement.removeAttribute('data-theme')
    document.documentElement.removeAttribute('data-density')
    document.documentElement.classList.remove('dark')
  })

  it('defaults: expanded sidebar, comfortable density, light theme, closed drawer', () => {
    const s = useShellUiStore()
    expect(s.sidebarCollapsed).toBe(false)
    expect(s.density).toBe('comfortable')
    expect(s.theme).toBe('light')
    expect(s.drawer.isOpen).toBe(false)
  })

  it('toggleSidebar flips collapsed', () => {
    const s = useShellUiStore()
    s.toggleSidebar()
    expect(s.sidebarCollapsed).toBe(true)
  })

  it('applyDomAttributes writes data-theme/data-density and .dark', () => {
    const s = useShellUiStore()
    s.setTheme('dark')
    s.setDensity('compact')
    s.applyDomAttributes()
    expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
    expect(document.documentElement.getAttribute('data-density')).toBe('compact')
    expect(document.documentElement.classList.contains('dark')).toBe(true)
  })

  it('openDrawer/closeDrawer mutate drawer state', () => {
    const s = useShellUiStore()
    s.openDrawer('PersonCard', { id: '01H' })
    expect(s.drawer).toEqual({ isOpen: true, component: 'PersonCard', props: { id: '01H' } })
    s.closeDrawer()
    expect(s.drawer.isOpen).toBe(false)
  })
})
  • Step 2: Run it to verify it fails

Run: cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run src/stores/__tests__/useShellUiStore.spec.ts Expected: FAIL — cannot resolve @/stores/useShellUiStore.

  • Step 3: Write the store

Create apps/app/src/stores/useShellUiStore.ts:

import { defineStore } from 'pinia'
import { ref } from 'vue'

// v2 shell UI state ONLY (RFC-WS-GUI-REDESIGN AD-G4). No tenant/org
// state — that stays in useAuthStore/useOrganisationStore. Owns the
// writes to <html data-theme>/<html data-density>/.dark (composes with
// Aura darkModeSelector '.dark'); v2 bypasses Vuexy useSkins.ts.

export type ShellTheme = 'light' | 'dark'
export type ShellDensity = 'comfortable' | 'compact'

export interface ShellDrawerState {
  isOpen: boolean
  component: string | null
  props: Record<string, unknown>
}

export const useShellUiStore = defineStore('shellUi', () => {
  const sidebarCollapsed = ref(false)
  const density = ref<ShellDensity>('comfortable')
  const theme = ref<ShellTheme>('light')
  const drawer = ref<ShellDrawerState>({ isOpen: false, component: null, props: {} })

  function toggleSidebar(): void {
    sidebarCollapsed.value = !sidebarCollapsed.value
  }

  function setTheme(next: ShellTheme): void {
    theme.value = next
  }

  function setDensity(next: ShellDensity): void {
    density.value = next
  }

  function applyDomAttributes(): void {
    const el = document.documentElement
    el.setAttribute('data-theme', theme.value)
    el.setAttribute('data-density', density.value)
    el.classList.toggle('dark', theme.value === 'dark')
  }

  function openDrawer(component: string, props: Record<string, unknown> = {}): void {
    drawer.value = { isOpen: true, component, props }
  }

  function closeDrawer(): void {
    drawer.value = { isOpen: false, component: null, props: {} }
  }

  return {
    sidebarCollapsed,
    density,
    theme,
    drawer,
    toggleSidebar,
    setTheme,
    setDensity,
    applyDomAttributes,
    openDrawer,
    closeDrawer,
  }
})
  • Step 4: Run the test to verify it passes

Run: cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run src/stores/__tests__/useShellUiStore.spec.ts Expected: PASS — 5 tests.

  • Step 5: Commit
cd /Users/berthausmans/Documents/Development/crewli
git add apps/app/src/stores/useShellUiStore.ts apps/app/src/stores/__tests__/useShellUiStore.spec.ts
git commit -m "feat(stores): add useShellUiStore for v2 shell UI state"

Task 7: useRightDrawer() facade composable

Files:

  • Create: apps/app/src/composables/useRightDrawer.ts

  • Test: apps/app/src/composables/__tests__/useRightDrawer.spec.ts

  • Step 1: Write the failing test

Create apps/app/src/composables/__tests__/useRightDrawer.spec.ts:

import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useRightDrawer } from '@/composables/useRightDrawer'
import { useShellUiStore } from '@/stores/useShellUiStore'

describe('useRightDrawer', () => {
  beforeEach(() => setActivePinia(createPinia()))

  it('open() writes through to the store', () => {
    const { open } = useRightDrawer()
    open('ArtistCard', { id: '01H' })
    const s = useShellUiStore()
    expect(s.drawer).toEqual({ isOpen: true, component: 'ArtistCard', props: { id: '01H' } })
  })

  it('close() clears the store drawer', () => {
    const { open, close, isOpen } = useRightDrawer()
    open('ArtistCard')
    close()
    expect(isOpen.value).toBe(false)
    expect(useShellUiStore().drawer.isOpen).toBe(false)
  })
})
  • Step 2: Run it to verify it fails

Run: cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run src/composables/__tests__/useRightDrawer.spec.ts Expected: FAIL — cannot resolve @/composables/useRightDrawer.

  • Step 3: Write the composable

Create apps/app/src/composables/useRightDrawer.ts:

import { storeToRefs } from 'pinia'
import type { ComputedRef } from 'vue'
import { computed } from 'vue'
import { useShellUiStore } from '@/stores/useShellUiStore'

// Thin facade over useShellUiStore.drawer (RFC-WS-GUI-REDESIGN AD-G4,
// issue 5). NOT a module-level ref singleton: state lives in the Pinia
// store so Playwright CT can drive the drawer via @pinia/testing
// without rendering the shell, and tests don't leak state across cases.

export interface UseRightDrawer {
  isOpen: ComputedRef<boolean>
  component: ComputedRef<string | null>
  props: ComputedRef<Record<string, unknown>>
  open: (component: string, props?: Record<string, unknown>) => void
  close: () => void
}

export function useRightDrawer(): UseRightDrawer {
  const store = useShellUiStore()
  const { drawer } = storeToRefs(store)

  return {
    isOpen: computed(() => drawer.value.isOpen),
    component: computed(() => drawer.value.component),
    props: computed(() => drawer.value.props),
    open: (component, props = {}) => store.openDrawer(component, props),
    close: () => store.closeDrawer(),
  }
}
  • Step 4: Run the test to verify it passes

Run: cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run src/composables/__tests__/useRightDrawer.spec.ts Expected: PASS — 2 tests.

  • Step 5: Commit
cd /Users/berthausmans/Documents/Development/crewli
git add apps/app/src/composables/useRightDrawer.ts apps/app/src/composables/__tests__/useRightDrawer.spec.ts
git commit -m "feat(composables): add useRightDrawer facade over useShellUiStore"

Task 8: OrganizerLayoutV2 + AppShellV2 skeleton

Files:

  • Create: apps/app/src/layouts/components/AppShellV2.vue
  • Create: apps/app/src/layouts/OrganizerLayoutV2.vue
  • Test: apps/app/tests/component/layouts/AppShellV2.spec.ts

Skeleton only: a Tailwind 12-col-ish grid with named slot regions (sidebar, topbar, default=content, drawer) + RouterView. No PrimeVue parts yet (spec: outer container is custom Tailwind grid; PrimeVue shell pieces arrive in Plan 2). Per spec §13, the skeleton gets a Vitest mount test now; Playwright-CT visual baselines are captured in Plan 2 when the real shell pieces land.

  • Step 1: Write the failing mount test

Create apps/app/tests/component/layouts/AppShellV2.spec.ts:

import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import AppShellV2 from '@/layouts/components/AppShellV2.vue'

describe('AppShellV2 (skeleton)', () => {
  it('renders the grid regions and default slot content', () => {
    const wrapper = mount(AppShellV2, {
      global: { plugins: [createPinia()] },
      slots: {
        sidebar: '<nav data-testid="sb">SB</nav>',
        topbar: '<header data-testid="tb">TB</header>',
        default: '<main data-testid="content">CONTENT</main>',
        drawer: '<aside data-testid="dr">DR</aside>',
      },
    })
    expect(wrapper.find('[data-testid="appshell-v2"]').exists()).toBe(true)
    expect(wrapper.find('[data-testid="sb"]').exists()).toBe(true)
    expect(wrapper.find('[data-testid="tb"]').exists()).toBe(true)
    expect(wrapper.find('[data-testid="content"]').text()).toBe('CONTENT')
    expect(wrapper.find('[data-testid="dr"]').exists()).toBe(true)
  })

  it('applies the collapsed modifier from useShellUiStore', async () => {
    const pinia = createPinia()
    const wrapper = mount(AppShellV2, { global: { plugins: [pinia] } })
    const { useShellUiStore } = await import('@/stores/useShellUiStore')
    useShellUiStore().toggleSidebar()
    await wrapper.vm.$nextTick()
    expect(wrapper.find('[data-testid="appshell-v2"]').classes()).toContain('is-collapsed')
  })
})
  • Step 2: Run it to verify it fails

Run: cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run --project component tests/component/layouts/AppShellV2.spec.ts Expected: FAIL — cannot resolve @/layouts/components/AppShellV2.vue.

  • Step 3: Write AppShellV2.vue

Create apps/app/src/layouts/components/AppShellV2.vue:

<script setup lang="ts">
// AppShellV2 — Tailwind-grid SKELETON (RFC-WS-GUI-REDESIGN AD-G2/G3,
// Plan 1). Named slot regions; PrimeVue shell pieces (AppSidebar,
// AppTopbar, WorkspaceSwitcher, RightDrawer) are injected via slots in
// Plan 2. Outer container is intentionally custom Tailwind (no PrimeVue
// equivalent for a permanent rail), matching crewli-starter `.app`.

import { computed, onMounted, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useShellUiStore } from '@/stores/useShellUiStore'

const shell = useShellUiStore()
const { sidebarCollapsed } = storeToRefs(shell)

const rootClass = computed(() => ({ 'is-collapsed': sidebarCollapsed.value }))

onMounted(() => shell.applyDomAttributes())
watch(
  () => [shell.theme, shell.density],
  () => shell.applyDomAttributes(),
)
</script>

<template>
  <div
    data-testid="appshell-v2"
    class="grid min-h-screen grid-cols-[auto_1fr]"
    :class="rootClass"
  >
    <div class="row-span-2">
      <slot name="sidebar" />
    </div>

    <div class="flex min-h-screen flex-col">
      <slot name="topbar" />

      <main class="flex-1 overflow-auto p-6">
        <slot />
      </main>
    </div>

    <slot name="drawer" />
  </div>
</template>
  • Step 4: Run the test to verify it passes

Run: cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run --project component tests/component/layouts/AppShellV2.spec.ts Expected: PASS — 2 tests.

  • Step 5: Write OrganizerLayoutV2.vue

Create apps/app/src/layouts/OrganizerLayoutV2.vue (must live in src/layouts/ — MetaLayouts target):

<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.
// Plan 2 fills the sidebar/topbar/drawer slots with ported PrimeVue
// shell pieces.
import AppShellV2 from '@/layouts/components/AppShellV2.vue'
</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>
    </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>
    </template>

    <RouterView />
  </AppShellV2>
</template>
  • Step 6: Typecheck

Run: cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vue-tsc --noEmit -p tsconfig.json 2>&1 | grep -E 'AppShellV2|OrganizerLayoutV2' | head Expected: no output (no type errors in the new files).

  • Step 7: Commit
cd /Users/berthausmans/Documents/Development/crewli
git add apps/app/src/layouts/OrganizerLayoutV2.vue apps/app/src/layouts/components/AppShellV2.vue apps/app/tests/component/layouts/AppShellV2.spec.ts
git commit -m "feat(layouts): add OrganizerLayoutV2 + AppShellV2 skeleton"

Task 9: Boot proof — /v2/dashboard + full gate

Files:

  • Modify: apps/app/src/pages-v2/dashboard.vue (flesh out from the Task 3 stub)

  • Test: apps/app/tests/playwright-ct/v2/appshell-boot.spec.ts

  • Step 1: Flesh out the page

Replace the contents of apps/app/src/pages-v2/dashboard.vue:

<script setup lang="ts">
// First v2 page — boot proof (RFC-WS-GUI-REDESIGN Plan 1). `public`
// keeps it reachable without auth wiring during the foundation slice;
// it is removed when the real dashboard lands in a later plan.
definePage({ meta: { layout: 'OrganizerLayoutV2', public: true } })
</script>

<template>
  <section data-testid="v2-dashboard">
    <h1 class="text-xl font-semibold text-surface-900 dark:text-surface-0">
      v2 foundation OK
    </h1>
    <p class="mt-2 text-surface-600 dark:text-surface-300">
      AppShellV2 skeleton renders this route at /v2/dashboard.
    </p>
  </section>
</template>
  • Step 2: Write the failing CT smoke (mounts the skeleton + page body)

Create apps/app/tests/playwright-ct/v2/appshell-boot.spec.ts:

import { expect, test } from '@playwright/experimental-ct-vue'
import { createTestingPinia } from '@pinia/testing'
import AppShellV2 from '@/layouts/components/AppShellV2.vue'

test('AppShellV2 mounts and renders content in the CT runner', async ({ mount }) => {
  const component = await mount(AppShellV2, {
    global: { plugins: [createTestingPinia({ stubActions: false })] },
    slots: { default: '<section data-testid="v2-dashboard">v2 foundation OK</section>' },
  })

  await expect(component.getByTestId('appshell-v2')).toBeVisible()
  await expect(component.getByTestId('v2-dashboard')).toContainText('v2 foundation OK')
})
  • Step 3: Run the smoke (integration test — depends on Task 8)

This is an integration smoke, not unit TDD: it proves the CT runner can mount the Plan-1 skeleton built in Task 8. There is no artificial red phase — the test file is the new artifact; if AppShellV2 were absent the import would fail.

Run: cd /Users/berthausmans/Documents/Development/crewli/apps/app && grep '@pinia/testing' package.json && pnpm exec playwright test --config=playwright-ct.config.ts tests/playwright-ct/v2/appshell-boot.spec.ts 2>&1 | tail -6 Expected: @pinia/testing is present in package.json (it is, ^1.0.3); result 1 passed. If the first CT run reports the Chromium browser is missing, run pnpm exec playwright install chromium once and re-run.

  • Step 4: Run the full foundation gate

Run each; all must pass:

cd /Users/berthausmans/Documents/Development/crewli/apps/app
pnpm exec vue-tsc --noEmit -p tsconfig.json          # typecheck: 0 new errors
pnpm exec eslint src/pages-v2 src/components-v2 src/stores/useShellUiStore.ts src/composables/useRightDrawer.ts  # boundaries + v2 layout rule clean
pnpm exec vitest run src/stores/__tests__/useShellUiStore.spec.ts src/composables/__tests__/useRightDrawer.spec.ts src/plugins/1.router/__tests__/v2RouteName.spec.ts tests/unit/boundaries-v2.spec.ts
pnpm exec vitest run --project component tests/component/layouts/AppShellV2.spec.ts
pnpm exec vite build                                  # production build succeeds

Expected: typecheck 0 new errors; eslint 0 errors; all vitest specs pass; build succeeds with a /v2/dashboardv2-dashboard entry in typed-router.d.ts.

  • Step 5: Commit
cd /Users/berthausmans/Documents/Development/crewli
git add apps/app/src/pages-v2/dashboard.vue apps/app/tests/playwright-ct/v2/appshell-boot.spec.ts
git commit -m "feat(v2): boot /v2/dashboard through OrganizerLayoutV2 + AppShellV2"

Definition of Done (Plan 1)

  • New RFC committed; F4aF4d banner + PRIMEVUE_COMPONENTS pointer added.
  • /v2/dashboard renders via OrganizerLayoutV2AppShellV2; its route name is v2-dashboard (no collision with v1 dashboard).
  • useShellUiStore + useRightDrawer() unit-tested; drawer state is store-backed (CT-drivable).
  • New boundary zones active; back-port v1→components-v2 is an error.
  • A pages-v2/** page missing the OrganizerLayoutV2 layout meta is an ESLint error.
  • pnpm exec vue-tsc --noEmit, pnpm lint, pnpm test, the CT smoke, and pnpm exec vite build all pass. Existing test count not reduced.
  • No src/components-v2/forms/ directory created (FormField reused via the components-foundation bridge).

Subsequent plans (authored after Plan 1 lands)

  • Plan 2 — Shell pieces: AppSidebar, SidebarHeader, SidebarNav, WorkspaceSwitcher (PrimeVue Popover + computed over useAuthStore/useOrganisationStore), AppTopbar (PrimeVue Breadcrumb/Button/Avatar/Menu/OverlayBadge), RightDrawer (PrimeVue Drawer + scaffold, driven by useRightDrawer()), AppDialog (PrimeVue Dialog + scaffold). Each: Vitest mount + a Playwright-CT @visual baseline captured after parity-check vs crewli-starter. Fills the OrganizerLayoutV2 slots.
  • Plan 3 — Tier-1 primitives + DraggableBlock: StatusTag (+ statusSeverity.ts map seeded from src/types/ enums), StatCard, StateBlock, PageHead, TagsInput, EnergyDots, EnergyPicker, and DraggableBlock (foundation despite Tier-4 deferring the Timetable/Cue pages — spec §8/§9). Co-located .stories.ts each.
  • Plan 4 — Template layer: ListTemplate, FormTemplate, DetailTemplate, DashboardTemplate, StateBlock integration.
  • Plan 5 — Storybook catalog + toolbar: global theme/density toolbar decorators in .storybook/preview.ts; the ~80-component PrimeVue standard catalog (grouped per crewli-starter ComponentsPage.vue); Foundations stories. CT specs stay standalone (no @storybook/test-runner), per spec §13.

Then the Smart-Filter sub-sprint, then Page-1 (events list), then the remaining page trees, per spec §10.