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

1187 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-30``routesFolder` array + `getRouteName` extension
- `apps/app/.eslintrc.cjs``boundaries/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:
```markdown
# 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:
```markdown
> **⚠️ 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:
```markdown
> **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).
```bash
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`:
```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`:
```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**
```bash
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):
```ts
VueRouter({
getRouteName: routeNode => {
// Convert pascal case to kebab case
return getPascalCaseRouteName(routeNode)
.replace(/([a-z\d])([A-Z])/g, '$1-$2')
.toLowerCase()
},
}),
```
with:
```ts
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:
```ts
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):
```vue
<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**
```bash
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`:
```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'`:
```js
{ type: 'components', pattern: 'src/components/**' },
```
Insert **directly above** it (so the narrow zones win — first-match-wins, order matters):
```js
// 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:
```js
{ type: 'pages', pattern: 'src/pages/**' },
```
Insert **directly above** it:
```js
{ 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`:
```js
{ from: 'components', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'components', 'components-shared', 'components-organizer'] },
```
Insert **directly above** it:
```js
// 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**
```bash
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`:
```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`:
```js
/**
* 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`:
```js
'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:
```js
{
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**
```bash
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`:
```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`:
```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**
```bash
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`:
```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`:
```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**
```bash
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`:
```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`:
```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`):
```vue
<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**
```bash
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`:
```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`:
```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:
```bash
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/dashboard``v2-dashboard` entry in `typed-router.d.ts`.
- [ ] **Step 5: Commit**
```bash
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 `OrganizerLayoutV2``AppShellV2`; 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.
```