From 280776fcda8e1255075d8043ab4f646255525713 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sun, 17 May 2026 22:49:58 +0200 Subject: [PATCH] docs(plan): add Plan 3 (Tier-1 primitives + DraggableBlock) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight components under apps/app/src/components-v2/shared/ plus a severity-map utility, seeded from amended spec §8 and enforced via the bidirectional Vitest consistency test required by §8.X. Carries forward two Plan 2-deviation cleanup tasks from spec-amend commit ae0bd2da: - (a) migrate 6 centralized stories from src/stories/v2/ to co-located - (b) refactor AppTopbar to wrap PrimeVue Menubar per RFC AD-3 Also deletes the X.vue boundary-test stub and repoints boundaries-v2.spec.ts at a real shared component. Plan format follows Plan 1 precedent (REQUIRED SUB-SKILL header, - [ ] task syntax, Definition of Done, Plans 4-5 outline). Execution will happen in a fresh Claude Code session via superpowers:subagent-driven-development. Co-Authored-By: Claude Opus 4.7 --- ...026-05-17-gui-redesign-tier1-primitives.md | 1771 +++++++++++++++++ 1 file changed, 1771 insertions(+) create mode 100644 dev-docs/superpowers/plans/2026-05-17-gui-redesign-tier1-primitives.md diff --git a/dev-docs/superpowers/plans/2026-05-17-gui-redesign-tier1-primitives.md b/dev-docs/superpowers/plans/2026-05-17-gui-redesign-tier1-primitives.md new file mode 100644 index 00000000..ee55b91b --- /dev/null +++ b/dev-docs/superpowers/plans/2026-05-17-gui-redesign-tier1-primitives.md @@ -0,0 +1,1771 @@ +# Crewli GUI Redesign — Plan 3 (Tier-1 primitives + DraggableBlock) + +> **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 eight Tier-1 shared primitives (`StatusTag`, `StatCard`, `PageHead`, `StateBlock`, `TagsInput`, `EnergyDots`, `EnergyPicker`, `DraggableBlock`) plus the `statusSeverity.ts` single-source-of-truth map under `apps/app/src/components-v2/shared/`, each with a co-located Storybook story and the right test tier, so Plan 4's template layer and all subsequent v2 pages have a stable, visually-locked component vocabulary — all green under typecheck/lint/test/CT/build. + +**Architecture:** Six primitives are 1:1 ports of crewli-starter sources translated to TypeScript + Tailwind + PrimeVue Aura tokens following the Plan 2 `AppDialog.vue` precedent (documented CSS-translation JSDoc, `var(--p-*)` tokens, `Icon` via the `components-foundation` bridge). `StatusTag` resolves severity exclusively through `statusSeverity.ts` (spec §8/§8.X). `StateBlock` is built fresh (no crewli-starter source). `TagsInput` is a re-implementation onto PrimeVue `AutoComplete` (spec §8), not a port. `DraggableBlock` is a designed abstraction over two crewli-starter consumers with different drag models — its canonical API (spec §7.1) is reconciled in a Phase A gate **before any code**. No v1 code is touched except the two carried-forward Plan 2 cleanups and the X.vue boundary-stub removal. + +**Tech Stack:** Vue 3 ` + + +``` + +- [ ] **Step 4: Run — expect PASS** (3 tests). Run: `pnpm test -- StatusTag`. + +- [ ] **Step 5: Write the co-located story** + +```ts +// apps/app/src/components-v2/shared/StatusTag.stories.ts +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import StatusTag from '@/components-v2/shared/StatusTag.vue' + +const meta: Meta = { + title: 'Shared/StatusTag', + component: StatusTag, + tags: ['autodocs'], + argTypes: { + status: { control: 'text' }, + label: { control: 'text' }, + dot: { control: 'boolean' }, + }, +} +export default meta +type Story = StoryObj + +export const Success: Story = { args: { status: 'approved' } } +export const Warn: Story = { args: { status: 'pending_approval' } } +export const Info: Story = { args: { status: 'invited' } } +export const Secondary: Story = { args: { status: 'draft' } } +export const Danger: Story = { args: { status: 'no_show' } } +export const WithDot: Story = { args: { status: 'confirmed', dot: true } } +export const CustomLabel: Story = { args: { status: 'rejected', label: 'Afgewezen' } } +``` + +- [ ] **Step 6: Lint, typecheck, commit** + +Run: `pnpm exec vue-tsc --noEmit` and `pnpm lint -- src/components-v2/shared/StatusTag.vue src/components-v2/shared/StatusTag.stories.ts` → expect clean. + +```bash +git add apps/app/src/components-v2/shared/StatusTag.vue apps/app/src/components-v2/shared/__tests__/StatusTag.spec.ts apps/app/src/components-v2/shared/StatusTag.stories.ts +git commit -m "feat(gui-v2): StatusTag (PrimeVue Tag + statusSeverity map) + story" +``` + +- [ ] **Step 7: Parity-check (constraint #8) — RECORD, do not self-pass** + +Bert performs side-by-side: crewli-starter `StatusTag` (rendered under `[data-theme="dark"]` and light) vs v2 Storybook `Shared/StatusTag` (under `.dark` and light). Capture two screenshots into `apps/app/tests/playwright-ct/v2/__screenshots__/parity/StatusTag.{light,dark}.png` as evidence. Tick this box only after Bert records "parity pass" in the PR/commit trailer. **WHO:** Bert. **WHEN:** now, before any `@visual` baseline. **HOW:** browser side-by-side, screenshots committed. + +- [ ] **Step 8: Capture the Tier-4 `@visual` baseline (after Step 7 passes)** + +Run: `pnpm test:visual:update -- StatusTag` then `pnpm test:visual -- StatusTag` → expect PASS. + +```bash +git add apps/app/tests/playwright-ct/v2/__screenshots__ +git commit -m "test(gui-v2): StatusTag @visual baseline (parity-pass recorded)" +``` + +--- + +## Task 3: `StatCard.vue` + spec + story + +**Source:** `../crewli-starter/src/components/shared/StatCard.vue` (26 lines: `icon,label,value,trend,trendDir`; `.stat-card` CSS 1019–1039). **v2:** PrimeVue `` shell + `Icon` bridge + trend row; spec §8 "replaces AppKpiCard". + +**Files:** Create `StatCard.vue`; Test `__tests__/StatCard.spec.ts`; Story `StatCard.stories.ts`. + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/app/src/components-v2/shared/__tests__/StatCard.spec.ts +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { defineComponent } from 'vue' +import StatCard from '@/components-v2/shared/StatCard.vue' + +const CardStub = defineComponent({ name: 'CardStub', template: `
` }) +const IconStub = defineComponent({ name: 'Icon', props: ['name', 'size'], template: `` }) +const mountCard = (props: Record) => + mount(StatCard, { props, global: { stubs: { Card: CardStub, Icon: IconStub } } }) + +describe('StatCard', () => { + it('renders label, value and the leading icon', () => { + const w = mountCard({ icon: 'tabler-users', label: 'Vrijwilligers', value: 128 }) + expect(w.text()).toContain('Vrijwilligers') + expect(w.text()).toContain('128') + expect(w.get('.icon-stub').attributes('data-icon')).toBe('tabler-users') + }) + + it('shows the trend row with direction class only when trend is set', () => { + expect(mountCard({ icon: 'tabler-users', label: 'X', value: 1 }).find('[data-trend]').exists()).toBe(false) + const up = mountCard({ icon: 'tabler-users', label: 'X', value: 1, trend: '+12%', trendDir: 'up' }) + expect(up.get('[data-trend]').attributes('data-trend')).toBe('up') + expect(up.text()).toContain('+12%') + }) +}) +``` + +- [ ] **Step 2: Run — expect FAIL.** Run: `pnpm test -- StatCard`. + +- [ ] **Step 3: Implement `StatCard.vue`** + +```vue + + + +``` + +- [ ] **Step 4: Run — expect PASS.** Run: `pnpm test -- StatCard`. + +- [ ] **Step 5: Co-located story** + +```ts +// apps/app/src/components-v2/shared/StatCard.stories.ts +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import StatCard from '@/components-v2/shared/StatCard.vue' + +const meta: Meta = { + title: 'Shared/StatCard', + component: StatCard, + tags: ['autodocs'], + argTypes: { + icon: { control: 'text' }, + label: { control: 'text' }, + value: { control: 'text' }, + trend: { control: 'text' }, + trendDir: { control: 'inline-radio', options: ['', 'up', 'down'] }, + }, +} +export default meta +type Story = StoryObj + +export const Plain: Story = { args: { icon: 'tabler-users', label: 'Vrijwilligers', value: 128 } } +export const TrendUp: Story = { args: { icon: 'tabler-calendar-event', label: 'Diensten', value: 42, trend: '+12% vs vorige week', trendDir: 'up' } } +export const TrendDown: Story = { args: { icon: 'tabler-alert-triangle', label: 'No-shows', value: 3, trend: '-2 vs vorige week', trendDir: 'down' } } +``` + +- [ ] **Step 6: Typecheck, lint, commit** (same commands as Task 2 Step 6, paths for StatCard). + +```bash +git add apps/app/src/components-v2/shared/StatCard.vue apps/app/src/components-v2/shared/__tests__/StatCard.spec.ts apps/app/src/components-v2/shared/StatCard.stories.ts +git commit -m "feat(gui-v2): StatCard (PrimeVue Card KPI tile, replaces AppKpiCard) + story" +``` + +- [ ] **Step 7: Parity-check (Bert, record) then Step 8 `@visual` baseline** — identical workflow to Task 2 Steps 7–8 with `StatCard`. + +--- + +## Task 4: `PageHead.vue` + spec + story + +**Source:** `../crewli-starter/src/components/shared/PageHead.vue` (18 lines: `title,sub`, `#actions` slot; `.page-head` CSS 466–478 + responsive 1355–1360). **v2:** pure Tailwind flex, no PrimeVue (spec §8 "thin Tailwind flex"). + +**Files:** Create `PageHead.vue`; Test `__tests__/PageHead.spec.ts`; Story `PageHead.stories.ts`. + +- [ ] **Step 1: Failing test** + +```ts +// apps/app/src/components-v2/shared/__tests__/PageHead.spec.ts +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import PageHead from '@/components-v2/shared/PageHead.vue' + +describe('PageHead', () => { + it('renders the title and optional sub', () => { + const w = mount(PageHead, { props: { title: 'Evenementen', sub: '3 actief' } }) + expect(w.get('h1').text()).toBe('Evenementen') + expect(w.text()).toContain('3 actief') + }) + + it('omits the sub element when not provided', () => { + const w = mount(PageHead, { props: { title: 'Evenementen' } }) + expect(w.find('[data-sub]').exists()).toBe(false) + }) + + it('renders the #actions slot', () => { + const w = mount(PageHead, { props: { title: 'X' }, slots: { actions: '' } }) + expect(w.get('button').text()).toBe('New') + }) +}) +``` + +- [ ] **Step 2: Run — expect FAIL.** Run: `pnpm test -- PageHead`. + +- [ ] **Step 3: Implement `PageHead.vue`** + +```vue + + + +``` + +- [ ] **Step 4: Run — expect PASS.** Run: `pnpm test -- PageHead`. + +- [ ] **Step 5: Co-located story** + +```ts +// apps/app/src/components-v2/shared/PageHead.stories.ts +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import Button from 'primevue/button' +import PageHead from '@/components-v2/shared/PageHead.vue' + +const meta: Meta = { + title: 'Shared/PageHead', + component: PageHead, + tags: ['autodocs'], + argTypes: { title: { control: 'text' }, sub: { control: 'text' } }, +} +export default meta +type Story = StoryObj + +export const TitleOnly: Story = { args: { title: 'Evenementen' } } +export const WithSub: Story = { args: { title: 'Evenementen', sub: '3 actieve evenementen' } } +export const WithActions: Story = { + args: { title: 'Evenementen', sub: '3 actief' }, + render: args => ({ + components: { PageHead, Button }, + setup: () => ({ args }), + template: ``, + }), +} +``` + +- [ ] **Step 6: Typecheck, lint, commit** + +```bash +git add apps/app/src/components-v2/shared/PageHead.vue apps/app/src/components-v2/shared/__tests__/PageHead.spec.ts apps/app/src/components-v2/shared/PageHead.stories.ts +git commit -m "feat(gui-v2): PageHead (Tailwind flex title/sub/#actions) + story" +``` + +- [ ] **Step 7–8: Parity-check (Bert, record) + `@visual` baseline** — Task 2 Steps 7–8 workflow with `PageHead`. + +--- + +## Task 5: `StateBlock.vue` (built fresh) + spec + story — constraint #5 + +**No crewli-starter source.** Fresh composition (spec §8 + CLAUDE.md three-state): `Skeleton` (loading) · `Message` + retry `Button` (error) · empty `Card` + action `Button` (empty) · default slot (success). **Constraint #5: NO `@visual` baseline day one** — a self-baseline is tautological. Coverage = exhaustive Vitest across all states + transitions; visual baseline deferred to a Plans 4–5 follow-up after first real usage. + +**Files:** Create `StateBlock.vue`; Test `__tests__/StateBlock.spec.ts`; Story `StateBlock.stories.ts`. + +- [ ] **Step 1: Write the exhaustive failing test (all 3 states + transitions)** + +```ts +// apps/app/src/components-v2/shared/__tests__/StateBlock.spec.ts +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { defineComponent } from 'vue' +import StateBlock from '@/components-v2/shared/StateBlock.vue' + +const stubs = { + Skeleton: defineComponent({ name: 'Skeleton', template: `
` }), + Message: defineComponent({ name: 'Message', props: ['severity'], template: `
` }), + Button: defineComponent({ name: 'Button', props: ['label'], emits: ['click'], template: `` }), + Card: defineComponent({ name: 'Card', template: `
` }), +} +const mountSB = (props: Record, slots: Record = {}) => + mount(StateBlock, { props, slots, global: { stubs } }) + +describe('StateBlock', () => { + it('loading: renders Skeleton, nothing else', () => { + const w = mountSB({ state: 'loading' }, { default: '

data

' }) + expect(w.find('.skeleton-stub').exists()).toBe(true) + expect(w.text()).not.toContain('data') + }) + + it('error: renders Message + retry Button; emits retry on click', async () => { + const w = mountSB({ state: 'error', errorMessage: 'Mislukt' }) + expect(w.get('.message-stub').text()).toContain('Mislukt') + await w.get('.btn-stub').trigger('click') + expect(w.emitted('retry')).toHaveLength(1) + }) + + it('empty: renders empty Card + action Button; emits action on click', async () => { + const w = mountSB({ state: 'empty', emptyMessage: 'Niets hier', actionLabel: 'Maak aan' }) + expect(w.text()).toContain('Niets hier') + await w.get('.btn-stub').trigger('click') + expect(w.emitted('action')).toHaveLength(1) + }) + + it('success: renders the default slot, no state chrome', () => { + const w = mountSB({ state: 'success' }, { default: '

real content

' }) + expect(w.text()).toContain('real content') + expect(w.find('.skeleton-stub').exists()).toBe(false) + expect(w.find('.message-stub').exists()).toBe(false) + }) + + it('transition loading→success swaps chrome for slot content', async () => { + const w = mountSB({ state: 'loading' }, { default: '

loaded

' }) + expect(w.find('.skeleton-stub').exists()).toBe(true) + await w.setProps({ state: 'success' }) + expect(w.find('.skeleton-stub').exists()).toBe(false) + expect(w.text()).toContain('loaded') + }) + + it('transition error→loading clears the message', async () => { + const w = mountSB({ state: 'error', errorMessage: 'Mislukt' }) + expect(w.text()).toContain('Mislukt') + await w.setProps({ state: 'loading' }) + expect(w.find('.message-stub').exists()).toBe(false) + expect(w.find('.skeleton-stub').exists()).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run — expect FAIL.** Run: `pnpm test -- StateBlock`. + +- [ ] **Step 3: Implement `StateBlock.vue`** + +```vue + + + +``` + +- [ ] **Step 4: Run — expect PASS (6 tests).** Run: `pnpm test -- StateBlock`. + +- [ ] **Step 5: Co-located story** (note in story description: no @visual baseline by Plan 3 constraint #5) + +```ts +// apps/app/src/components-v2/shared/StateBlock.stories.ts +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import StateBlock from '@/components-v2/shared/StateBlock.vue' + +/** + * StateBlock stories. NOTE: per Plan 3 constraint #5 this component has + * NO Tier-4 @visual baseline yet (self-baseline is tautological). These + * stories exist for autodocs + a11y + manual review only. + */ +const meta: Meta = { + title: 'Shared/StateBlock', + component: StateBlock, + tags: ['autodocs'], + argTypes: { state: { control: 'inline-radio', options: ['loading', 'error', 'empty', 'success'] } }, +} +export default meta +type Story = StoryObj + +export const Loading: Story = { args: { state: 'loading' } } +export const Error: Story = { args: { state: 'error', errorMessage: 'Kon evenementen niet laden.' } } +export const Empty: Story = { args: { state: 'empty', emptyMessage: 'Nog geen evenementen.', actionLabel: 'Nieuw evenement' } } +export const Success: Story = { + args: { state: 'success' }, + render: args => ({ components: { StateBlock }, setup: () => ({ args }), template: `

Echte inhoud.

` }), +} +``` + +- [ ] **Step 6: Typecheck, lint, commit** + +```bash +git add apps/app/src/components-v2/shared/StateBlock.vue apps/app/src/components-v2/shared/__tests__/StateBlock.spec.ts apps/app/src/components-v2/shared/StateBlock.stories.ts +git commit -m "feat(gui-v2): StateBlock 3-state wrapper (exhaustive Vitest, no @visual per constraint #5)" +``` + +> **No parity-check, no `@visual` step for StateBlock** (constraint #5). The exhaustive Vitest spec is the lock. + +--- + +## Task 6: `TagsInput.vue` (re-implementation on PrimeVue AutoComplete) — constraint #2 + +**NOT a port.** crewli-starter source is **behavioural reference only**. Spec §8: PrimeVue `AutoComplete` `multiple` + `typeahead`. The prompt's "wraps PrimeVue Chip" note was incorrect — dropped. The five behavioural rules from the crewli-starter source become a Vitest checklist. + +**Files:** Create `TagsInput.vue`; Test `__tests__/TagsInput.spec.ts`; Story `TagsInput.stories.ts`. + +- [ ] **Step 1: Write the failing test — the 5 behavioural rules as a checklist** + +```ts +// apps/app/src/components-v2/shared/__tests__/TagsInput.spec.ts +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { defineComponent } from 'vue' +import TagsInput from '@/components-v2/shared/TagsInput.vue' + +/** + * AutoCompleteStub mirrors the PrimeVue AutoComplete contract TagsInput + * relies on: v-model (array in multiple mode), `complete` event, and + * exposing the typed query so we can drive add/dedupe logic. + */ +const AutoCompleteStub = defineComponent({ + name: 'AutoComplete', + props: { modelValue: { type: Array, default: () => [] }, suggestions: { type: Array, default: () => [] }, multiple: Boolean, typeahead: Boolean }, + emits: ['update:modelValue', 'complete'], + methods: { + addRaw(raw: string) { this.$emit('update:modelValue', [...(this.modelValue as string[]), raw]) }, + }, + template: `
`, +}) +const mountTI = (props: Record = {}) => + mount(TagsInput, { props, global: { stubs: { AutoComplete: AutoCompleteStub } } }) + +describe('TagsInput — 5 behavioural rules (crewli-starter reference)', () => { + it('(a) array model: modelValue is an array, update:modelValue emits an array', async () => { + const w = mountTI({ modelValue: ['rock'] }) + expect(w.get('.ac-stub').attributes('data-count')).toBe('1') + await w.vm.normalizeAndEmit(['rock', 'jazz']) + expect(w.emitted('update:modelValue')![0][0]).toEqual(['rock', 'jazz']) + }) + + it('(b) lowercase-dedupe: mixed-case duplicates collapse to one lowercase entry', async () => { + const w = mountTI({ modelValue: ['rock'] }) + await w.vm.normalizeAndEmit(['rock', 'ROCK', 'Rock', 'jazz']) + expect(w.emitted('update:modelValue')!.at(-1)![0]).toEqual(['rock', 'jazz']) + }) + + it('(c) Enter or comma adds (separator handling in onComplete query)', async () => { + const w = mountTI({ modelValue: [] }) + expect(w.vm.splitQuery('rock,jazz')).toEqual(['rock', 'jazz']) + expect(w.vm.splitQuery('rock\n')).toEqual(['rock']) + }) + + it('(d) Backspace-remove last is delegated to AutoComplete multiple (chip removal) — model shrinks', async () => { + const w = mountTI({ modelValue: ['rock', 'jazz'] }) + await w.vm.normalizeAndEmit(['rock']) + expect(w.emitted('update:modelValue')!.at(-1)![0]).toEqual(['rock']) + }) + + it('(e) 5-suggestion cap: visibleSuggestions never exceeds 5 filtered, dedup-against-model', () => { + const w = mountTI({ modelValue: ['rock'], suggestions: ['rock', 'rockabilly', 'rocksteady', 'rock-n-roll', 'rockpool', 'rockford', 'rocketry'] }) + const out = w.vm.filterSuggestions('rock') + expect(out.length).toBeLessThanOrEqual(5) + expect(out).not.toContain('rock') // already in model + }) +}) +``` + +- [ ] **Step 2: Run — expect FAIL.** Run: `pnpm test -- TagsInput`. + +- [ ] **Step 3: Implement `TagsInput.vue` (AutoComplete multiple + typeahead)** + +```vue + + + +``` + +- [ ] **Step 4: Run — expect PASS (5 tests).** Run: `pnpm test -- TagsInput`. + +- [ ] **Step 5: Co-located story** + +```ts +// apps/app/src/components-v2/shared/TagsInput.stories.ts +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' +import TagsInput from '@/components-v2/shared/TagsInput.vue' + +const meta: Meta = { + title: 'Shared/TagsInput', + component: TagsInput, + tags: ['autodocs'], +} +export default meta +type Story = StoryObj + +function model(initial: string[], suggestions: string[]): Story['render'] { + return () => ({ + components: { TagsInput }, + setup() { const tags = ref(initial); return { tags, suggestions } }, + template: `
{{ tags }}
`, + }) +} +export const Empty: Story = { render: model([], ['rock', 'jazz', 'techno', 'house', 'ambient', 'drum-n-bass']) } +export const Prefilled: Story = { render: model(['rock', 'jazz'], ['rock', 'jazz', 'techno', 'house', 'ambient']) } +``` + +- [ ] **Step 6: Typecheck, lint, commit** + +```bash +git add apps/app/src/components-v2/shared/TagsInput.vue apps/app/src/components-v2/shared/__tests__/TagsInput.spec.ts apps/app/src/components-v2/shared/TagsInput.stories.ts +git commit -m "feat(gui-v2): TagsInput re-impl on PrimeVue AutoComplete (5 behavioural rules) + story" +``` + +- [ ] **Step 7: Parity-check (Bert) — criterion is "coherent in Aura/teal", NOT pixel-match** (constraint #2). Record pass. Then Step 8 `@visual` baseline (the v2 rendering is the baseline; this is allowed here because the component is intentionally a re-design, unlike StateBlock which has no stable target at all). + +```bash +git add apps/app/tests/playwright-ct/v2/__screenshots__ +git commit -m "test(gui-v2): TagsInput @visual baseline (Aura-coherence parity recorded)" +``` + +--- + +## Task 7: `EnergyDots.vue` + spec + story + +**Source:** `../crewli-starter/src/components/shared/EnergyDots.vue` (17 lines: `value,lg`; `.energy-dots` CSS 1982–1991, level colours). **v2:** minimal scoped CSS justified (spec §8 — no PrimeVue primitive; `Rating` is stars). + +**Files:** Create `EnergyDots.vue`; Test `__tests__/EnergyDots.spec.ts`; Story `EnergyDots.stories.ts`. + +- [ ] **Step 1: Failing test** + +```ts +// apps/app/src/components-v2/shared/__tests__/EnergyDots.spec.ts +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import EnergyDots from '@/components-v2/shared/EnergyDots.vue' + +describe('EnergyDots', () => { + it('always renders 5 dots; `on` count equals value', () => { + const w = mount(EnergyDots, { props: { value: 3 } }) + expect(w.findAll('.d')).toHaveLength(5) + expect(w.findAll('.d.on')).toHaveLength(3) + }) + it('exposes data-energy for level colouring and lg class when lg', () => { + const w = mount(EnergyDots, { props: { value: 5, lg: true } }) + expect(w.get('[data-energy]').attributes('data-energy')).toBe('5') + expect(w.get('.energy-dots').classes()).toContain('lg') + }) + it('clamps value into 0..5', () => { + expect(mount(EnergyDots, { props: { value: 9 } }).findAll('.d.on')).toHaveLength(5) + expect(mount(EnergyDots, { props: { value: -2 } }).findAll('.d.on')).toHaveLength(0) + }) +}) +``` + +- [ ] **Step 2: Run — expect FAIL.** Run: `pnpm test -- EnergyDots`. + +- [ ] **Step 3: Implement `EnergyDots.vue`** (scoped CSS justified; level colours via Aura tokens) + +```vue + + + + + +``` + +- [ ] **Step 4: Run — expect PASS (3 tests).** Run: `pnpm test -- EnergyDots`. + +- [ ] **Step 5: Co-located story** + +```ts +// apps/app/src/components-v2/shared/EnergyDots.stories.ts +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import EnergyDots from '@/components-v2/shared/EnergyDots.vue' + +const meta: Meta = { + title: 'Shared/EnergyDots', + component: EnergyDots, + tags: ['autodocs'], + argTypes: { value: { control: { type: 'range', min: 0, max: 5, step: 1 } }, lg: { control: 'boolean' } }, +} +export default meta +type Story = StoryObj +export const Level3: Story = { args: { value: 3 } } +export const Level5: Story = { args: { value: 5 } } +export const Large: Story = { args: { value: 4, lg: true } } +``` + +- [ ] **Step 6: Typecheck, lint, commit** + +```bash +git add apps/app/src/components-v2/shared/EnergyDots.vue apps/app/src/components-v2/shared/__tests__/EnergyDots.spec.ts apps/app/src/components-v2/shared/EnergyDots.stories.ts +git commit -m "feat(gui-v2): EnergyDots 5-dot meter (scoped CSS justified per §8) + story" +``` + +- [ ] **Step 7–8: Parity-check (Bert, record) + `@visual` baseline** — Task 2 Steps 7–8 with `EnergyDots`. + +--- + +## Task 8: `EnergyPicker.vue` + spec + story + +**Source:** `../crewli-starter/src/components/music/EnergyPicker.vue` (22 lines: `modelValue`, `update:modelValue`, click-to-toggle-zero). **v2:** interactive sibling of EnergyDots, same scoped-CSS justification. + +**Files:** Create `EnergyPicker.vue`; Test `__tests__/EnergyPicker.spec.ts`; Story `EnergyPicker.stories.ts`. + +- [ ] **Step 1: Failing test** + +```ts +// apps/app/src/components-v2/shared/__tests__/EnergyPicker.spec.ts +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import EnergyPicker from '@/components-v2/shared/EnergyPicker.vue' + +describe('EnergyPicker', () => { + it('renders 5 buttons; clicking i emits i', async () => { + const w = mount(EnergyPicker, { props: { modelValue: 0 } }) + const btns = w.findAll('button') + expect(btns).toHaveLength(5) + await btns[2].trigger('click') + expect(w.emitted('update:modelValue')![0]).toEqual([3]) + }) + it('clicking the current value toggles back to 0 (crewli-starter parity)', async () => { + const w = mount(EnergyPicker, { props: { modelValue: 3 } }) + await w.findAll('button')[2].trigger('click') + expect(w.emitted('update:modelValue')![0]).toEqual([0]) + }) + it('marks buttons up to modelValue as on', () => { + const w = mount(EnergyPicker, { props: { modelValue: 2 } }) + expect(w.findAll('button.on')).toHaveLength(2) + }) +}) +``` + +- [ ] **Step 2: Run — expect FAIL.** Run: `pnpm test -- EnergyPicker`. + +- [ ] **Step 3: Implement `EnergyPicker.vue`** + +```vue + + + + + +``` + +- [ ] **Step 4: Run — expect PASS (3 tests).** Run: `pnpm test -- EnergyPicker`. + +- [ ] **Step 5: Co-located story** + +```ts +// apps/app/src/components-v2/shared/EnergyPicker.stories.ts +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' +import EnergyPicker from '@/components-v2/shared/EnergyPicker.vue' + +const meta: Meta = { title: 'Shared/EnergyPicker', component: EnergyPicker, tags: ['autodocs'] } +export default meta +type Story = StoryObj +export const Interactive: Story = { + render: () => ({ + components: { EnergyPicker }, + setup() { const v = ref(0); return { v } }, + template: `
value: {{ v }}
`, + }), +} +``` + +- [ ] **Step 6: Typecheck, lint, commit** + +```bash +git add apps/app/src/components-v2/shared/EnergyPicker.vue apps/app/src/components-v2/shared/__tests__/EnergyPicker.spec.ts apps/app/src/components-v2/shared/EnergyPicker.stories.ts +git commit -m "feat(gui-v2): EnergyPicker interactive 5-step (crewli-starter port) + story" +``` + +- [ ] **Step 7–8: Parity-check (Bert, record) + `@visual` baseline** — Task 2 Steps 7–8 with `EnergyPicker`. + +--- + +## Task 9: `DraggableBlock.vue` (abstraction; gated on Task A2) + spec + stories + CT + +**MUST NOT start until Task A2 is committed.** Implements the spec §7.1 canonical contract using the A2-reconciled PointerEvent drag model. Internals: PrimeVue `Tag` + `ProgressBar`; 2-line flex in scoped CSS (spec §7.1 — justified bespoke). Parent owns all positioning. + +**Files:** Create `DraggableBlock.vue`; Test `__tests__/DraggableBlock.spec.ts`; CT `apps/app/tests/playwright-ct/v2/draggableblock.ct.spec.ts`; Story `DraggableBlock.stories.ts`. + +- [ ] **Step 1: Confirm the A2 gate** + +Run: `git log --oneline --grep "Task A2" -1` → expect the A2 reconciliation commit. If absent, STOP — Task 9 is gated. + +- [ ] **Step 2: Write the failing Vitest spec (props/render/click-vs-drag discrimination)** + +```ts +// apps/app/src/components-v2/shared/__tests__/DraggableBlock.spec.ts +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { defineComponent } from 'vue' +import DraggableBlock from '@/components-v2/shared/DraggableBlock.vue' + +const stubs = { + Tag: defineComponent({ name: 'Tag', props: ['value', 'severity'], template: `{{ value }}` }), + ProgressBar: defineComponent({ name: 'ProgressBar', props: ['value'], template: `
` }), +} +const mountDB = (props: Record) => + mount(DraggableBlock, { props, global: { stubs }, attachTo: document.body }) + +describe('DraggableBlock', () => { + it('renders line1Left text + tag and line2Right progress', () => { + const w = mountDB({ + line1Left: { text: 'DJ Foo', tag: { label: 'confirmed', severity: 'success' } }, + line2Right: { progress: 42 }, + }) + expect(w.text()).toContain('DJ Foo') + expect(w.get('.tag-stub').attributes('data-sev')).toBe('success') + expect(w.get('.pb-stub').attributes('data-val')).toBe('42') + }) + + it('density prop maps to the row-height data attribute', () => { + expect(mountDB({ line1Left: { text: 'x' }, density: 'compact' }).get('[data-density]').attributes('data-density')).toBe('compact') + expect(mountDB({ line1Left: { text: 'x' }, density: 'comfy' }).get('[data-density]').attributes('data-density')).toBe('comfy') + }) + + it('pointer without movement → click emitted, no dragstart/dragend', async () => { + const w = mountDB({ line1Left: { text: 'x' } }) + const el = w.get('[data-density]') + await el.trigger('pointerdown', { button: 0, clientX: 10, clientY: 10, pointerId: 1 }) + await el.trigger('pointerup', { clientX: 10, clientY: 10, pointerId: 1 }) + expect(w.emitted('click')).toHaveLength(1) + expect(w.emitted('dragstart')).toBeUndefined() + expect(w.emitted('dragend')).toBeUndefined() + }) + + it('pointer past 3px threshold → dragstart once, dragend with delta, no click', async () => { + const w = mountDB({ line1Left: { text: 'x' } }) + const el = w.get('[data-density]') + await el.trigger('pointerdown', { button: 0, clientX: 0, clientY: 0, pointerId: 1 }) + await el.trigger('pointermove', { clientX: 20, clientY: 8, pointerId: 1 }) + await el.trigger('pointerup', { clientX: 20, clientY: 8, pointerId: 1 }) + expect(w.emitted('dragstart')).toHaveLength(1) + expect(w.emitted('dragend')![0]).toEqual([{ x: 20, y: 8 }]) + expect(w.emitted('click')).toBeUndefined() + }) +}) +``` + +- [ ] **Step 3: Run — expect FAIL.** Run: `pnpm test -- DraggableBlock`. + +- [ ] **Step 4: Implement `DraggableBlock.vue` (PointerEvent model from A2; parent owns positioning)** + +```vue + + + + + +``` + +- [ ] **Step 5: Run — expect PASS (4 tests).** Run: `pnpm test -- DraggableBlock`. + +- [ ] **Step 6: Write the Tier-2 CT drag-emit interaction test (real Chromium pointer)** + +```ts +// apps/app/tests/playwright-ct/v2/draggableblock.ct.spec.ts +import { expect, test } from '@playwright/experimental-ct-vue' +import DraggableBlock from '@/components-v2/shared/DraggableBlock.vue' + +test('drag past threshold emits dragstart then dragend with delta @ct', async ({ mount }) => { + const events: string[] = [] + const component = await mount(DraggableBlock, { + props: { line1Left: { text: 'DJ Foo' } }, + on: { + dragstart: () => events.push('dragstart'), + dragend: (d: { x: number, y: number }) => events.push(`dragend:${d.x},${d.y}`), + click: () => events.push('click'), + }, + }) + const box = (await component.boundingBox())! + await component.page().mouse.move(box.x + 10, box.y + 10) + await component.page().mouse.down() + await component.page().mouse.move(box.x + 40, box.y + 22) + await component.page().mouse.up() + expect(events[0]).toBe('dragstart') + expect(events.some(e => e.startsWith('dragend:'))).toBe(true) + expect(events).not.toContain('click') +}) + +test('static states render for @visual baseline @visual', async ({ mount }) => { + const component = await mount(DraggableBlock, { + props: { + line1Left: { text: 'DJ Foo', tag: { label: 'confirmed', severity: 'success' } }, + line1Right: { pill: 'Main' }, + line2Left: '21:00–22:00', + line2Right: { progress: 60 }, + selected: true, + }, + }) + await expect(component).toHaveScreenshot('draggableblock-selected.png') +}) +``` + +- [ ] **Step 7: Run CT — expect PASS.** Run: `pnpm test:component -- draggableblock`. + +- [ ] **Step 8: Write the co-located stories (incl. A2 retrofit-prove ArtistBlock + CueBlock)** + +```ts +// apps/app/src/components-v2/shared/DraggableBlock.stories.ts +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import DraggableBlock from '@/components-v2/shared/DraggableBlock.vue' + +const meta: Meta = { + title: 'Shared/DraggableBlock', + component: DraggableBlock, + tags: ['autodocs'], +} +export default meta +type Story = StoryObj + +/** A2 retrofit-prove: TimetableGrid performance block via the §7.1 contract. */ +export const ArtistBlock: Story = { + args: { + line1Left: { text: 'DJ Foo', tag: { label: 'confirmed', severity: 'success' } }, + line1Right: { pill: 'Main Stage' }, + line2Left: '21:00–22:00', + line2Right: { progress: 60 }, + }, +} +/** A2 retrofit-prove: CueTimelineEditor cue block via the §7.1 contract. */ +export const CueBlock: Story = { + args: { + line1Left: { text: 'Intro VT', tag: { label: 'video', severity: 'info' } }, + line1Right: { pill: 'PGM' }, + line2Left: '00:30', + line2Right: null, + }, +} +export const WithProgress: Story = { args: { line1Left: { text: 'Set' }, line2Right: { progress: 80 } } } +export const Selected: Story = { args: { line1Left: { text: 'Selected' }, selected: true } } +export const Dragging: Story = { args: { line1Left: { text: 'Dragging' }, dragging: true } } +export const Compact: Story = { args: { line1Left: { text: 'Compact' }, density: 'compact' } } +export const Comfy: Story = { args: { line1Left: { text: 'Comfy' }, density: 'comfy' } } +``` + +- [ ] **Step 9: Typecheck, lint, commit** + +```bash +git add apps/app/src/components-v2/shared/DraggableBlock.vue apps/app/src/components-v2/shared/__tests__/DraggableBlock.spec.ts apps/app/tests/playwright-ct/v2/draggableblock.ct.spec.ts apps/app/src/components-v2/shared/DraggableBlock.stories.ts +git commit -m "feat(gui-v2): DraggableBlock §7.1 abstraction (PointerEvent drag, A2-reconciled) + CT + stories" +``` + +- [ ] **Step 10: Parity-check (Bert, record) + `@visual` baseline (static states only — drag is covered by the CT @ct test, not a screenshot)** + +Run: `pnpm test:visual:update -- draggableblock` then `pnpm test:visual -- draggableblock` → PASS. + +```bash +git add apps/app/tests/playwright-ct/v2/__screenshots__ +git commit -m "test(gui-v2): DraggableBlock @visual baseline (ArtistBlock+CueBlock parity recorded)" +``` + +--- + +## Task 10: Cleanup (a) — migrate Plan 2's 6 stories to co-located + +`_helpers.ts` stays at `apps/app/src/stories/v2/_helpers.ts` (decision #3 — it's a story-util, not a story; relocating it is a separate refactor listed in Plans 4–5). The 6 `.stories.ts` move beside their `.vue`; their `_helpers` import is rewritten to the absolute alias `@/stories/v2/_helpers` so it survives the move. + +**Files moved:** `src/stories/v2/{AppDialog→components-v2/shared, AppSidebar→components-v2/layout, AppTopbar→components-v2/layout, RightDrawer→components-v2/layout, SidebarNav→components-v2/layout, WorkspaceSwitcher→components-v2/layout}.stories.ts` + +- [ ] **Step 1: Move AppDialog story (the one in `shared/`)** + +```bash +git mv apps/app/src/stories/v2/AppDialog.stories.ts apps/app/src/components-v2/shared/AppDialog.stories.ts +``` + +Edit the import in `apps/app/src/components-v2/shared/AppDialog.stories.ts`: `import AppDialog from '@/components-v2/shared/AppDialog.vue'` already alias-based — confirm no relative `_helpers` import (AppDialog story has none). Run: `pnpm test -- AppDialog` and `pnpm exec vue-tsc --noEmit` → expect PASS / clean. + +- [ ] **Step 2: Move the 5 layout stories** + +```bash +git mv apps/app/src/stories/v2/AppSidebar.stories.ts apps/app/src/components-v2/layout/AppSidebar.stories.ts +git mv apps/app/src/stories/v2/AppTopbar.stories.ts apps/app/src/components-v2/layout/AppTopbar.stories.ts +git mv apps/app/src/stories/v2/RightDrawer.stories.ts apps/app/src/components-v2/layout/RightDrawer.stories.ts +git mv apps/app/src/stories/v2/SidebarNav.stories.ts apps/app/src/components-v2/layout/SidebarNav.stories.ts +git mv apps/app/src/stories/v2/WorkspaceSwitcher.stories.ts apps/app/src/components-v2/layout/WorkspaceSwitcher.stories.ts +``` + +- [ ] **Step 3: Rewrite `_helpers` imports to the stable alias** + +In each moved layout story that imports helpers, change any `from './_helpers'` to `from '@/stories/v2/_helpers'`. Verify none remain relative: + +Run: `grep -rn "_helpers" apps/app/src/components-v2/` → every hit must read `@/stories/v2/_helpers`. Expected: no `./_helpers` or `../`. + +- [ ] **Step 4: Verify discovery + green suite** + +Run: `pnpm exec vue-tsc --noEmit` → clean. Run: `pnpm test` → existing count not reduced. Run: `pnpm build-storybook 2>&1 | tail -5` (or `pnpm exec storybook build`) → the 6 stories still resolve under the `../src/**/*.stories.ts` glob (now via the co-located path). + +- [ ] **Step 5: Commit** + +```bash +git add apps/app/src/components-v2 apps/app/src/stories/v2 +git commit -m "refactor(gui-v2): cleanup(a) — co-locate Plan 2's 6 stories per amended §6 (_helpers stays)" +``` + +--- + +## Task 11: Cleanup (b) — AppTopbar wraps PrimeVue `Menubar` (RFC AD-3) + +RFC-WS-FRONTEND-PRIMEVUE AD-3: *"…Menubar for the top bar…"*. Current `AppTopbar.vue` is hand-rolled (Breadcrumb+Menu+Popover, no Menubar). Refactor so the top-bar chrome is a `` whose `#start`/`#end` slots host the existing breadcrumb (start) and the existing search/notifications/user-menu cluster (end). Behaviour and existing tests' intent are preserved. + +**Files:** Modify `apps/app/src/components-v2/layout/AppTopbar.vue`; Modify `apps/app/src/components-v2/layout/__tests__/AppTopbar.spec.ts`. + +- [ ] **Step 1: FULL read of `apps/app/src/components-v2/layout/AppTopbar.vue` BEFORE any edit** + +This is a *wrap, don't rewrite* refactor — the plan deliberately does not inline AppTopbar's ~200 lines (fabricating line ranges unread would be a worse placeholder than this instruction). Therefore Step 1 is mandatory and blocking: open and read `apps/app/src/components-v2/layout/AppTopbar.vue` in full, top to bottom, before touching anything. Identify the three regions that will move into Menubar slots: the `` block (→ `#start`), and the search `InputText` / notifications `OverlayBadge` / user `Avatar`+`Menu` cluster (→ `#end`). Note every `', + ) + expect(errs).toHaveLength(0) +}) + +it('forbids components-v2/shared → pages-v2 (no upward import)', async () => { + const errs = await boundaryErrors( + 'src/components-v2/shared/StatusTag.vue', + '', + ) + expect(errs.length).toBeGreaterThan(0) +}) + +it('forbids components-v2/shared → layouts (no upward import)', async () => { + const errs = await boundaryErrors( + 'src/components-v2/shared/StatusTag.vue', + '', + ) + expect(errs.length).toBeGreaterThan(0) +}) +``` + +- [ ] **Step 3: Run — expect PASS** (repointed + 3 new cases). Run: `pnpm test -- boundaries-v2`. + +- [ ] **Step 4: Delete the stub** + +```bash +git rm apps/app/src/components-v2/shared/X.vue +``` + +- [ ] **Step 5: Full green gate + commit** + +Run: `pnpm test -- boundaries-v2` → PASS (no X.vue resolution error — the resolver now hits real StatusTag.vue). Run: `pnpm exec vue-tsc --noEmit` → clean. + +```bash +git add apps/app/tests/unit/boundaries-v2.spec.ts +git commit -m "refactor(gui-v2): delete X.vue stub, repoint 2 boundary refs to StatusTag, add shared/* regression locks" +``` + +--- + +## Definition of Done (Plan 3) + +- [ ] `apps/app/src/components-v2/shared/statusSeverity.ts` exists, seeded **verbatim** from amended spec §8 (no reinterpretation). +- [ ] `tests/unit/utils/statusSeverity.consistency.spec.ts` passes **both** directions (completeness + no-phantoms) against the 5 live enums (spec §8.X). +- [ ] All 8 components exist under `apps/app/src/components-v2/shared/` with a co-located `__tests__/*.spec.ts` and a co-located `*.stories.ts` (amended §6). +- [ ] DraggableBlock built only after Task A2's reconciliation doc committed; its Vitest spec proves click-vs-drag discrimination and `dragend` delta; CT `@ct` test proves real-pointer drag emits; `ArtistBlock` + `CueBlock` retrofit-prove stories exist (constraint #4). +- [ ] StateBlock has an exhaustive Vitest spec (3 states + 2 transitions) and **no `@visual` baseline** (constraint #5); its story notes this. +- [ ] TagsInput is the `AutoComplete` re-impl; the 5 behavioural rules pass as a Vitest checklist; parity criterion recorded as "Aura-coherent", not pixel-match (constraint #2). +- [ ] Theme-alignment decision (Task A1) is documented; parity harness normalises `.dark` vs `[data-theme="dark"]` (constraint #6). +- [ ] Parity-check performed by Bert and **recorded** (screenshot evidence committed) per component before its `@visual` baseline; StateBlock exempt (constraint #8). +- [ ] `X.vue` deleted **and both** `boundaries-v2.spec.ts` references (L35 + L65) repointed to `StatusTag.vue`; 3 shared/* regression-lock cases added (constraint #7). +- [ ] Cleanup (a): the 6 Plan 2 stories are co-located; `_helpers.ts` unchanged at `src/stories/v2/_helpers.ts`; no relative `_helpers` imports remain; `src/stories/v2/` contains only `_helpers.ts`. +- [ ] Cleanup (b): `AppTopbar.vue` renders a PrimeVue ``; its Plan 2 `@visual` baseline updated with a recorded parity-pass (visually identical). +- [ ] `pnpm exec vue-tsc --noEmit` clean; **no `any`** anywhere in new code. +- [ ] `pnpm lint` clean (scoped — whole-codebase formatter OOM is the known Plan 1 constraint; lint the touched paths). +- [ ] `pnpm test` green; existing test count **not reduced** (new specs are additive). +- [ ] `pnpm test:component` green (DraggableBlock CT). +- [ ] `pnpm test:visual` green for every component with a baseline (all except StateBlock). +- [ ] `pnpm exec vite build` green **and the JS bundle-size delta reported** in the final commit/PR body (expected small; measured, not assumed — constraint #9). + +--- + +## Open questions + +> Open question (decided during implementation, not now): +> Spec §8 specifies runtime fallback = info + dev-warn (this plan +> implements that verbatim per constraint #1). Claude Chat suggested +> secondary + dev-warn for visual distinguishability between "real +> info" and "missing mapping". Non-blocking — §8.X bidirectional +> test makes fallback unreachable in a passing build. Decide during +> Plan 3 implementation. If `secondary` is chosen, a §8 spec +> amendment precedes the map change. + +--- + +## Subsequent plans (authored after Plan 3 lands) + +- **Plan 4 — Template layer:** `ListTemplate` (PageHead + SmartFilterBar + DataTable + StateBlock), `FormTemplate` (PageHead + Form/FormField + footer), `DetailTemplate` (PageHead + Tabs + RightDrawer hook), `DashboardTemplate` (StatCard grid + widget slots), with StateBlock integrated. Includes the **deferred StateBlock `@visual` baseline** (constraint #5) once it has first real template usage. +- **Plan 5 — Storybook catalog + toolbar:** global theme/density toolbar decorators in `.storybook/preview.ts`; the ~80-component PrimeVue standard catalog (centralised under `stories/` per amended §6 class 2); Foundations stories. CT specs stay standalone. +- **Optional follow-up (decision #3):** relocate `src/stories/v2/_helpers.ts` to a non-story test-support location (e.g. `tests/support/storyHelpers.ts`) and repoint the 6 co-located stories. Out of Plan 3 scope deliberately — it is a pure refactor with no user-facing or contract change and would bloat the cleanup. + +Then the Smart-Filter sub-sprint, then Page-1 (events list), then the remaining page trees, per spec §10.