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>
46 KiB
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 F4a–F4d) 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 1–2 applied).
Scope: This is Plan 1 of 5. It delivers the RFC + spec §9 deliverable 1. Plans 2–5 (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 F4a–F4dapps/app/src/plugins/1.router/v2RouteName.ts— pure route-name prefix helperapps/app/src/plugins/1.router/__tests__/v2RouteName.spec.ts— its unit testapps/app/eslint-local-rules.cjs— local ESLint plugin entryapps/app/eslint-rules/require-v2-layout-meta.cjs— the custom ruleapps/app/eslint-rules/__tests__/require-v2-layout-meta.spec.ts— RuleTester testapps/app/tests/unit/boundaries-v2.spec.ts— ESLint-API test proving the new zonesapps/app/src/stores/useShellUiStore.ts— v2 shell UI stateapps/app/src/stores/__tests__/useShellUiStore.spec.tsapps/app/src/composables/useRightDrawer.ts— facade over the storeapps/app/src/composables/__tests__/useRightDrawer.spec.tsapps/app/src/layouts/OrganizerLayoutV2.vue— v2 layout (MetaLayouts target dir)apps/app/src/layouts/components/AppShellV2.vue— Tailwind-grid skeletonapps/app/tests/component/layouts/AppShellV2.spec.ts— mount testapps/app/src/pages-v2/dashboard.vue— empty boot-proof pageapps/app/tests/playwright-ct/v2/appshell-boot.spec.ts— CT smoke
Modified:
apps/app/vite.config.ts:23-30—routesFolderarray +getRouteNameextensionapps/app/.eslintrc.cjs—boundaries/elements+boundaries/element-types+plugins+overridesapps/app/package.json— addeslint-plugin-local-rulesdevDependencydev-docs/RFC-WS-FRONTEND-PRIMEVUE.md— supersession banner on F4a–F4ddev-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 migrationheading) -
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** | F4a–F4d 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 F4a–F4d 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 (5–6 days, 4 sub-packages) and insert immediately above it:
> **⚠️ SUPERSEDED (2026-05-16):** F4a–F4d 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 F4a–F4d).
> 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/elementsandboundaries/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(addeslint-plugin-local-rulesdev dep) -
Modify:
apps/app/.eslintrc.cjs(plugins+ newoverridesentry) -
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/dashboard → v2-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; F4a–F4d banner + PRIMEVUE_COMPONENTS pointer added.
/v2/dashboardrenders viaOrganizerLayoutV2→AppShellV2; its route name isv2-dashboard(no collision with v1dashboard).useShellUiStore+useRightDrawer()unit-tested; drawer state is store-backed (CT-drivable).- New boundary zones active; back-port v1→
components-v2is an error. - A
pages-v2/**page missing theOrganizerLayoutV2layout meta is an ESLint error. pnpm exec vue-tsc --noEmit,pnpm lint,pnpm test, the CT smoke, andpnpm exec vite buildall pass. Existing test count not reduced.- No
src/components-v2/forms/directory created (FormField reused via thecomponents-foundationbridge).
Subsequent plans (authored after Plan 1 lands)
- Plan 2 — Shell pieces:
AppSidebar,SidebarHeader,SidebarNav,WorkspaceSwitcher(PrimeVuePopover+ computed overuseAuthStore/useOrganisationStore),AppTopbar(PrimeVueBreadcrumb/Button/Avatar/Menu/OverlayBadge),RightDrawer(PrimeVueDrawer+ scaffold, driven byuseRightDrawer()),AppDialog(PrimeVueDialog+ scaffold). Each: Vitest mount + a Playwright-CT@visualbaseline captured after parity-check vs crewli-starter. Fills theOrganizerLayoutV2slots. - Plan 3 — Tier-1 primitives + DraggableBlock:
StatusTag(+statusSeverity.tsmap seeded fromsrc/types/enums),StatCard,StateBlock,PageHead,TagsInput,EnergyDots,EnergyPicker, andDraggableBlock(foundation despite Tier-4 deferring the Timetable/Cue pages — spec §8/§9). Co-located.stories.tseach. - Plan 4 — Template layer:
ListTemplate,FormTemplate,DetailTemplate,DashboardTemplate,StateBlockintegration. - Plan 5 — Storybook catalog + toolbar: global theme/density toolbar
decorators in
.storybook/preview.ts; the ~80-component PrimeVue standard catalog (grouped per crewli-starterComponentsPage.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.