# 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.