Files
crewli/dev-docs/superpowers/plans/2026-05-17-gui-redesign-tier1-primitives.md
bert.hausmans 280776fcda docs(plan): add Plan 3 (Tier-1 primitives + DraggableBlock)
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 <noreply@anthropic.com>
2026-05-17 22:49:58 +02:00

82 KiB
Raw Blame History

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 <script setup lang="ts">, PrimeVue 4 (Tag, Card, Skeleton, Message, Button, AutoComplete, ProgressBar) + Aura preset (@/plugins/primevue/theme.ts), Tailwind v4, @vue/test-utils + Vitest (Tier-1), Playwright Component Testing (Tier-2/Tier-4 @visual), Storybook CSF3 (co-located, auto-discovered via ../src/**/*.stories.ts), eslint-plugin-boundaries 6.0.2, pnpm.

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 and Plan 1 live.

Source spec: dev-docs/superpowers/specs/2026-05-15-crewli-starter-gui-redesign-design.md (amended commit ae0bd2da — §6 stories-placement, §8 severity table, §8.X enforcement).

Scope: Plan 3 of 5. Delivers spec §9 deliverable 3 (Tier-1 primitives + DraggableBlock) plus §6/§8.X enforcement and two carried-forward Plan 2 cleanups from commit ae0bd2da. Plans 45 (template layer, Storybook catalog + toolbar) are outlined at the end.

Ground-truth inputs (Phase A audit, confirmed 2026-05-17):

  • crewli-starter is the sibling working dir ../crewli-starter (abs /Users/berthausmans/Documents/Development/crewli-starter).
  • Primitive sources exist: shared/{StatusTag,StatCard,PageHead,EnergyDots}.vue, music/{EnergyPicker,TagsInput}.vue. StateBlock has no source. DraggableBlock is inline markup in timetable/TimetableGrid.vue, not a component.
  • Only DraggableBlock has a §7-pinned prop contract (§7.1). The other six derive 1:1 from their crewli-starter source (TagsInput excepted — re-impl per §8).
  • Storybook glob is ../src/**/*.stories.ts → co-located stories are auto-discovered (cleanup (a) needs no .storybook/main.ts change).
  • components-v2 is one boundaries zone (no shared/ sub-zone) already allowed types,utils,lib,composables,composables-forms,stores,components-v2,components-foundation and forbidden v1/pages/layouts. Constraint #7 is therefore satisfied by the existing zone; Task 12 adds regression-lock tests, not a new zone (audit-before-assume deviation from the prompt's framing — documented in Task 12).
  • X.vue is referenced in two boundaries-v2.spec.ts cases (L35 allows pages-v2 → components-v2; L65 forbids v1 components → components-v2). Both repoint to StatusTag.vue.
  • AppTopbar.vue imports Avatar,Breadcrumb,InputText,Menu,OverlayBadge,Popover — no Menubar (RFC AD-3 deviation, cleanup (b)).
  • Theme divergence: crewli-starter dark selector [data-theme="dark"] + hardcoded dark primary #1eafb1; apps/app dark selector .dark (RFC AD-2) + Aura token ramp {primary.400}. Reconciled in Task A1.
  • Test commands (pnpm): pnpm test (Vitest run), pnpm test:component (Playwright CT), pnpm test:visual (CT @visual), pnpm test:visual:update (update baselines), pnpm exec vue-tsc --noEmit (typecheck), pnpm lint (scoped — whole-codebase formatter OOM is a known Plan 1 constraint), pnpm exec vite build.
  • Icon.vue props: name: string (required), size?: number|string → maps to @iconify/vue <Icon :icon :width :height>. Crewli-starter uses @iconify/vue directly with icon="tabler:x"; v2 ports use @/components/Icon.vue with the dash form name="tabler-x" (AppDialog precedent).
  • Vitest mount pattern: @vue/test-utils mount, Pinia via global.plugins:[createPinia()], PrimeVue + Icon stubbed, test files co-located in __tests__/.

CSS-translation token map (crewli-starter main.css var → apps/app Aura token, per AppDialog.vue precedent):

crewli-starter apps/app PrimeVue token
--fg --p-text-color
--fg-muted --p-text-muted-color
--surface --p-content-background
--surface-alt --p-content-hover-background
--border --p-content-border-color
--p-primary --p-primary-color
--radius-sm / --radius / --radius-lg --p-border-radius-sm / --p-border-radius / --p-border-radius-lg
--success / --danger / --warn / --info (text) --p-green-600 / --p-red-600 / --p-amber-600 / --p-sky-600 (Aura palette; verify shade against theme.ts)
--p-primary-tint color-mix(in srgb, var(--p-primary-color) 12%, transparent)

Semantic status colours are never hand-mapped on StatusTag — it delegates to PrimeVue <Tag :severity>, which themes correctly in both modes. The palette tokens above are only for StatCard trend and EnergyDots energy levels.


File Structure (Plan 3)

Created:

  • apps/app/src/components-v2/shared/statusSeverity.ts — status→severity SoT map (spec §8)
  • apps/app/tests/unit/utils/statusSeverity.consistency.spec.ts — bidirectional §8.X enforcement test
  • apps/app/src/components-v2/shared/StatusTag.vue + __tests__/StatusTag.spec.ts + StatusTag.stories.ts
  • apps/app/src/components-v2/shared/StatCard.vue + __tests__/StatCard.spec.ts + StatCard.stories.ts
  • apps/app/src/components-v2/shared/PageHead.vue + __tests__/PageHead.spec.ts + PageHead.stories.ts
  • apps/app/src/components-v2/shared/StateBlock.vue + __tests__/StateBlock.spec.ts + StateBlock.stories.ts
  • apps/app/src/components-v2/shared/TagsInput.vue + __tests__/TagsInput.spec.ts + TagsInput.stories.ts
  • apps/app/src/components-v2/shared/EnergyDots.vue + __tests__/EnergyDots.spec.ts + EnergyDots.stories.ts
  • apps/app/src/components-v2/shared/EnergyPicker.vue + __tests__/EnergyPicker.spec.ts + EnergyPicker.stories.ts
  • apps/app/src/components-v2/shared/DraggableBlock.vue + __tests__/DraggableBlock.spec.ts + DraggableBlock.stories.ts
  • apps/app/tests/playwright-ct/v2/draggableblock.ct.spec.ts — Tier-2 drag-emit interaction test (@visual baseline for static states only)
  • dev-docs/superpowers/plans/2026-05-17-gui-redesign-tier1-primitives-DRAGGABLEBLOCK-CONTRACT.md — Task A2 reconciliation deliverable

Modified:

  • apps/app/tests/unit/boundaries-v2.spec.ts — X.vue → StatusTag.vue (L35 & L65) + shared/* regression-lock cases
  • apps/app/src/components-v2/layout/AppTopbar.vue — refactor to wrap PrimeVue Menubar (RFC AD-3)
  • apps/app/src/components-v2/layout/__tests__/AppTopbar.spec.ts — assertions updated for Menubar
  • Story relocations (cleanup (a)): src/stories/v2/{AppDialog,AppSidebar,AppTopbar,RightDrawer,SidebarNav,WorkspaceSwitcher}.stories.ts → co-located beside their .vue; imports of _helpers.ts rewritten to the unchanged @/stories/v2/_helpers.ts path

Deleted:

  • apps/app/src/components-v2/shared/X.vue (boundary-test stub, TODO TECH-WS-GUI-REDESIGN)

Phase A — Reconciliation gates (NO component code yet)

Task A1: Theme-alignment decision (constraint #6)

Files: Create section in the contract doc; no code.

  • Step 1: Document the divergence and the decision

Append to dev-docs/superpowers/plans/2026-05-17-gui-redesign-tier1-primitives-DRAGGABLEBLOCK-CONTRACT.md a "Theme alignment" section stating verbatim:

crewli-starter: darkModeSelector: '[data-theme="dark"]', dark primary hardcoded #1eafb1, bespoke surface block. apps/app: darkModeSelector: '.dark' (RFC-WS-FRONTEND-PRIMEVUE AD-2, matches Vuexy), dark primary {primary.400} Aura ramp, RFC Appendix B surface tokens. Decision (option b — normalise at the harness): v2 components NEVER hardcode a dark selector or a semantic hex; they consume var(--p-*) Aura tokens only, which resolve correctly under apps/app's .dark. The parity harness renders the crewli-starter reference under [data-theme="dark"] and the v2 component under .dark; the human parity-check compares rendered pixels, not selector strings. The #1eafb1 vs {primary.400} ramp delta is accepted (same teal family; RFC AD-2 owns the apps/app ramp) and explicitly recorded so a dark-mode parity diff is read as theme-by-design, not a component bug.

  • Step 2: Commit
git add dev-docs/superpowers/plans/2026-05-17-gui-redesign-tier1-primitives-DRAGGABLEBLOCK-CONTRACT.md
git commit -m "docs(plan): Plan 3 Task A1 — theme-alignment decision (accept .dark vs [data-theme] delta)"

Task A2: DraggableBlock dual-consumer contract reconciliation (constraint #4)

Files: dev-docs/superpowers/plans/2026-05-17-gui-redesign-tier1-primitives-DRAGGABLEBLOCK-CONTRACT.md

Spec §7.1 is canonical. The two consumers use different drag models (Phase A facts):

  • TimetableGrid (startDragMove, line 241): mousedownwindow mousemove/mouseup, 3px move threshold, parent computes snap/lane/stage from clientX/Y and px/min; emits dispatch({type}).

  • CueTimelineEditor (onCueDragStart, line 200): native HTML5 drag (draggable="true", dataTransfer.setData), .dragging class, drop via slot dragover; emits update.

  • Step 1: Write the field→slot reconciliation tables

Add to the contract doc two tables mapping each consumer's per-block data to the §7.1 generic slots:

TimetableGrid performance block → §7.1
  artist.name                → line1Left.text
  status tag (engagement)    → line1Left.tag {label, severity}
  genre                      → line1Right.pill
  capacity/conflict warn     → line1Right.tag {label:'!', severity:'danger'}
  blockTime(start,end)       → line2Left
  advanceCount done/total    → line2Right.progress  (0..1 → 0..100)
  selected (selectedId)      → selected
  drag.value!=null           → dragging
  baseRowHeight 56/64/76     → density compact|regular|comfy

CueTimelineEditor cue block → §7.1
  cue.label                  → line1Left.text
  cue.kind tag               → line1Left.tag {label, severity}
  cue.dest pill              → line1Right.pill
  (none)                     → line1Right.tag
  cue.time                   → line2Left
  (none)                     → line2Right  (null — no progress on cues)
  selectedCueId===cue.id     → selected
  drag?.id===cue.id          → dragging
  fixed 'regular'            → density
  • Step 2: Write the unified drag-model decision

Add verbatim:

Drag model = PointerEvents, parent owns positioning. DraggableBlock is presentational. On pointerdown (primary button) it calls setPointerCapture, tracks a 3px move threshold (TimetableGrid parity), emits dragstart: [e: PointerEvent] once threshold is crossed, and on pointerup/lostpointercapture emits dragend: [delta: { x: number; y: number }] (clientX/Y minus start). It performs zero snap/lane/px-min math. TimetableGrid keeps startDragMove's math but is driven by @dragstart/@dragend instead of its own mousedown. CueTimelineEditor drops HTML5 drag and adopts the same emits. click is emitted only when no drag occurred (threshold not crossed). vuedraggable is not used (wrong abstraction for free-position blocks; spec §7.1).

  • Step 3: Write the retrofit-prove requirement

Add verbatim:

Plan 3 is incomplete without a retrofit proof per consumer: DraggableBlock.stories.ts MUST include an ArtistBlock story (TimetableGrid usage expressed in the §7.1 contract) and a CueBlock story (CueTimelineEditor usage in the §7.1 contract). These prove the abstraction expresses both consumers without either consumer's page existing yet (Tier-4 defers the pages, not this proof).

  • Step 4: Commit
git add dev-docs/superpowers/plans/2026-05-17-gui-redesign-tier1-primitives-DRAGGABLEBLOCK-CONTRACT.md
git commit -m "docs(plan): Plan 3 Task A2 — DraggableBlock canonical API reconciled from 2 consumers"

Gate: Tasks 19 may start in parallel with A1/A2 EXCEPT Task 9 (DraggableBlock), which MUST NOT begin until A2 is committed.


Task 1: statusSeverity.ts + bidirectional consistency test (constraints #1, #2; spec §8/§8.X)

Files:

  • Create: apps/app/src/components-v2/shared/statusSeverity.ts

  • Test: apps/app/tests/unit/utils/statusSeverity.consistency.spec.ts

  • Step 1: Write the failing consistency test

// apps/app/tests/unit/utils/statusSeverity.consistency.spec.ts
import { describe, expect, it } from 'vitest'
import { ShiftAssignmentStatus } from '@/types/shiftAssignment'
import { PersonStatus } from '@/types/person'
import { MatchStatus } from '@/types/identityMatch'
import { ArtistEngagementStatus, PaymentStatus } from '@/types/timetable'
import { STATUS_SEVERITY, statusSeverity } from '@/components-v2/shared/statusSeverity'

const ENUMS = { ShiftAssignmentStatus, ArtistEngagementStatus, PaymentStatus, PersonStatus, MatchStatus }
const ALL_VALUES = Object.values(ENUMS).flatMap(e => Object.values(e)) as string[]

describe('statusSeverity §8.X enforcement', () => {
  it('completeness: every live enum value resolves to an explicit severity (no dev-fallback)', () => {
    const missing = ALL_VALUES.filter(v => !(v in STATUS_SEVERITY))
    expect(missing, `unmapped enum values: ${missing.join(', ')}`).toEqual([])
  })

  it('no phantoms: every map key exists in at least one live enum', () => {
    const orphans = Object.keys(STATUS_SEVERITY).filter(k => !ALL_VALUES.includes(k))
    expect(orphans, `orphan keys: ${orphans.join(', ')}`).toEqual([])
  })

  it('resolver returns the mapped severity and never throws on a known value', () => {
    expect(statusSeverity('approved')).toBe('success')
    expect(statusSeverity('no_show')).toBe('danger')
    expect(statusSeverity('deposit_paid')).toBe('info')
  })
})
  • Step 2: Run it — expect FAIL (module missing)

Run: pnpm test -- statusSeverity.consistency Expected: FAIL — Cannot find module '@/components-v2/shared/statusSeverity'.

  • Step 3: Implement statusSeverity.ts seeded verbatim from amended spec §8
// apps/app/src/components-v2/shared/statusSeverity.ts
// Single source of truth for status → PrimeVue Tag severity.
// Seeded VERBATIM from design spec §8 (commit ae0bd2da). Do NOT
// reinterpret here — a wrong severity is a spec-amendment process,
// not an edit to this file. Enforced bidirectionally by
// tests/unit/utils/statusSeverity.consistency.spec.ts (spec §8.X).

export type TagSeverity = 'success' | 'warn' | 'info' | 'secondary' | 'danger'

export const STATUS_SEVERITY: Readonly<Record<string, TagSeverity>> = Object.freeze({
  // success — terminal-good / fully settled
  approved: 'success',
  completed: 'success',
  confirmed: 'success',
  contracted: 'success',
  paid_in_full: 'success',
  // warn — organizer action required
  pending_approval: 'warn',
  pending: 'warn',
  applied: 'warn',
  option: 'warn',
  offered: 'warn',
  reverted: 'warn',
  // info — awaiting external party / in-progress, no viewer action
  invited: 'info',
  requested: 'info',
  deposit_paid: 'info',
  // secondary — muted: absent / not-yet-live / archived
  none: 'secondary',
  draft: 'secondary',
  dismissed: 'secondary',
  // danger — terminal-bad
  rejected: 'danger',
  cancelled: 'danger',
  declined: 'danger',
  no_show: 'danger',
})

const FALLBACK: TagSeverity = 'info'

/**
 * Resolve a status string to a Tag severity. The §8.X consistency
 * test guarantees every live enum value is mapped, so the fallback
 * is unreachable in a passing build; it exists only as defence in
 * depth and emits a dev-only console warning so any gap is loud.
 */
export function statusSeverity(status: string): TagSeverity {
  const hit = STATUS_SEVERITY[status]
  if (hit)
    return hit
  if (import.meta.env.DEV)
    console.warn(`[statusSeverity] unmapped status "${status}" — falling back to "${FALLBACK}". Add a §8 row + extend the §8.X test.`)
  return FALLBACK
}
  • Step 4: Run it — expect PASS

Run: pnpm test -- statusSeverity.consistency Expected: PASS (3 tests). If completeness fails, a src/types enum changed since the spec amend — STOP and amend spec §8 first (constraint #1: never reinterpret here).

  • Step 5: Typecheck + commit

Run: pnpm exec vue-tsc --noEmit → expect no new errors.

git add apps/app/src/components-v2/shared/statusSeverity.ts apps/app/tests/unit/utils/statusSeverity.consistency.spec.ts
git commit -m "feat(gui-v2): statusSeverity SoT map + bidirectional §8.X consistency test"

Task 2: StatusTag.vue (consumes Task 1) + spec + story

Source: ../crewli-starter/src/components/shared/StatusTag.vue (13 lines: tone, dot props, .tag/.dot CSS). v2 implementation per spec §8: PrimeVue <Tag :severity> resolved via statusSeverity.ts, optional leading dot via :pt.

Files:

  • Create: apps/app/src/components-v2/shared/StatusTag.vue

  • Test: apps/app/src/components-v2/shared/__tests__/StatusTag.spec.ts

  • Story: apps/app/src/components-v2/shared/StatusTag.stories.ts

  • Step 1: Write the failing test

// apps/app/src/components-v2/shared/__tests__/StatusTag.spec.ts
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { defineComponent } from 'vue'
import StatusTag from '@/components-v2/shared/StatusTag.vue'

const TagStub = defineComponent({
  name: 'TagStub',
  props: { value: { type: String, default: '' }, severity: { type: String, default: '' }, pt: { type: Object, default: () => ({}) } },
  template: `<span class="tag-stub" :data-severity="severity">{{ value }}<slot /></span>`,
})
const mountTag = (props: Record<string, unknown>) =>
  mount(StatusTag, { props, global: { stubs: { Tag: TagStub } } })

describe('StatusTag', () => {
  it('resolves severity from statusSeverity for the status prop', () => {
    expect(mountTag({ status: 'approved' }).get('.tag-stub').attributes('data-severity')).toBe('success')
    expect(mountTag({ status: 'no_show' }).get('.tag-stub').attributes('data-severity')).toBe('danger')
    expect(mountTag({ status: 'deposit_paid' }).get('.tag-stub').attributes('data-severity')).toBe('info')
  })

  it('renders the label prop, defaulting to the humanised status', () => {
    expect(mountTag({ status: 'pending_approval' }).text()).toContain('pending approval')
    expect(mountTag({ status: 'approved', label: 'Goedgekeurd' }).text()).toContain('Goedgekeurd')
  })

  it('adds the dot passthrough only when dot=true', () => {
    expect(mountTag({ status: 'draft' }).get('.tag-stub').attributes('data-severity')).toBe('secondary')
    const pt = mountTag({ status: 'draft', dot: true }).getComponent(TagStub).props('pt') as Record<string, unknown>
    expect(pt).toHaveProperty('root')
  })
})
  • Step 2: Run — expect FAIL (Cannot find module StatusTag.vue). Run: pnpm test -- StatusTag.

  • Step 3: Implement StatusTag.vue

<script setup lang="ts">
/**
 * StatusTag — PrimeVue <Tag> whose severity ALWAYS resolves through
 * statusSeverity.ts (spec §8). Never inline a severity here.
 *
 * crewli-starter port: .tag/.dot visual is delegated to PrimeVue Tag
 * (themes in both modes via Aura). The optional leading dot reproduces
 * crewli-starter's `<span class="dot">` via :pt.root (a ::before-style
 * inline dot) rather than scoped CSS — no bespoke spacing needed.
 */
import { computed } from 'vue'
import Tag from 'primevue/tag'
import { statusSeverity } from '@/components-v2/shared/statusSeverity'

const props = defineProps<{
  status: string
  label?: string
  dot?: boolean
}>()

const severity = computed(() => statusSeverity(props.status))

const text = computed(() =>
  props.label ?? props.status.replace(/_/g, ' '),
)

const pt = computed(() =>
  props.dot
    ? {
        root: {
          class: 'before:content-[""] before:inline-block before:w-2 before:h-2 before:rounded-full before:bg-current before:mr-1.5 before:align-middle',
        },
      }
    : {},
)
</script>

<template>
  <Tag
    :value="text"
    :severity="severity"
    :pt="pt"
  />
</template>
  • Step 4: Run — expect PASS (3 tests). Run: pnpm test -- StatusTag.

  • Step 5: Write the co-located story

// 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<typeof StatusTag> = {
  title: 'Shared/StatusTag',
  component: StatusTag,
  tags: ['autodocs'],
  argTypes: {
    status: { control: 'text' },
    label: { control: 'text' },
    dot: { control: 'boolean' },
  },
}
export default meta
type Story = StoryObj<typeof StatusTag>

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.

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.

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 10191039). v2: PrimeVue <Card> 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
// 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: `<div class="card-stub"><slot name="content" /></div>` })
const IconStub = defineComponent({ name: 'Icon', props: ['name', 'size'], template: `<i class="icon-stub" :data-icon="name" />` })
const mountCard = (props: Record<string, unknown>) =>
  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

<script setup lang="ts">
/**
 * StatCard — KPI tile. crewli-starter port (replaces v1 AppKpiCard).
 * CSS translation (main.css .stat-card 10191039 → Tailwind + Aura tokens):
 *   .stat-card .lbl  → text-[11.5px] font-semibold uppercase tracking-[0.06em] text-[var(--p-text-muted-color)]
 *   .stat-card .val  → text-[28px] font-bold tracking-[-0.01em] tabular-nums
 *   .trend.up/.down  → text-[var(--p-green-600)] / text-[var(--p-red-600)]
 */
import Card from 'primevue/card'
import Icon from '@/components/Icon.vue'

defineProps<{
  icon: string
  label: string
  value: string | number
  trend?: string
  trendDir?: 'up' | 'down'
}>()
</script>

<template>
  <Card
    :pt="{
      root: 'border border-[var(--p-content-border-color)] rounded-[var(--p-border-radius-lg)] shadow-none',
      body: 'p-[18px]',
      content: 'flex flex-col gap-1.5',
    }"
  >
    <template #content>
      <div class="flex items-center gap-2 text-[11.5px] font-semibold uppercase tracking-[0.06em] text-[var(--p-text-muted-color)]">
        <Icon :name="icon" :size="16" class="text-[var(--p-primary-color)]" />
        {{ label }}
      </div>
      <div class="text-[28px] font-bold tracking-[-0.01em] tabular-nums">
        {{ value }}
      </div>
      <div
        v-if="trend"
        :data-trend="trendDir"
        class="flex items-center gap-1 text-xs text-[var(--p-text-muted-color)]"
        :class="{
          'text-[var(--p-green-600)]!': trendDir === 'up',
          'text-[var(--p-red-600)]!': trendDir === 'down',
        }"
      >
        <Icon v-if="trendDir === 'up'" name="tabler-trending-up" :size="14" />
        <Icon v-if="trendDir === 'down'" name="tabler-trending-down" :size="14" />
        {{ trend }}
      </div>
    </template>
  </Card>
</template>
  • Step 4: Run — expect PASS. Run: pnpm test -- StatCard.

  • Step 5: Co-located story

// 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<typeof StatCard> = {
  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<typeof StatCard>

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).
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 78 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 466478 + responsive 13551360). 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
// 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: '<button>New</button>' } })
    expect(w.get('button').text()).toBe('New')
  })
})
  • Step 2: Run — expect FAIL. Run: pnpm test -- PageHead.

  • Step 3: Implement PageHead.vue

<script setup lang="ts">
/**
 * PageHead — title/sub/#actions. Pure layout (spec §8). CSS translation
 * of main.css .page-head 466478 (+ <=640px stack 13551360) to Tailwind.
 */
defineProps<{ title: string, sub?: string }>()
</script>

<template>
  <div class="flex flex-wrap items-end justify-between gap-6 mb-5 max-[640px]:flex-col max-[640px]:items-stretch max-[640px]:gap-3.5">
    <div class="min-w-0 flex-[1_1_240px] max-[640px]:flex-[0_0_auto]">
      <h1 class="m-0 text-2xl font-bold tracking-[-0.01em] text-[var(--p-text-color)] text-balance max-[640px]:text-[22px]">
        {{ title }}
      </h1>
      <div v-if="sub" data-sub class="mt-1 text-[13.5px] text-[var(--p-text-muted-color)]">
        {{ sub }}
      </div>
    </div>
    <div class="flex flex-wrap items-center gap-2 max-[640px]:w-full max-[640px]:justify-start">
      <slot name="actions" />
    </div>
  </div>
</template>
  • Step 4: Run — expect PASS. Run: pnpm test -- PageHead.

  • Step 5: Co-located story

// 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<typeof PageHead> = {
  title: 'Shared/PageHead',
  component: PageHead,
  tags: ['autodocs'],
  argTypes: { title: { control: 'text' }, sub: { control: 'text' } },
}
export default meta
type Story = StoryObj<typeof PageHead>

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: `<PageHead :title="args.title" :sub="args.sub"><template #actions><Button label="Nieuw evenement" /></template></PageHead>`,
  }),
}
  • Step 6: Typecheck, lint, commit
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 78: Parity-check (Bert, record) + @visual baseline — Task 2 Steps 78 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 45 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)
// 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: `<div class="skeleton-stub" />` }),
  Message: defineComponent({ name: 'Message', props: ['severity'], template: `<div class="message-stub"><slot /></div>` }),
  Button: defineComponent({ name: 'Button', props: ['label'], emits: ['click'], template: `<button class="btn-stub" @click="$emit('click')">{{ label }}</button>` }),
  Card: defineComponent({ name: 'Card', template: `<div class="card-stub"><slot name="content" /></div>` }),
}
const mountSB = (props: Record<string, unknown>, slots: Record<string, string> = {}) =>
  mount(StateBlock, { props, slots, global: { stubs } })

describe('StateBlock', () => {
  it('loading: renders Skeleton, nothing else', () => {
    const w = mountSB({ state: 'loading' }, { default: '<p>data</p>' })
    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: '<p>real content</p>' })
    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: '<p>loaded</p>' })
    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

<script setup lang="ts">
/**
 * StateBlock — the CLAUDE.md mandatory three-state wrapper (loading /
 * error / empty) + success passthrough. Built fresh (no crewli-starter
 * source). Per Plan 3 constraint #5 this component intentionally has
 * NO @visual baseline yet (self-baseline is tautological); correctness
 * is locked by the exhaustive Vitest spec. Visual baseline is a
 * Plans 45 follow-up after first real page usage.
 */
import Skeleton from 'primevue/skeleton'
import Message from 'primevue/message'
import Button from 'primevue/button'
import Card from 'primevue/card'

defineProps<{
  state: 'loading' | 'error' | 'empty' | 'success'
  errorMessage?: string
  emptyMessage?: string
  actionLabel?: string
  retryLabel?: string
}>()

const emit = defineEmits<{ retry: [], action: [] }>()
</script>

<template>
  <div v-if="state === 'loading'" class="flex flex-col gap-3" data-state="loading">
    <Skeleton height="2rem" />
    <Skeleton height="2rem" width="80%" />
    <Skeleton height="2rem" width="60%" />
  </div>

  <Message
    v-else-if="state === 'error'"
    severity="error"
    :closable="false"
    data-state="error"
  >
    <div class="flex items-center justify-between gap-4 w-full">
      <span>{{ errorMessage ?? 'Er ging iets mis.' }}</span>
      <Button :label="retryLabel ?? 'Opnieuw proberen'" size="small" @click="emit('retry')" />
    </div>
  </Message>

  <Card
    v-else-if="state === 'empty'"
    data-state="empty"
    :pt="{ root: 'border border-dashed border-[var(--p-content-border-color)] shadow-none', content: 'flex flex-col items-center gap-3 py-10 text-center' }"
  >
    <template #content>
      <p class="m-0 text-[var(--p-text-muted-color)]">
        {{ emptyMessage ?? 'Nog niets om te tonen.' }}
      </p>
      <Button v-if="actionLabel" :label="actionLabel" @click="emit('action')" />
    </template>
  </Card>

  <slot v-else />
</template>
  • 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)

// 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<typeof StateBlock> = {
  title: 'Shared/StateBlock',
  component: StateBlock,
  tags: ['autodocs'],
  argTypes: { state: { control: 'inline-radio', options: ['loading', 'error', 'empty', 'success'] } },
}
export default meta
type Story = StoryObj<typeof StateBlock>

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: `<StateBlock v-bind="args"><p>Echte inhoud.</p></StateBlock>` }),
}
  • Step 6: Typecheck, lint, commit
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
// 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: `<div class="ac-stub" :data-count="modelValue.length" />`,
})
const mountTI = (props: Record<string, unknown> = {}) =>
  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)

<script setup lang="ts">
/**
 * TagsInput — RE-IMPLEMENTATION (not a port) onto PrimeVue AutoComplete
 * `multiple` + `typeahead` per spec §8. crewli-starter's hand-rolled
 * <input> is behavioural reference only. The 5 reference rules:
 *   (a) array model            (b) lowercase-dedupe
 *   (c) Enter/comma adds       (d) Backspace removes last
 *   (e) 5-suggestion cap
 * Visual parity criterion: "coherent in Aura/teal aesthetic", NOT a
 * pixel match against crewli-starter (constraint #2).
 */
import { ref } from 'vue'
import AutoComplete from 'primevue/autocomplete'

const props = withDefaults(defineProps<{
  modelValue?: string[]
  suggestions?: string[]
  placeholder?: string
}>(), { modelValue: () => [], suggestions: () => [], placeholder: 'Tag toevoegen…' })

const emit = defineEmits<{ 'update:modelValue': [string[]] }>()

const filtered = ref<string[]>([])

/** (b) lowercase + dedupe, order-preserving. */
function normalizeAndEmit(next: string[]): void {
  const seen = new Set<string>()
  const out: string[] = []
  for (const raw of next) {
    const t = raw.trim().toLowerCase()
    if (t && !seen.has(t)) {
      seen.add(t)
      out.push(t)
    }
  }
  emit('update:modelValue', out)
}

/** (c) split a typed query on comma / newline (Enter). */
function splitQuery(q: string): string[] {
  return q.split(/[,\n]/).map(s => s.trim()).filter(Boolean)
}

/** (e) filter suggestions: exclude already-selected, cap at 5. */
function filterSuggestions(q: string): string[] {
  const t = q.toLowerCase()
  const model = props.modelValue.map(s => s.toLowerCase())
  return props.suggestions
    .filter(s => !model.includes(s.toLowerCase()) && s.toLowerCase().includes(t))
    .slice(0, 5)
}

function onComplete(e: { query: string }): void {
  filtered.value = filterSuggestions(e.query)
}

function onModelUpdate(next: unknown): void {
  // AutoComplete multiple emits the full array (chips + typed token).
  const arr = Array.isArray(next) ? next.flatMap(v => typeof v === 'string' ? splitQuery(v) : [String(v)]) : []
  normalizeAndEmit(arr)
}

defineExpose({ normalizeAndEmit, splitQuery, filterSuggestions })
</script>

<template>
  <AutoComplete
    :model-value="props.modelValue"
    :suggestions="filtered"
    multiple
    typeahead
    :placeholder="props.placeholder"
    class="w-full"
    @complete="onComplete"
    @update:model-value="onModelUpdate"
  />
</template>
  • Step 4: Run — expect PASS (5 tests). Run: pnpm test -- TagsInput.

  • Step 5: Co-located story

// 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<typeof TagsInput> = {
  title: 'Shared/TagsInput',
  component: TagsInput,
  tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof TagsInput>

function model(initial: string[], suggestions: string[]): Story['render'] {
  return () => ({
    components: { TagsInput },
    setup() { const tags = ref(initial); return { tags, suggestions } },
    template: `<div class="max-w-md"><TagsInput v-model="tags" :suggestions="suggestions" /><pre class="mt-3 text-xs">{{ tags }}</pre></div>`,
  })
}
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
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).
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 19821991, 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
// 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)

<script setup lang="ts">
/**
 * EnergyDots — 5-dot meter (spec §8: no PrimeVue primitive; Rating is
 * stars/wrong visual → minimal scoped CSS is the justified bespoke case).
 * crewli-starter main.css 19821991 ported; crewli vars → Aura tokens.
 */
import { computed } from 'vue'

const props = withDefaults(defineProps<{ value?: number, lg?: boolean }>(), { value: 0, lg: false })
const clamped = computed(() => Math.max(0, Math.min(5, Math.round(props.value))))
</script>

<template>
  <div class="energy-dots" :class="{ lg }" :data-energy="clamped">
    <span v-for="i in 5" :key="i" class="d" :class="{ on: i <= clamped }" />
  </div>
</template>

<style scoped>
/* Justified per spec §8 — no Tailwind/PrimeVue expression of this meter. */
.energy-dots { display: inline-flex; gap: 3px; align-items: center; }
.d { width: 8px; height: 8px; border-radius: 50%; background: var(--p-content-border-color); }
.d.on { background: var(--p-primary-color); }
.energy-dots[data-energy="1"] .d.on { background: var(--p-sky-600); }
.energy-dots[data-energy="4"] .d.on { background: oklch(65% 0.15 35); }
.energy-dots[data-energy="5"] .d.on { background: var(--p-red-600); }
.energy-dots.lg .d { width: 11px; height: 11px; }
</style>
  • Step 4: Run — expect PASS (3 tests). Run: pnpm test -- EnergyDots.

  • Step 5: Co-located story

// 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<typeof EnergyDots> = {
  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<typeof EnergyDots>
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
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 78: Parity-check (Bert, record) + @visual baseline — Task 2 Steps 78 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
// 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

<script setup lang="ts">
/**
 * EnergyPicker — interactive 5-step picker (crewli-starter music/
 * EnergyPicker.vue port). Click current value → reset to 0. Scoped CSS
 * justified per spec §8 (same rationale as EnergyDots).
 */
const props = withDefaults(defineProps<{ modelValue?: number }>(), { modelValue: 0 })
const emit = defineEmits<{ 'update:modelValue': [number] }>()

function pick(i: number): void {
  emit('update:modelValue', i === props.modelValue ? 0 : i)
}
</script>

<template>
  <div class="energy-picker">
    <button
      v-for="i in 5"
      :key="i"
      type="button"
      :class="{ on: i <= modelValue }"
      @click="pick(i)"
    >{{ i }}</button>
  </div>
</template>

<style scoped>
.energy-picker { display: inline-flex; gap: 4px; }
.energy-picker button {
  width: 28px; height: 28px; border-radius: 50%;
  border: 1px solid var(--p-content-border-color);
  background: var(--p-content-background); color: var(--p-text-muted-color);
  font-size: 12px; font-weight: 600; cursor: pointer;
  transition: background .15s, color .15s, border-color .15s;
}
.energy-picker button.on {
  background: var(--p-primary-color); color: var(--p-primary-contrast-color);
  border-color: var(--p-primary-color);
}
</style>
  • Step 4: Run — expect PASS (3 tests). Run: pnpm test -- EnergyPicker.

  • Step 5: Co-located story

// 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<typeof EnergyPicker> = { title: 'Shared/EnergyPicker', component: EnergyPicker, tags: ['autodocs'] }
export default meta
type Story = StoryObj<typeof EnergyPicker>
export const Interactive: Story = {
  render: () => ({
    components: { EnergyPicker },
    setup() { const v = ref(0); return { v } },
    template: `<div class="flex items-center gap-3"><EnergyPicker v-model="v" /><span class="text-sm">value: {{ v }}</span></div>`,
  }),
}
  • Step 6: Typecheck, lint, commit
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 78: Parity-check (Bert, record) + @visual baseline — Task 2 Steps 78 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)
// 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: `<span class="tag-stub" :data-sev="severity">{{ value }}</span>` }),
  ProgressBar: defineComponent({ name: 'ProgressBar', props: ['value'], template: `<div class="pb-stub" :data-val="value" />` }),
}
const mountDB = (props: Record<string, unknown>) =>
  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)

<script setup lang="ts">
/**
 * DraggableBlock — spec §7.1 canonical contract. ABSTRACTION over two
 * crewli-starter consumers (TimetableGrid mousedown+px/min;
 * CueTimelineEditor HTML5 drag) reconciled in Task A2. This component
 * is presentational + emits normalised pointer drag; it performs NO
 * snap/lane/px-min math — the parent owns all positioning.
 *
 * Scoped CSS is the spec §7.1-justified bespoke case (crewli-starter
 * 2-line block spacing has no Tailwind/PrimeVue expression).
 */
import { ref } from 'vue'
import Tag from 'primevue/tag'
import ProgressBar from 'primevue/progressbar'
import type { TagSeverity } from '@/components-v2/shared/statusSeverity'

interface TagBit { label: string, severity?: TagSeverity }

const props = withDefaults(defineProps<{
  line1Left: { tag?: TagBit, text?: string }
  line1Right?: { tag?: TagBit, pill?: string }
  line2Left?: string
  line2Right?: { progress: number } | null
  selected?: boolean
  dragging?: boolean
  density?: 'compact' | 'regular' | 'comfy'
}>(), { density: 'regular', selected: false, dragging: false, line2Right: null })

const emit = defineEmits<{
  click: []
  dragstart: [e: PointerEvent]
  dragend: [delta: { x: number, y: number }]
}>()

const THRESHOLD = 3
const startX = ref(0)
const startY = ref(0)
const moved = ref(false)
const active = ref(false)

function onPointerDown(e: PointerEvent): void {
  if (e.button !== 0)
    return
  active.value = true
  moved.value = false
  startX.value = e.clientX
  startY.value = e.clientY
  ;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId)
}

function onPointerMove(e: PointerEvent): void {
  if (!active.value)
    return
  if (!moved.value && (Math.abs(e.clientX - startX.value) > THRESHOLD || Math.abs(e.clientY - startY.value) > THRESHOLD)) {
    moved.value = true
    emit('dragstart', e)
  }
}

function onPointerUp(e: PointerEvent): void {
  if (!active.value)
    return
  active.value = false
  if (moved.value)
    emit('dragend', { x: e.clientX - startX.value, y: e.clientY - startY.value })
  else
    emit('click')
}
</script>

<template>
  <div
    class="db"
    :data-density="density"
    :class="{ 'is-selected': selected, 'is-dragging': dragging }"
    @pointerdown="onPointerDown"
    @pointermove="onPointerMove"
    @pointerup="onPointerUp"
    @lostpointercapture="onPointerUp"
  >
    <div class="db-row">
      <span class="db-left">
        <Tag v-if="line1Left.tag" :value="line1Left.tag.label" :severity="line1Left.tag.severity" />
        <span v-if="line1Left.text" class="db-text">{{ line1Left.text }}</span>
      </span>
      <span v-if="line1Right" class="db-right">
        <Tag v-if="line1Right.tag" :value="line1Right.tag.label" :severity="line1Right.tag.severity" />
        <span v-if="line1Right.pill" class="db-pill">{{ line1Right.pill }}</span>
      </span>
    </div>
    <div v-if="line2Left || line2Right" class="db-row db-row2">
      <span v-if="line2Left" class="db-time">{{ line2Left }}</span>
      <ProgressBar v-if="line2Right" :value="line2Right.progress" :show-value="false" class="db-progress" />
    </div>
  </div>
</template>

<style scoped>
/* spec §7.1 justified bespoke — crewli-starter block spacing. */
.db {
  display: flex; flex-direction: column; justify-content: center; gap: 2px;
  padding: 4px 8px; border-radius: var(--p-border-radius);
  border: 1px solid var(--p-content-border-color);
  background: var(--p-content-background);
  user-select: none; cursor: grab; overflow: hidden;
}
.db[data-density="compact"] { min-height: 56px; }
.db[data-density="regular"] { min-height: 64px; }
.db[data-density="comfy"] { min-height: 76px; }
.db.is-selected { border-color: var(--p-primary-color); box-shadow: 0 0 0 2px color-mix(in srgb, var(--p-primary-color) 30%, transparent); }
.db.is-dragging { cursor: grabbing; opacity: .85; }
.db-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; }
.db-left, .db-right { display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
.db-text { font-size: 13px; font-weight: 600; color: var(--p-text-color); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.db-pill { font-size: 11px; color: var(--p-text-muted-color); }
.db-row2 { gap: 6px; }
.db-time { font-size: 11px; color: var(--p-text-muted-color); }
.db-progress { flex: 1; height: 4px; }
</style>
  • 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)

// 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:0022: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)

// 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<typeof DraggableBlock> = {
  title: 'Shared/DraggableBlock',
  component: DraggableBlock,
  tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof DraggableBlock>

/** 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:0022: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
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.

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 45). 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/)
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
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
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 <Menubar> 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 <Breadcrumb> block (→ #start), and the search InputText / notifications OverlayBadge / user Avatar+Menu cluster (→ #end). Note every <script setup> ref/handler (breadcrumbItems, userMenuRef, toggleUserMenu, logout, etc.) — none of these change; only the template wrapper does. Then run pnpm test -- AppTopbar and record the current green assertions as the behaviour baseline to preserve. The exact Menubar shape (slots + :pt overrides required to match current appearance) is concretely pinned in Step 4 — do not improvise it.

  • Step 2: Update the spec first (TDD) — assert a Menubar is the chrome root

Add to __tests__/AppTopbar.spec.ts (keep all existing assertions):

import MenubarReal from 'primevue/menubar'
// ... within describe:
it('renders the top bar inside a PrimeVue Menubar (RFC AD-3)', () => {
  const w = mountTopbar()
  expect(w.findComponent(MenubarReal).exists()).toBe(true)
})
it('breadcrumb lives in Menubar #start, user cluster in #end', () => {
  const w = mountTopbar()
  const bar = w.findComponent(MenubarReal)
  expect(bar.find('[data-tb="breadcrumb"]').exists()).toBe(true)
  expect(bar.find('[data-tb="user"]').exists()).toBe(true)
})

(If the existing spec stubs PrimeVue globally, add a MenubarStub exposing #start/#end slots mirroring the AppDialog DialogStub pattern, and assert via the stub instead of the real component — match the file's existing stubbing convention.)

  • Step 3: Run — expect FAIL (no Menubar yet). Run: pnpm test -- AppTopbar.

  • Step 4: Refactor AppTopbar.vue — wrap content in <Menubar>

Add import Menubar from 'primevue/menubar'. Replace the outer hand-rolled chrome container with:

<Menubar
  :model="[]"
  :pt="{
    root: 'border-0 border-b border-[var(--p-content-border-color)] rounded-none px-4 py-0 bg-[var(--p-content-background)]',
    start: 'flex items-center min-w-0',
    end: 'flex items-center gap-2',
  }"
>
  <template #start>
    <div data-tb="breadcrumb"><!-- existing <Breadcrumb> block, unchanged --></div>
  </template>
  <template #end>
    <div data-tb="search"><!-- existing InputText search, unchanged --></div>
    <div data-tb="notifications"><!-- existing OverlayBadge bell, unchanged --></div>
    <div data-tb="user"><!-- existing Avatar + Menu user cluster, unchanged --></div>
  </template>
</Menubar>

Keep every existing script-block ref/handler (breadcrumbItems, userMenuRef, toggleUserMenu, logout, etc.) — only the template wrapper changes. :model="[]" because the Crewli top bar has no Menubar menu items; it uses Menubar purely as the AD-3-mandated chrome shell with custom #start/#end.

  • Step 5: Run — expect PASS (new + all pre-existing AppTopbar assertions). Run: pnpm test -- AppTopbar.

  • Step 6: Typecheck, lint, visual re-baseline (AppTopbar already had a Plan 2 baseline → update it)

Run: pnpm exec vue-tsc --noEmit → clean. Run: pnpm test:visual:update -- AppTopbar then pnpm test:visual -- AppTopbar → PASS (Bert records parity-pass: the Menubar refactor must be visually identical to the Plan 2 AppTopbar).

  • Step 7: Commit
git add apps/app/src/components-v2/layout/AppTopbar.vue apps/app/src/components-v2/layout/__tests__/AppTopbar.spec.ts apps/app/tests/playwright-ct/v2/__screenshots__
git commit -m "refactor(gui-v2): cleanup(b) — AppTopbar wraps PrimeVue Menubar per RFC AD-3"

Task 12: Delete X.vue, repoint both boundary refs, add shared/* regression locks (constraint #7)

Deliberate, approved deviation from the prompt's constraint #7 wording (read this, future Plan reviewer): the originating prompt's constraint #7 literally asked to "add import rules to the boundaries config so shared/* may import types/utils/composables and may NOT import pages-v2/layouts-v2". This plan intentionally does not do that, and the deviation was reviewed and approved by Bert at the Phase C gate (2026-05-17). Rationale: Phase A established that components-v2 is one boundaries zone already allowed types,utils,lib,composables,composables-forms,stores,components-v2,components-foundation and (by omission) already forbidden pages-v2/layouts/v1. Adding a shared/ sub-zone would (a) be redundant — the desired edges already hold — and (b) actively contradict the existing single-zone architecture set in Plan 1, creating a maintenance trap. The constraint's intent (lock the shared/* edges against future drift) is better served by regression-lock tests than by a structural change. So Task 12 instead (1) removes the X.vue stub, (2) repoints both boundary-spec refs to a real shared component, and (3) adds regression-lock test cases asserting the already-correct shared/* edges so future drift is caught. This paragraph is the audit trail: the deviation is a considered architectural choice, not an oversight.

Files: Modify apps/app/tests/unit/boundaries-v2.spec.ts; Delete apps/app/src/components-v2/shared/X.vue.

  • Step 1: Repoint both X.vue references to StatusTag.vue (L35 + L65)

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

  • Case allows pages-v2 → components-v2: replace import X from '@/components-v2/shared/X.vue' / <X /> with import StatusTag from '@/components-v2/shared/StatusTag.vue' / <StatusTag status="approved" />.

  • Case forbids v1 components → components-v2 (no back-porting): same substitution (keep the expect(errs.length).toBeGreaterThan(0) assertion).

  • Step 2: Add shared/ regression-lock cases*

Append inside the describe('boundaries — v2 zones') block:

it('allows components-v2/shared → types (statusSeverity consumes enums)', async () => {
  const errs = await boundaryErrors(
    'src/components-v2/shared/StatusTag.vue',
    '<script setup lang="ts">import { ShiftAssignmentStatus } from \'@/types/shiftAssignment\'</script><template><span>{{ ShiftAssignmentStatus.APPROVED }}</span></template>',
  )
  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',
    '<script setup lang="ts">import Dash from \'@/pages-v2/dashboard.vue\'</script><template><Dash /></template>',
  )
  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',
    '<script setup lang="ts">import L from \'@/layouts/OrganizerLayoutV2.vue\'</script><template><L /></template>',
  )
  expect(errs.length).toBeGreaterThan(0)
})
  • Step 3: Run — expect PASS (repointed + 3 new cases). Run: pnpm test -- boundaries-v2.

  • Step 4: Delete the stub

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.

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 <Menubar>; 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.