diff --git a/.claude-sync.conf b/.claude-sync.conf
index c5e007ee..bc39a1f0 100644
--- a/.claude-sync.conf
+++ b/.claude-sync.conf
@@ -16,6 +16,7 @@ dev-docs/ARCH-API-VALIDATION.md
dev-docs/RFC-WS-7-OBSERVABILITY.md
dev-docs/GLITCHTIP.md
dev-docs/ARCH-OBSERVABILITY.md
+dev-docs/ARCH-TESTING.md
dev-docs/runbooks/observability-triage.md
dev-docs/runbooks/observability-erasure.md
dev-docs/RFC-WS-6.md
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..05d9b352
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+apps/app/tests/playwright-ct/**/__screenshots__/**/*.png filter=lfs diff=lfs merge=lfs -text
+apps/app/tests/playwright-e2e/**/__screenshots__/**/*.png filter=lfs diff=lfs merge=lfs -text
diff --git a/.gitignore b/.gitignore
index 0f4834fe..2d89c75c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,6 +39,18 @@ storage/framework/views/*
.phpunit.result.cache
coverage/
+# Playwright runtime artifacts (test-results, blob-report, html-report,
+# .cache build dir, playwright traces). __screenshots__/ is committed
+# (via Git LFS, see .gitattributes).
+apps/app/test-results/
+apps/app/playwright-report/
+apps/app/blob-report/
+apps/app/playwright/.cache/
+
+# Playwright e2e seed-data fixture file — written by E2EBaselineSeeder
+# during globalSetup, never source-of-truth.
+api/storage/app/e2e-fixtures.json
+
# Misc
*.pem
.cache/
diff --git a/CLAUDE.md b/CLAUDE.md
index 01103de4..4c68aec1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -160,6 +160,10 @@ PR as the migration.
- Use factories for all test data
- After each module: `php artisan test --filter=ModuleName`
+Frontend test architecture (5 tiers: Unit / Component / Integration /
+Visual / E2E) is documented in `dev-docs/ARCH-TESTING.md`. Choose the
+right tier per the decision tree there before adding new tests.
+
## Frontend rules (strict)
### Vuexy reference source (mandatory)
diff --git a/api/database/seeders/E2EBaselineSeeder.php b/api/database/seeders/E2EBaselineSeeder.php
new file mode 100644
index 00000000..c6e9321e
--- /dev/null
+++ b/api/database/seeders/E2EBaselineSeeder.php
@@ -0,0 +1,112 @@
+call(RoleSeeder::class);
+
+ $org = Organisation::factory()->create([
+ 'name' => 'E2E Test Organisation',
+ ]);
+
+ $user = User::factory()->create([
+ 'email' => 'e2e@test.local',
+ 'password' => Hash::make('password'),
+ 'email_verified_at' => now(),
+ ]);
+ $org->users()->attach($user, ['role' => 'org_admin']);
+
+ $event = Event::factory()->create([
+ 'organisation_id' => $org->id,
+ 'name' => 'E2E Test Festival',
+ 'start_date' => CarbonImmutable::now()->subDay(),
+ 'end_date' => CarbonImmutable::now()->addDays(30),
+ ]);
+
+ $stage = Stage::factory()->create([
+ 'event_id' => $event->id,
+ 'name' => 'E2E Stage',
+ ]);
+
+ StageDay::query()->create([
+ 'stage_id' => $stage->id,
+ 'event_id' => $event->id,
+ ]);
+
+ $artist = Artist::factory()->create([
+ 'organisation_id' => $org->id,
+ 'name' => 'E2E Artist',
+ ]);
+
+ $engagement = ArtistEngagement::factory()->create([
+ 'artist_id' => $artist->id,
+ 'event_id' => $event->id,
+ ]);
+
+ $start = CarbonImmutable::now()->addDays(2)->setTime(20, 0);
+ Performance::factory()->create([
+ 'engagement_id' => $engagement->id,
+ 'event_id' => $event->id,
+ 'stage_id' => $stage->id,
+ 'lane' => 0,
+ 'start_at' => $start,
+ 'end_at' => $start->addHour(),
+ 'version' => 0,
+ ]);
+
+ $performance = Performance::query()
+ ->where('event_id', $event->id)
+ ->where('stage_id', $stage->id)
+ ->first();
+
+ // Write seeded IDs to a known location the Playwright e2e
+ // fixture reads. Avoids artisan-stdout-parsing fragility.
+ $fixturePath = storage_path('app/e2e-fixtures.json');
+ @mkdir(dirname($fixturePath), 0755, true);
+ file_put_contents($fixturePath, json_encode([
+ 'user_email' => 'e2e@test.local',
+ 'user_password' => 'password',
+ 'organisation_id' => $org->id,
+ 'event_id' => $event->id,
+ 'stage_id' => $stage->id,
+ 'performance_id' => $performance?->id,
+ ], JSON_PRETTY_PRINT));
+
+ $this->command?->info("E2E fixtures written to {$fixturePath}");
+ }
+}
diff --git a/apps/app/package.json b/apps/app/package.json
index dcef67f4..fa6d6dbe 100644
--- a/apps/app/package.json
+++ b/apps/app/package.json
@@ -14,7 +14,11 @@
"msw:init": "msw init public/ --save",
"postinstall": "npm run build:icons && npm run msw:init",
"test": "vitest run",
- "test:watch": "vitest"
+ "test:watch": "vitest",
+ "test:component": "playwright test --config=playwright-ct.config.ts",
+ "test:e2e": "playwright test --config=playwright.config.ts",
+ "test:visual": "playwright test --config=playwright-ct.config.ts --grep @visual",
+ "test:visual:update": "playwright test --config=playwright-ct.config.ts --grep @visual --update-snapshots"
},
"dependencies": {
"@casl/ability": "6.7.3",
@@ -67,6 +71,7 @@
"@antfu/eslint-config-ts": "0.43.1",
"@antfu/eslint-config-vue": "0.43.1",
"@antfu/utils": "0.7.10",
+ "@axe-core/playwright": "^4.11.3",
"@fullcalendar/core": "6.1.19",
"@fullcalendar/daygrid": "6.1.19",
"@fullcalendar/interaction": "6.1.19",
@@ -82,6 +87,8 @@
"@iconify/vue": "4.1.2",
"@intlify/unplugin-vue-i18n": "11.0.1",
"@pinia/testing": "^1.0.3",
+ "@playwright/experimental-ct-vue": "^1.59.1",
+ "@playwright/test": "^1.59.1",
"@stylistic/eslint-plugin-js": "0.0.4",
"@stylistic/eslint-plugin-ts": "0.0.4",
"@stylistic/stylelint-config": "1.0.1",
@@ -101,6 +108,7 @@
"@typescript-eslint/parser": "7.18.0",
"@vitejs/plugin-vue": "6.0.1",
"@vitejs/plugin-vue-jsx": "5.1.1",
+ "@vue/compiler-dom": "^3.5.34",
"@vue/test-utils": "^2.4.9",
"axe-core": "^4.11.4",
"baseline-browser-mapping": "^2.10.16",
diff --git a/apps/app/playwright-ct.config.ts b/apps/app/playwright-ct.config.ts
new file mode 100644
index 00000000..a0e1b52a
--- /dev/null
+++ b/apps/app/playwright-ct.config.ts
@@ -0,0 +1,78 @@
+import { fileURLToPath } from 'node:url'
+import { defineConfig, devices } from '@playwright/experimental-ct-vue'
+import vue from '@vitejs/plugin-vue'
+
+// Component-test config — mounts individual components in a real
+// Chromium via Playwright Component Testing. Used by:
+// pnpm test:component (all CT tests, baselines verified)
+// pnpm test:visual (subset tagged @visual)
+// pnpm test:visual:update (regenerate visual baselines)
+//
+// E2E (real backend + real frontend) lives in playwright.config.ts.
+
+const sharedAliases = {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ '@core': fileURLToPath(new URL('./src/@core', import.meta.url)),
+ '@layouts': fileURLToPath(new URL('./src/@layouts', import.meta.url)),
+ '@images': fileURLToPath(new URL('./src/assets/images/', import.meta.url)),
+ '@styles': fileURLToPath(new URL('./src/assets/styles/', import.meta.url)),
+}
+
+export default defineConfig({
+ testDir: './tests/playwright-ct',
+ testMatch: /.*\.spec\.ts$/,
+ snapshotDir: './tests/playwright-ct/__screenshots__',
+ snapshotPathTemplate: '{snapshotDir}/{testFilePath}/{arg}{ext}',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: 0,
+ workers: process.env.CI ? 1 : undefined,
+ reporter: process.env.CI ? 'github' : 'list',
+
+ use: {
+ trace: 'off',
+ viewport: { width: 1440, height: 900 },
+ ctPort: 3100,
+ ctViteConfig: {
+ plugins: [vue()],
+ resolve: { alias: sharedAliases },
+
+ // Vuetify ships ESM that Vite needs to inline-process; matches
+ // the convention from vitest.config.ts's component project.
+ ssr: { noExternal: ['vuetify'] },
+ },
+ },
+
+ // Linux-Chromium-only baselines per RFC §A.5 — Firefox/WebKit and
+ // mobile/responsive viewports deferred to a later sprint. Running
+ // baselines on macOS (dev) vs Linux (CI) will produce a 1-2px
+ // anti-alias diff; pixel tolerance below absorbs that, but the
+ // canonical baseline IS the dev machine for now (no CI yet — see
+ // BACKLOG TEST-INFRA-002).
+ expect: {
+ toHaveScreenshot: {
+ maxDiffPixelRatio: 0.001,
+ threshold: 0.2,
+ },
+ },
+
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+
+ // Auto-start the prototype static server for visual baselines.
+ // Tests under tests/playwright-ct/visual/ navigate to this URL via
+ // page.goto() rather than calling mount(); they coexist with normal
+ // CT tests in the same runner.
+ webServer: {
+ command: 'node tests/playwright-ct/visual/static-server.mjs',
+ url: `http://127.0.0.1:${process.env.PROTOTYPE_PORT ?? 5179}/crewli-timetable.html`,
+ reuseExistingServer: !process.env.CI,
+ timeout: 30_000,
+ stdout: 'ignore',
+ stderr: 'pipe',
+ },
+})
diff --git a/apps/app/playwright.config.ts b/apps/app/playwright.config.ts
new file mode 100644
index 00000000..588023ca
--- /dev/null
+++ b/apps/app/playwright.config.ts
@@ -0,0 +1,69 @@
+import { defineConfig, devices } from '@playwright/test'
+
+// E2E config — drives a real Laravel test server. Used by `pnpm test:e2e`.
+// Component tests live in playwright-ct.config.ts (different runner).
+//
+// Architecture (B4 / TEST-CONTRACT-001):
+// - globalSetup runs `php artisan migrate:fresh --force --seed` against
+// crewli_test (the existing PHPUnit MySQL test DB) using the
+// E2EBaselineSeeder. Writes seeded IDs to api/storage/app/e2e-fixtures.json.
+// - webServer auto-starts `php artisan serve --port=8001` against the
+// same DB so the test can hit a live HTTP endpoint.
+// - Tests use page.context().request to call /api/v1/* with cookie auth
+// established via /api/v1/auth/login (Bearer-via-cookie, not stateful
+// Sanctum SPA flow — see api/.../SetAuthCookie.php).
+//
+// SCOPE: contract tests only (request shape verification). UI-driven e2e
+// tests would additionally need the Vite dev server (port 5174). Add that
+// to webServer when first UI-driven e2e test lands.
+//
+// CI integration: deferred to TEST-INFRA-002. This config currently
+// targets local-machine execution.
+
+const E2E_API_PORT = Number(process.env.E2E_API_PORT ?? 8001)
+
+// Use `localhost` (not 127.0.0.1) so the auth cookie set with
+// `domain=localhost` (per api/.env's SESSION_DOMAIN) is matched on
+// subsequent requests. Playwright's APIRequestContext respects
+// cookie scope strictly.
+const E2E_API_URL = `http://localhost:${E2E_API_PORT}`
+
+export default defineConfig({
+ testDir: './tests/playwright-e2e',
+ fullyParallel: false,
+ forbidOnly: !!process.env.CI,
+ retries: 0,
+ workers: 1,
+ reporter: process.env.CI ? 'github' : 'list',
+ globalSetup: './tests/playwright-e2e/global-setup.ts',
+
+ use: {
+ baseURL: E2E_API_URL,
+ trace: 'off',
+ video: 'off',
+ screenshot: 'off',
+ viewport: { width: 1440, height: 900 },
+ extraHTTPHeaders: {
+ Accept: 'application/json',
+ },
+ },
+
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+
+ // Auto-start the Laravel test server. globalSetup runs first
+ // (migrate:fresh + seed), then this command spawns artisan serve
+ // against the now-seeded crewli_test DB.
+ webServer: {
+ command: 'cd ../../api && DB_DATABASE=crewli_test php artisan --env=testing serve --port=8001',
+ url: `${E2E_API_URL}/up`,
+ reuseExistingServer: !process.env.CI,
+ timeout: 60_000,
+ stdout: 'ignore',
+ stderr: 'pipe',
+ },
+})
diff --git a/apps/app/playwright/index.html b/apps/app/playwright/index.html
new file mode 100644
index 00000000..fd8b764d
--- /dev/null
+++ b/apps/app/playwright/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Playwright CT — Crewli
+
+
+
+
+
+
diff --git a/apps/app/playwright/index.ts b/apps/app/playwright/index.ts
new file mode 100644
index 00000000..5ad543fa
--- /dev/null
+++ b/apps/app/playwright/index.ts
@@ -0,0 +1,125 @@
+import { beforeMount } from '@playwright/experimental-ct-vue/hooks'
+import { createPinia, setActivePinia } from 'pinia'
+import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
+import { type RouteRecordRaw, createMemoryHistory, createRouter } from 'vue-router'
+import { type ThemeDefinition, createVuetify } from 'vuetify'
+
+// vuetify/components namespace import: required to register the full
+// component set on a freshly-created Vuetify instance per test, mirroring
+// tests/utils/mountWithVuexy.ts. Test infra only.
+import * as components from 'vuetify/components' // eslint-disable-line no-restricted-imports
+import * as directives from 'vuetify/directives'
+
+// Vuetify base styles — required for v-btn / v-chip / v-card etc. to
+// render with their actual visual appearance (not a default browser
+// look). Without this, Playwright visual baselines would capture
+// unstyled components. Removed in F3 alongside the Vuetify plugin.
+import 'vuetify/styles'
+
+// Plain-CSS token sheet — getComputedStyle(el).getPropertyValue('--tt-status-…')
+// resolves during component tests. Path resolved by the alias map in
+// playwright-ct.config.ts.
+import '@/styles/tokens/_timetable.css'
+
+// =============================================================================
+// HOOKS CONFIG (per-test, opt-in)
+// =============================================================================
+//
+// Tests pass `hooksConfig` to mount() to override defaults. Shape:
+// {
+// initialRoute?: string,
+// initialQuery?: Record,
+// routes?: RouteRecordRaw[],
+// piniaInitialState?: Record>,
+// // injected by mountWithProviders.ts wrapper:
+// notificationMockKey?: string,
+// }
+//
+// Defaults below render every component with the full Vuexy/Vuetify
+// stack. F3 (PrimeVue foundation) replaces the Vuetify plugin line
+// here with PrimeVue and updates the sanity test — that is a ~2-hour
+// swap, not a rewrite. Vuetify is INTENTIONAL TEMPORARY STATE in this
+// file; do not abstract behind a "UI framework provider" indirection
+// because the abstraction would itself need to be removed in F3.
+// See dev-docs/ARCH-TESTING.md §6 for the migration timeline.
+// =============================================================================
+
+export interface HooksConfig {
+ initialRoute?: string
+ initialQuery?: Record
+ routes?: RouteRecordRaw[]
+ piniaInitialState?: Record>
+}
+
+const defaultTheme: ThemeDefinition = {
+ dark: false,
+ colors: {
+ primary: '#1f7ad1',
+ error: '#d63d4b',
+ success: '#2fa66a',
+ warning: '#e0992c',
+ info: '#1f7ad1',
+ },
+}
+
+beforeMount(async ({ app, hooksConfig }) => {
+ // ---- Vuetify (TEMPORARY: replaced by PrimeVue in F3) -----------------
+ const vuetify = createVuetify({
+ components,
+ directives,
+ theme: { defaultTheme: 'crewliLight', themes: { crewliLight: defaultTheme } },
+ })
+
+ app.use(vuetify)
+
+ // ---- Pinia ----------------------------------------------------------
+ // Fresh instance per test. We do NOT use @pinia/testing's
+ // createTestingPinia here because it depends on Vitest's `vi.fn`,
+ // which doesn't exist in Playwright's Node runtime. Tests that need
+ // to assert on store actions should snapshot store state via
+ // page.evaluate() against window.__pinia (exposed below).
+ const pinia = createPinia()
+
+ app.use(pinia)
+ setActivePinia(pinia)
+
+ if (hooksConfig?.piniaInitialState) {
+ // Hydrate store state directly. Stores are created lazily on first
+ // use(); pre-hydration via pinia.state.value is safe.
+ pinia.state.value = {
+ ...pinia.state.value,
+ ...hooksConfig.piniaInitialState,
+ }
+ }
+
+ // Expose pinia on window for cross-frame state assertions.
+ ;(globalThis as { __pinia?: typeof pinia }).__pinia = pinia
+
+ // ---- TanStack Vue Query ---------------------------------------------
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, refetchOnWindowFocus: false },
+ mutations: { retry: false },
+ },
+ })
+
+ app.use(VueQueryPlugin, { queryClient })
+
+ // ---- Router (memory history; no auth guards) ------------------------
+ const routes: RouteRecordRaw[] = hooksConfig?.routes ?? [
+ { path: '/', component: { template: '' } },
+ { path: '/:pathMatch(.*)*', component: { template: '' } },
+ ]
+
+ const router = createRouter({ history: createMemoryHistory(), routes })
+
+ app.use(router)
+
+ if (hooksConfig?.initialRoute) {
+ await router.push({
+ path: hooksConfig.initialRoute,
+ query: hooksConfig.initialQuery,
+ })
+ }
+ await router.isReady()
+})
diff --git a/apps/app/pnpm-lock.yaml b/apps/app/pnpm-lock.yaml
index 1761002e..63e3066f 100644
--- a/apps/app/pnpm-lock.yaml
+++ b/apps/app/pnpm-lock.yaml
@@ -157,6 +157,9 @@ importers:
'@antfu/utils':
specifier: 0.7.10
version: 0.7.10
+ '@axe-core/playwright':
+ specifier: ^4.11.3
+ version: 4.11.3(playwright-core@1.59.1)
'@fullcalendar/core':
specifier: 6.1.19
version: 6.1.19
@@ -198,10 +201,16 @@ importers:
version: 4.1.2(vue@3.5.22(typescript@5.9.3))
'@intlify/unplugin-vue-i18n':
specifier: 11.0.1
- version: 11.0.1(@vue/compiler-dom@3.5.22)(eslint@8.57.1)(rollup@4.52.5)(typescript@5.9.3)(vue-i18n@11.1.12(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))
+ version: 11.0.1(@vue/compiler-dom@3.5.34)(eslint@8.57.1)(rollup@4.52.5)(typescript@5.9.3)(vue-i18n@11.1.12(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))
'@pinia/testing':
specifier: ^1.0.3
version: 1.0.3(pinia@3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3)))
+ '@playwright/experimental-ct-vue':
+ specifier: ^1.59.1
+ version: 1.59.1(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))(yaml@2.8.1)
+ '@playwright/test':
+ specifier: ^1.59.1
+ version: 1.59.1
'@stylistic/eslint-plugin-js':
specifier: 0.0.4
version: 0.0.4
@@ -216,7 +225,7 @@ importers:
version: 2.1.3(stylelint@16.8.0(typescript@5.9.3))
'@testing-library/vue':
specifier: ^8.1.0
- version: 8.1.0(@vue/compiler-dom@3.5.22)(@vue/compiler-sfc@3.5.22)(@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))
+ version: 8.1.0(@vue/compiler-dom@3.5.34)(@vue/compiler-sfc@3.5.22)(@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))
'@tiptap/extension-character-count':
specifier: ^2.27.1
version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)
@@ -259,9 +268,12 @@ importers:
'@vitejs/plugin-vue-jsx':
specifier: 5.1.1
version: 5.1.1(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))
+ '@vue/compiler-dom':
+ specifier: ^3.5.34
+ version: 3.5.34
'@vue/test-utils':
specifier: ^2.4.9
- version: 2.4.9(@vue/compiler-dom@3.5.22)(@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))
+ version: 2.4.9(@vue/compiler-dom@3.5.34)(@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))
axe-core:
specifier: ^4.11.4
version: 4.11.4
@@ -387,7 +399,7 @@ importers:
version: 0.18.6(@vueuse/core@10.11.1(vue@3.5.22(typescript@5.9.3)))(rollup@4.52.5)
unplugin-vue-components:
specifier: 0.27.5
- version: 0.27.5(@babel/parser@7.28.5)(rollup@4.52.5)(vue@3.5.22(typescript@5.9.3))
+ version: 0.27.5(@babel/parser@7.29.3)(rollup@4.52.5)(vue@3.5.22(typescript@5.9.3))
unplugin-vue-router:
specifier: 0.8.8
version: 0.8.8(rollup@4.52.5)(vue-router@4.5.1(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))
@@ -467,6 +479,11 @@ packages:
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
+ '@axe-core/playwright@4.11.3':
+ resolution: {integrity: sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==}
+ peerDependencies:
+ playwright-core: '>= 1.0.0'
+
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
@@ -554,6 +571,11 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ '@babel/parser@7.29.3':
+ resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
'@babel/plugin-proposal-decorators@7.28.0':
resolution: {integrity: sha512-zOiZqvANjWDUaUS9xMxbMcK/Zccztbe/6ikvUXaG9nsPH3w6qh5UaPGAnirI/WhIbZ8m3OHU0ReyPrknG+ZKeg==}
engines: {node: '>=6.9.0'}
@@ -611,6 +633,10 @@ packages:
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
engines: {node: '>=6.9.0'}
+ '@babel/types@7.29.0':
+ resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
+ engines: {node: '>=6.9.0'}
+
'@boundaries/elements@2.0.1':
resolution: {integrity: sha512-sAWO3D8PFP6pBXdxxW93SQi/KQqqhE2AAHo3AgWfdtJXwO6bfK6/wUN81XnOZk0qRC6vHzUEKhjwVD9dtDWvxg==}
engines: {node: '>=18.18'}
@@ -1157,6 +1183,20 @@ packages:
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
+ '@playwright/experimental-ct-core@1.59.1':
+ resolution: {integrity: sha512-U7+jNROBJxfwjM/G7011+UNEyLiI5zIT1HWAn1k89WZIWl5RUWaCGWlYkYdAZwBSVfGstjF9AgkzmS0RsF8Ulw==}
+ engines: {node: '>=18'}
+
+ '@playwright/experimental-ct-vue@1.59.1':
+ resolution: {integrity: sha512-RygXcwXQwRHzcdaQAXpKiHEl8XDPepZKmfHDNPCSnCN1g9ylaAvtNF6s7DgpsxlHqLlwgc2DmMr1n3D/OOVKyQ==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ '@playwright/test@1.59.1':
+ resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -2013,6 +2053,13 @@ packages:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0
vue: ^3.0.0
+ '@vitejs/plugin-vue@5.2.4':
+ resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ peerDependencies:
+ vite: ^5.0.0 || ^6.0.0
+ vue: ^3.2.25
+
'@vitejs/plugin-vue@6.0.1':
resolution: {integrity: sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2086,9 +2133,15 @@ packages:
'@vue/compiler-core@3.5.22':
resolution: {integrity: sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==}
+ '@vue/compiler-core@3.5.34':
+ resolution: {integrity: sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==}
+
'@vue/compiler-dom@3.5.22':
resolution: {integrity: sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==}
+ '@vue/compiler-dom@3.5.34':
+ resolution: {integrity: sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==}
+
'@vue/compiler-sfc@3.5.22':
resolution: {integrity: sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==}
@@ -2143,6 +2196,9 @@ packages:
'@vue/shared@3.5.22':
resolution: {integrity: sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==}
+ '@vue/shared@3.5.34':
+ resolution: {integrity: sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==}
+
'@vue/test-utils@2.4.9':
resolution: {integrity: sha512-YwgowiO1mPleZqpgAGfxvWu/A5A8nkLrbyH2SqiQRkyzCIaDzzo27/2uS/F1g7fRLvl8BUY0+Sr1eC+6+IHfrw==}
peerDependencies:
@@ -3219,6 +3275,11 @@ packages:
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -4207,6 +4268,16 @@ packages:
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
+ playwright-core@1.59.1:
+ resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ playwright@1.59.1:
+ resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==}
+ engines: {node: '>=18'}
+ hasBin: true
+
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
@@ -5212,6 +5283,46 @@ packages:
peerDependencies:
vue: '>=3.2.13'
+ vite@6.4.2:
+ resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+ jiti: '>=1.21.0'
+ less: '*'
+ lightningcss: ^1.21.0
+ sass: '*'
+ sass-embedded: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
vite@7.1.12:
resolution: {integrity: sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -5703,6 +5814,11 @@ snapshots:
'@asamuzakjp/nwsapi@2.3.9': {}
+ '@axe-core/playwright@4.11.3(playwright-core@1.59.1)':
+ dependencies:
+ axe-core: 4.11.4
+ playwright-core: 1.59.1
+
'@babel/code-frame@7.27.1':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -5826,6 +5942,10 @@ snapshots:
dependencies:
'@babel/types': 7.28.5
+ '@babel/parser@7.29.3':
+ dependencies:
+ '@babel/types': 7.29.0
+
'@babel/plugin-proposal-decorators@7.28.0(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
@@ -5896,6 +6016,11 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
+ '@babel/types@7.29.0':
+ dependencies:
+ '@babel/helper-string-parser': 7.27.1
+ '@babel/helper-validator-identifier': 7.28.5
+
'@boundaries/elements@2.0.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)':
dependencies:
eslint-import-resolver-node: 0.3.9
@@ -6254,12 +6379,12 @@ snapshots:
'@intlify/shared@11.1.12': {}
- '@intlify/unplugin-vue-i18n@11.0.1(@vue/compiler-dom@3.5.22)(eslint@8.57.1)(rollup@4.52.5)(typescript@5.9.3)(vue-i18n@11.1.12(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))':
+ '@intlify/unplugin-vue-i18n@11.0.1(@vue/compiler-dom@3.5.34)(eslint@8.57.1)(rollup@4.52.5)(typescript@5.9.3)(vue-i18n@11.1.12(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1)
'@intlify/bundle-utils': 11.0.1(vue-i18n@11.1.12(vue@3.5.22(typescript@5.9.3)))
'@intlify/shared': 11.1.12
- '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.1.12)(@vue/compiler-dom@3.5.22)(vue-i18n@11.1.12(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))
+ '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.1.12)(@vue/compiler-dom@3.5.34)(vue-i18n@11.1.12(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))
'@rollup/pluginutils': 5.3.0(rollup@4.52.5)
'@typescript-eslint/scope-manager': 8.46.2
'@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3)
@@ -6278,12 +6403,12 @@ snapshots:
- supports-color
- typescript
- '@intlify/vue-i18n-extensions@8.0.0(@intlify/shared@11.1.12)(@vue/compiler-dom@3.5.22)(vue-i18n@11.1.12(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))':
+ '@intlify/vue-i18n-extensions@8.0.0(@intlify/shared@11.1.12)(@vue/compiler-dom@3.5.34)(vue-i18n@11.1.12(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@babel/parser': 7.28.5
optionalDependencies:
'@intlify/shared': 11.1.12
- '@vue/compiler-dom': 3.5.22
+ '@vue/compiler-dom': 3.5.34
vue: 3.5.22(typescript@5.9.3)
vue-i18n: 11.1.12(vue@3.5.22(typescript@5.9.3))
@@ -6385,6 +6510,47 @@ snapshots:
'@pkgr/core@0.2.9': {}
+ '@playwright/experimental-ct-core@1.59.1(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1)':
+ dependencies:
+ playwright: 1.59.1
+ playwright-core: 1.59.1
+ vite: 6.4.2(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1)
+ transitivePeerDependencies:
+ - '@types/node'
+ - jiti
+ - less
+ - lightningcss
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - terser
+ - tsx
+ - yaml
+
+ '@playwright/experimental-ct-vue@1.59.1(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))(yaml@2.8.1)':
+ dependencies:
+ '@playwright/experimental-ct-core': 1.59.1(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1)
+ '@vitejs/plugin-vue': 5.2.4(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))
+ transitivePeerDependencies:
+ - '@types/node'
+ - jiti
+ - less
+ - lightningcss
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - terser
+ - tsx
+ - vite
+ - vue
+ - yaml
+
+ '@playwright/test@1.59.1':
+ dependencies:
+ playwright: 1.59.1
+
'@polka/url@1.0.0-next.29': {}
'@popperjs/core@2.11.8': {}
@@ -6614,11 +6780,11 @@ snapshots:
lz-string: 1.5.0
pretty-format: 27.5.1
- '@testing-library/vue@8.1.0(@vue/compiler-dom@3.5.22)(@vue/compiler-sfc@3.5.22)(@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))':
+ '@testing-library/vue@8.1.0(@vue/compiler-dom@3.5.34)(@vue/compiler-sfc@3.5.22)(@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@babel/runtime': 7.29.2
'@testing-library/dom': 9.3.4
- '@vue/test-utils': 2.4.9(@vue/compiler-dom@3.5.22)(@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))
+ '@vue/test-utils': 2.4.9(@vue/compiler-dom@3.5.34)(@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))
vue: 3.5.22(typescript@5.9.3)
optionalDependencies:
'@vue/compiler-sfc': 3.5.22
@@ -7257,6 +7423,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@vitejs/plugin-vue@5.2.4(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))':
+ dependencies:
+ vite: 7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1)
+ vue: 3.5.22(typescript@5.9.3)
+
'@vitejs/plugin-vue@6.0.1(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.29
@@ -7365,11 +7536,24 @@ snapshots:
estree-walker: 2.0.2
source-map-js: 1.2.1
+ '@vue/compiler-core@3.5.34':
+ dependencies:
+ '@babel/parser': 7.29.3
+ '@vue/shared': 3.5.34
+ entities: 7.0.1
+ estree-walker: 2.0.2
+ source-map-js: 1.2.1
+
'@vue/compiler-dom@3.5.22':
dependencies:
'@vue/compiler-core': 3.5.22
'@vue/shared': 3.5.22
+ '@vue/compiler-dom@3.5.34':
+ dependencies:
+ '@vue/compiler-core': 3.5.34
+ '@vue/shared': 3.5.34
+
'@vue/compiler-sfc@3.5.22':
dependencies:
'@babel/parser': 7.28.5
@@ -7436,7 +7620,7 @@ snapshots:
'@vue/language-core@3.1.2(typescript@5.9.3)':
dependencies:
'@volar/language-core': 2.4.23
- '@vue/compiler-dom': 3.5.22
+ '@vue/compiler-dom': 3.5.34
'@vue/shared': 3.5.22
alien-signals: 3.0.3
muggle-string: 0.4.1
@@ -7469,9 +7653,11 @@ snapshots:
'@vue/shared@3.5.22': {}
- '@vue/test-utils@2.4.9(@vue/compiler-dom@3.5.22)(@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))':
+ '@vue/shared@3.5.34': {}
+
+ '@vue/test-utils@2.4.9(@vue/compiler-dom@3.5.34)(@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))':
dependencies:
- '@vue/compiler-dom': 3.5.22
+ '@vue/compiler-dom': 3.5.34
js-beautify: 1.15.4
vue: 3.5.22(typescript@5.9.3)
vue-component-type-helpers: 3.2.7
@@ -8853,6 +9039,9 @@ snapshots:
fs.realpath@1.0.0: {}
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
@@ -9901,6 +10090,14 @@ snapshots:
exsolve: 1.0.7
pathe: 2.0.3
+ playwright-core@1.59.1: {}
+
+ playwright@1.59.1:
+ dependencies:
+ playwright-core: 1.59.1
+ optionalDependencies:
+ fsevents: 2.3.2
+
pluralize@8.0.0: {}
pngjs@5.0.0: {}
@@ -10933,7 +11130,7 @@ snapshots:
pathe: 2.0.3
picomatch: 4.0.3
- unplugin-vue-components@0.27.5(@babel/parser@7.28.5)(rollup@4.52.5)(vue@3.5.22(typescript@5.9.3)):
+ unplugin-vue-components@0.27.5(@babel/parser@7.29.3)(rollup@4.52.5)(vue@3.5.22(typescript@5.9.3)):
dependencies:
'@antfu/utils': 0.7.10
'@rollup/pluginutils': 5.3.0(rollup@4.52.5)
@@ -10947,7 +11144,7 @@ snapshots:
unplugin: 1.16.1
vue: 3.5.22(typescript@5.9.3)
optionalDependencies:
- '@babel/parser': 7.28.5
+ '@babel/parser': 7.29.3
transitivePeerDependencies:
- rollup
- supports-color
@@ -11099,7 +11296,7 @@ snapshots:
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.5)
'@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.28.5)
'@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.28.5)
- '@vue/compiler-dom': 3.5.22
+ '@vue/compiler-dom': 3.5.34
kolorist: 1.8.0
magic-string: 0.30.21
vite: 7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1)
@@ -11128,6 +11325,21 @@ snapshots:
svgo: 3.3.2
vue: 3.5.22(typescript@5.9.3)
+ vite@6.4.2(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1):
+ dependencies:
+ esbuild: 0.25.11
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+ postcss: 8.5.6
+ rollup: 4.52.5
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ '@types/node': 24.9.2
+ fsevents: 2.3.3
+ sass: 1.76.0
+ tsx: 4.20.6
+ yaml: 2.8.1
+
vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1):
dependencies:
esbuild: 0.25.11
diff --git a/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/canvas-friday.png b/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/canvas-friday.png
new file mode 100644
index 00000000..0eae1e50
--- /dev/null
+++ b/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/canvas-friday.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:acd74364b466e490092925546dc3a065cfdf6dc6898a68d3262b8e3b2e23e969
+size 94506
diff --git a/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/canvas-saturday.png b/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/canvas-saturday.png
new file mode 100644
index 00000000..01480bd0
--- /dev/null
+++ b/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/canvas-saturday.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:404193ea0b6848b641900d61ec29d6b5ff70571d31e216d8fa9e4c0ebab517c3
+size 84425
diff --git a/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/popover.png b/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/popover.png
new file mode 100644
index 00000000..65ccb89d
--- /dev/null
+++ b/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/popover.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a280af3450f2a9bd1e7d9b8b30153549e347813e1b16763417997229f0729875
+size 22473
diff --git a/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/stage-row-multilane.png b/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/stage-row-multilane.png
new file mode 100644
index 00000000..dc66a53c
--- /dev/null
+++ b/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/stage-row-multilane.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:67647f68e96447bde097f7fef52dc8ab1ff5a6de44eaff6b275ecf899ca6d48f
+size 22160
diff --git a/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/wachtrij-populated.png b/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/wachtrij-populated.png
new file mode 100644
index 00000000..d6ec9e5a
--- /dev/null
+++ b/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/wachtrij-populated.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9c4836eada0f817464928aca1896c904d5ebfea2c2c1257c6f695de4c36e85dd
+size 41833
diff --git a/apps/app/tests/playwright-ct/components/SanityButtonHarness.vue b/apps/app/tests/playwright-ct/components/SanityButtonHarness.vue
new file mode 100644
index 00000000..17f792ab
--- /dev/null
+++ b/apps/app/tests/playwright-ct/components/SanityButtonHarness.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Clicks: {{ count }}
+
+
+
diff --git a/apps/app/tests/playwright-ct/components/sanity-vuetify.spec.ts b/apps/app/tests/playwright-ct/components/sanity-vuetify.spec.ts
new file mode 100644
index 00000000..35f7ccec
--- /dev/null
+++ b/apps/app/tests/playwright-ct/components/sanity-vuetify.spec.ts
@@ -0,0 +1,50 @@
+import { expect, test } from '@playwright/experimental-ct-vue'
+import SanityButtonHarness from './SanityButtonHarness.vue'
+
+// B2 sanity — proves the full provider stack from playwright/index.ts
+// is wired:
+// - Vuetify renders v-btn with theme tokens applied
+// - Click events propagate via Vue's reactivity (counter ref updates)
+// - Vuetify CSS variables resolve in computed style
+//
+// TEMPORARY VUETIFY: this test is replaced by a PrimeVue equivalent
+// in F3. Do not extend or generalise — F3 rewrites it. See
+// dev-docs/ARCH-TESTING.md §6.
+//
+// Why a .vue harness file: Playwright CT runs the test orchestrator
+// in Node and the component in a Vite-bundled browser context. Vue
+// components that pull in CSS-side-effect imports (Vuetify) cannot be
+// loaded directly into the test's Node module graph; they must live
+// in a .vue / Vite-compilable file. This is a structural divergence
+// from Vitest, which uses jsdom and one module graph for both.
+
+test.describe('B2 sanity: provider stack', () => {
+ test('mounts a Vuetify v-btn and propagates clicks', async ({ mount }) => {
+ const component = await mount(SanityButtonHarness)
+ const btn = component.locator('[data-test="btn"]')
+
+ await expect(btn).toBeVisible()
+ await expect(btn).toContainText('Clicks: 0')
+
+ await btn.click()
+ await expect(btn).toContainText('Clicks: 1')
+
+ await btn.click()
+ await expect(btn).toContainText('Clicks: 2')
+ })
+
+ test('Vuetify primary theme color resolves on rendered button', async ({ mount, page }) => {
+ await mount(SanityButtonHarness)
+
+ // Vuetify exposes theme primary as "R, G, B" decimals on
+ // --v-theme-primary (e.g. "31, 122, 209" for #1f7ad1).
+ const themePrimary = await page.evaluate(() => {
+ const root = document.documentElement
+
+ return getComputedStyle(root).getPropertyValue('--v-theme-primary').trim()
+ })
+
+ expect(themePrimary).not.toBe('')
+ expect(themePrimary.split(',')).toHaveLength(3)
+ })
+})
diff --git a/apps/app/tests/playwright-ct/smoke.spec.ts b/apps/app/tests/playwright-ct/smoke.spec.ts
new file mode 100644
index 00000000..10ecff83
--- /dev/null
+++ b/apps/app/tests/playwright-ct/smoke.spec.ts
@@ -0,0 +1,8 @@
+import { expect, test } from '@playwright/experimental-ct-vue'
+
+// B1 smoke: proves Playwright Component Testing is wired and Chromium
+// boots. Replaces no functionality; deleted once a real component
+// test (B2 sanity) supersedes it.
+test('chromium boots in CT runner', async ({ page }) => {
+ expect(page).toBeTruthy()
+})
diff --git a/apps/app/tests/playwright-ct/utils/mountWithProviders.ts b/apps/app/tests/playwright-ct/utils/mountWithProviders.ts
new file mode 100644
index 00000000..8aabd5ba
--- /dev/null
+++ b/apps/app/tests/playwright-ct/utils/mountWithProviders.ts
@@ -0,0 +1,123 @@
+import type { Locator } from '@playwright/test'
+import type { Component } from 'vue'
+
+import type { HooksConfig } from '../../../playwright'
+
+/**
+ * mountWithProviders — Playwright Component Testing analogue of
+ * tests/utils/mountWithVuexy.ts.
+ *
+ * Why two helpers?
+ * ----------------
+ * Playwright CT's `mount()` API is structurally different from
+ * @vue/test-utils' `mount()`: provider plugins (Vuetify, Pinia,
+ * Router, VueQuery) must be registered in `playwright/index.ts`'s
+ * `beforeMount` hook rather than passed at call time. This helper
+ * is a thin ergonomic wrapper that:
+ *
+ * - Forwards `hooksConfig` to the beforeMount hook (typed via
+ * HooksConfig from playwright/index.ts)
+ * - Returns the standard Playwright Locator from CT's mount, so
+ * downstream test code uses normal Playwright assertions
+ * (component.click(), expect(component).toBeVisible(), etc.)
+ * - Serves as a single, discoverable surface for "how do I mount
+ * a component in this codebase" questions
+ *
+ * Lifecycle note (TEMPORARY VUETIFY):
+ * -----------------------------------
+ * The Vuetify plugin line in `playwright/index.ts`'s `beforeMount`
+ * hook is INTENTIONALLY temporary state. F3 (PrimeVue foundation,
+ * RFC-WS-FRONTEND-PRIMEVUE §6) replaces it with PrimeVue. We do NOT
+ * abstract behind a "pluggable UI framework" indirection because:
+ *
+ * 1. We are NOT retaining Vuetify; the abstraction would itself
+ * need to be removed in F3.
+ * 2. The swap is mechanical (~2-hour) and atomic; abstraction adds
+ * cognitive cost without paying back.
+ * 3. Reviewers seeing "Vuetify in test infra in a PrimeVue migration
+ * sprint" should read this JSDoc and dev-docs/ARCH-TESTING.md §6
+ * for context.
+ *
+ * Equivalence to mountWithVuexy.ts:
+ * ---------------------------------
+ * | Capability | Vitest (mountWithVuexy) | Playwright CT (this) |
+ * |----------------------------------|-------------------------------|----------------------|
+ * | Vuetify w/ tokens | createVuetify({components,…}) | beforeMount hook |
+ * | Pinia (actions execute) | createTestingPinia | createPinia |
+ * | TanStack Query (fresh client) | per-call new QueryClient | per-test in hook |
+ * | Memory-history router | per-call createRouter | per-test in hook |
+ * | initialPath / initialQuery | options.initialPath | hooksConfig.… |
+ * | Initial Pinia state | options.initialState | hooksConfig.piniaInitialState |
+ * | Notification mock | createNotificationMock + plug | (see assertNotification below) |
+ *
+ * Notification assertions:
+ * ------------------------
+ * Playwright runs in a separate Node process from the browser and
+ * cannot use `vi.fn()` spies on store actions. Instead, tests assert
+ * on the rendered UI (e.g. `await expect(page.getByRole('alert'))
+ * .toContainText('Saved')`) or read pinia state via page.evaluate.
+ * This is a deliberate divergence from the Vitest pattern — UI
+ * assertions are stronger than spy assertions for a real-browser
+ * runner.
+ *
+ * Type signature constraint:
+ * --------------------------
+ * Playwright CT's MountResult uses internal types. We accept the
+ * native MountOptions shape and wrap; tests should import the
+ * Playwright-CT `expect`/`test` and call `mountWithProviders` to
+ * get the locator back.
+ */
+export interface MountOptions {
+ props?: Record
+ slots?: Record
+ hooksConfig?: HooksConfig
+}
+
+// Re-export so callers have one import surface.
+export type { HooksConfig } from '../../../playwright'
+
+// The actual mount call must be done by the test using Playwright CT's
+// `mount` fixture (`test('…', async ({ mount }) => …)`). We expose a
+// helper that builds the options object correctly. This avoids the
+// "two ways to mount" footgun: there's the Playwright fixture, and
+// there's our wrapper that produces its arguments.
+export function buildMountArgs(
+ component: C,
+ opts: MountOptions = {},
+): {
+ component: C
+ options: { props?: Record; slots?: Record; hooksConfig?: HooksConfig }
+ } {
+ return {
+ component,
+ options: {
+ props: opts.props,
+ slots: opts.slots,
+ hooksConfig: opts.hooksConfig,
+ },
+ }
+}
+
+/**
+ * Convenience: assert on the notification store via the browser's
+ * window.__pinia exposed in the beforeMount hook. Returns the current
+ * notification state as serialised JSON.
+ */
+export async function readNotificationState(
+ componentLocator: Locator,
+): Promise<{ visible: boolean; message: string; type: string }> {
+ const page = componentLocator.page()
+
+ return page.evaluate(() => {
+ interface Win { __pinia?: { state: { value: Record } } }
+ const w = window as unknown as Win
+
+ const state = w.__pinia?.state?.value?.notification as
+ | { visible: boolean; message: string; type: string }
+ | undefined
+
+ return state
+ ? { visible: state.visible, message: state.message, type: state.type }
+ : { visible: false, message: '', type: 'info' }
+ })
+}
diff --git a/apps/app/tests/playwright-ct/visual/prototype-smoke.spec.ts b/apps/app/tests/playwright-ct/visual/prototype-smoke.spec.ts
new file mode 100644
index 00000000..2b7896c9
--- /dev/null
+++ b/apps/app/tests/playwright-ct/visual/prototype-smoke.spec.ts
@@ -0,0 +1,11 @@
+import { expect, test } from '@playwright/experimental-ct-vue'
+
+const PROTOTYPE_URL = `http://127.0.0.1:${process.env.PROTOTYPE_PORT ?? 5179}/crewli-timetable.html`
+
+test('prototype loads and renders', async ({ page }) => {
+ await page.goto(PROTOTYPE_URL)
+
+ // Wait for the React app to render. The first stage row appears
+ // once data + babel-transformed JSX are loaded.
+ await expect(page.locator('.cw-block').first()).toBeVisible({ timeout: 15_000 })
+})
diff --git a/apps/app/tests/playwright-ct/visual/prototype.spec.ts b/apps/app/tests/playwright-ct/visual/prototype.spec.ts
new file mode 100644
index 00000000..7bd6c6f6
--- /dev/null
+++ b/apps/app/tests/playwright-ct/visual/prototype.spec.ts
@@ -0,0 +1,203 @@
+import { expect, test } from '@playwright/experimental-ct-vue'
+
+// =============================================================================
+// VISUAL BASELINES — Artist Management surfaces (RFC §A.3 baseline scope)
+// =============================================================================
+//
+// SOURCE OF TRUTH: the Crewli prototype HTML at
+// resources/Crewli - Artist Timetable Management/crewli-timetable.html
+// This is the canonical visual reference Artist Management is measured
+// against during F4. When the prototype changes, baselines update with
+// `pnpm test:visual:update` and the diff PNG is reviewed in the PR.
+//
+// Tagged @visual so `pnpm test:visual` runs only this suite.
+//
+// Baseline strategy — composite over isolated:
+// --------------------------------------------
+// The prototype's DOM does not expose status, perf-id, or stage-id as
+// data-* attributes. Block status is encoded in inline `style.background`
+// only. Locating an individual block by status would require either
+// pixel-color sampling (brittle) or hardcoding artist names (locks the
+// test to specific data values, which would silently rot if the
+// prototype's seed data changes).
+//
+// Instead we capture COMPOSITE surfaces that contain the full visual
+// vocabulary side-by-side:
+// - Full Vrijdag canvas → all status colors, b2b, multi-lane
+// - Full Zaterdag canvas → conflict ring, capacity warning
+// - Wachtrij sidebar → list rendering, status badges, counts
+// - Popover (clicked from canvas) → popover layout
+// One screenshot covers many "surfaces" the prompt's §A.3 list
+// enumerates separately. F4 component-level Vue tests will provide
+// per-state isolated baselines using the live SPA's data-test-id
+// attributes (added during component migration).
+//
+// Surfaces NOT captured here are documented as test.skip() at the
+// bottom with the reason and what would be required to enable them.
+// =============================================================================
+
+const PROTOTYPE_URL = `http://127.0.0.1:${process.env.PROTOTYPE_PORT ?? 5179}/crewli-timetable.html`
+
+async function navigateAndStabilize(page: import('@playwright/test').Page) {
+ await page.goto(PROTOTYPE_URL)
+ await expect(page.locator('.cw-block').first()).toBeVisible({ timeout: 15_000 })
+
+ // Hide elements that vary visually between machines or are dev-only.
+ await page.addStyleTag({
+ content: `
+ *:focus { outline: none !important; }
+ .cw-tweaks-panel, .cw-tweak-btn { visibility: hidden !important; }
+ `,
+ })
+ await page.evaluate(() => document.fonts.ready)
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => resolve(undefined))))
+}
+
+test.describe('@visual Artist Management — prototype baselines', () => {
+ test.use({ viewport: { width: 1440, height: 900 } })
+
+ // ---------------------------------------------------------------------------
+ // CANVAS — full-page baselines per day
+ //
+ // Vrijdag (d_fr) covers status colors (option, requested, confirmed,
+ // contracted, concept), B2B indicators (auto-derived for adjacent
+ // perfs on hardstyle: p_1→p_2→p_3), and multi-lane stacking
+ // (p_4 / p_4b / p_4c overlap).
+ //
+ // Zaterdag (d_sa) covers conflict ring (p_18 vs p_19 overlap on
+ // s_urban) and capacity warning (p_14 draw 4800/cap 5000, p_15
+ // draw 5200/cap 5000 on s_hollandse).
+ // ---------------------------------------------------------------------------
+ test('canvas — Vrijdag (statuses + b2b + multi-lane)', async ({ page }) => {
+ await navigateAndStabilize(page)
+ await expect(page).toHaveScreenshot('canvas-friday.png', { fullPage: true })
+ })
+
+ test('canvas — Zaterdag (conflict + capacity warning)', async ({ page }) => {
+ await navigateAndStabilize(page)
+
+ // Day tab uses role="tab", not "button".
+ await page.getByRole('tab', { name: /Zaterdag/ }).click()
+ await page.waitForTimeout(200)
+ await expect(page).toHaveScreenshot('canvas-saturday.png', { fullPage: true })
+ })
+
+ // ---------------------------------------------------------------------------
+ // STAGE ROW — first row in isolation, captures multi-lane stacking
+ // detail. The prototype renders rows as .cw-tt-stage (label column)
+ // + a corresponding lane-band in the canvas. We capture the canvas
+ // element which spans 1440×row-height.
+ // ---------------------------------------------------------------------------
+ test('stage row — first row (Hardstyle, multi-lane on Vrijdag)', async ({ page }) => {
+ await navigateAndStabilize(page)
+
+ // The first .cw-tt-stage is hardstyle on Vrijdag. We capture the
+ // full row width by screenshotting the .cw-tt container's first
+ // visible row using a clip rectangle derived from the element.
+ const firstStage = page.locator('.cw-tt-stage').first()
+
+ await expect(firstStage).toBeVisible()
+
+ const stageBox = await firstStage.boundingBox()
+ if (!stageBox)
+ throw new Error('Could not measure first stage row')
+
+ await expect(page).toHaveScreenshot('stage-row-multilane.png', {
+ clip: { x: 0, y: stageBox.y, width: 1440, height: stageBox.height },
+ })
+ })
+
+ // ---------------------------------------------------------------------------
+ // WACHTRIJ — populated state (default seed data has parked + pending)
+ // ---------------------------------------------------------------------------
+ test('wachtrij — populated (default seed)', async ({ page }) => {
+ await navigateAndStabilize(page)
+
+ const queue = page.locator('aside.cw-parking').first()
+
+ await expect(queue).toBeVisible()
+ await expect(queue).toHaveScreenshot('wachtrij-populated.png')
+ })
+
+ // ---------------------------------------------------------------------------
+ // POPOVER — opens on block click
+ // ---------------------------------------------------------------------------
+ test('popover — opens on first-block click', async ({ page }) => {
+ await navigateAndStabilize(page)
+
+ // Click any visible block; the popover renders into a portal
+ // sibling of the block. We pick .cw-block first so we don't depend
+ // on artist names.
+ await page.locator('.cw-block').first().click()
+
+ const popover = page.locator('.cw-popover').first()
+
+ await expect(popover).toBeVisible({ timeout: 5_000 })
+ await expect(popover).toHaveScreenshot('popover.png')
+ })
+
+ // ---------------------------------------------------------------------------
+ // SKIPPED — surfaces not capturable from the canonical prototype.
+ // Each documents the gap so F4 component-level baselines pick them up.
+ // ---------------------------------------------------------------------------
+ test.skip('block — cancelled status (isolated)', async () => {
+ // Prototype data has no cancelled performance (data.js shows only
+ // concept|requested|option|confirmed|contracted appear). Status
+ // filter at timetable.jsx:227 also defaults cancelled OFF. To
+ // capture this baseline we'd modify prototype data — that would
+ // violate "we don't fake the prototype" (sprint prompt). Defer to
+ // F4 where the live SPA renders cancelled blocks against real
+ // data and we can baseline a dedicated cancelled fixture.
+ })
+
+ test.skip('block — B2B indicator (isolated)', async () => {
+ // Covered by canvas-friday.png composite (p_1→p_2→p_3 are
+ // perfectly back-to-back on hardstyle). An isolated block-level
+ // baseline would require artist-name-based locators that lock
+ // the test to specific data values. Defer to F4 where Vue
+ // components expose data-test-id="performance-block".
+ })
+
+ test.skip('block — capacity warning (isolated)', async () => {
+ // Covered by canvas-saturday.png composite. Same locator
+ // brittleness rationale as B2B above.
+ })
+
+ test.skip('block — conflict ring (isolated)', async () => {
+ // Covered by canvas-saturday.png composite.
+ })
+
+ test.skip('add-performance dialog — drag mode', async () => {
+ // Drag-mode requires a real mousedown→mousemove→mouseup sequence
+ // on the canvas. The prototype's drag detection uses raw mouse
+ // event math that's flaky under Playwright's page.mouse simulated
+ // events. F4 component test will use a stable drag-handle API on
+ // the live Vue component.
+ })
+
+ test.skip('add-performance dialog — button mode', async () => {
+ // The prototype does not expose a "+ Voorstelling" button at
+ // the canvas level — performances are only added via drag-from-
+ // wachtrij or by clicking an empty time slot. F4 component
+ // tests will baseline the live SPA's explicit "Voorstelling
+ // toevoegen" button.
+ })
+
+ test.skip('wachtrij — empty state', async () => {
+ // Prototype seed data ships with PARKED + PENDING populated;
+ // no UI flow drains the queue to empty. Defer to F4 component
+ // test against an empty-state fixture.
+ })
+
+ test.skip('wachtrij — grouped by status with counts', async () => {
+ // Grouping toggle exists (cw-pq-msel-btn) but its visual delta
+ // vs ungrouped is small and the toggle state is opaque to a
+ // post-render observer. Defer to F4 where the Vue component
+ // exposes state via data-test-state="grouped"|"ungrouped".
+ })
+
+ test.skip('canvas — empty day', async () => {
+ // Prototype only has d_fr and d_sa, both populated. No empty-day
+ // baseline available from canonical data. Defer to F4.
+ })
+})
diff --git a/apps/app/tests/playwright-ct/visual/static-server.mjs b/apps/app/tests/playwright-ct/visual/static-server.mjs
new file mode 100644
index 00000000..88234aef
--- /dev/null
+++ b/apps/app/tests/playwright-ct/visual/static-server.mjs
@@ -0,0 +1,72 @@
+/* eslint-disable no-console */
+import { createReadStream, statSync } from 'node:fs'
+import http from 'node:http'
+import path from 'node:path'
+
+// Tiny static-file server that serves the canonical Crewli prototype
+// HTML so Playwright visual baselines can render it in real Chromium.
+//
+// Why not http-server / serve / similar?
+// --------------------------------------
+// They'd add ~5 MB and another supply-chain hop for a 30-line problem.
+// The prototype directory has 9 files; we serve them with mime types
+// for .html, .css, .js, .jsx (text/babel handles via the HTML loader).
+// Node's built-in http + fs is sufficient.
+//
+// Started by playwright-ct.config.ts via webServer config.
+
+const ROOT = path.resolve(
+ process.cwd(),
+ '..',
+ '..',
+ 'resources',
+ 'Crewli - Artist Timetable Management',
+)
+const PORT = Number(process.env.PROTOTYPE_PORT ?? 5179)
+
+const MIME = {
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'application/javascript; charset=utf-8',
+ '.jsx': 'text/babel; charset=utf-8',
+ '.mjs': 'application/javascript; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.svg': 'image/svg+xml',
+ '.png': 'image/png',
+ '.ico': 'image/x-icon',
+}
+
+const server = http.createServer((req, res) => {
+ // Strip query / hash before path resolution.
+ const urlPath = (req.url ?? '/').split('?')[0].split('#')[0]
+ const safe = path.normalize(urlPath).replace(/^(\.\.[\\/])+/, '')
+ const filePath = path.join(ROOT, safe === '/' ? 'crewli-timetable.html' : safe)
+
+ // Reject path traversal.
+ if (!filePath.startsWith(ROOT)) {
+ res.statusCode = 403
+ res.end('Forbidden')
+ return
+ }
+
+ try {
+ const stat = statSync(filePath)
+ if (stat.isDirectory()) {
+ res.statusCode = 404
+ res.end('Not found')
+ return
+ }
+ const ext = path.extname(filePath).toLowerCase()
+ res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream')
+ res.setHeader('Cache-Control', 'no-store')
+ createReadStream(filePath).pipe(res)
+ }
+ catch {
+ res.statusCode = 404
+ res.end(`Not found: ${urlPath}`)
+ }
+})
+
+server.listen(PORT, '127.0.0.1', () => {
+ console.log(`[prototype-server] http://127.0.0.1:${PORT}/ -> ${ROOT}`)
+})
diff --git a/apps/app/tests/playwright-e2e/global-setup.ts b/apps/app/tests/playwright-e2e/global-setup.ts
new file mode 100644
index 00000000..2a3e888a
--- /dev/null
+++ b/apps/app/tests/playwright-e2e/global-setup.ts
@@ -0,0 +1,72 @@
+import { execSync } from 'node:child_process'
+import { existsSync, readFileSync } from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+
+// Playwright globalSetup — runs once before all e2e tests.
+//
+// Responsibilities:
+// 1. Verify api/.env exists (Laravel needs APP_KEY etc.)
+// 2. Run `php artisan migrate:fresh --force --seed --seeder=E2EBaselineSeeder`
+// against crewli_test (override DB_DATABASE on the command line).
+// The seeder writes seeded IDs to api/storage/app/e2e-fixtures.json.
+// 3. Verify the fixtures file was produced.
+//
+// Failures here abort the test run before any spec runs — prevents
+// confusing per-test failures when the test DB is in an unexpected state.
+//
+// Why crewli_test (and not a dedicated crewli_e2e DB):
+// The PHPUnit suite already uses crewli_test with `RefreshDatabase`
+// (transaction-rollback per test). Running e2e and PHPUnit
+// concurrently would collide; the lifecycle here assumes serial
+// execution, which is the realistic local-dev flow. Documented in
+// dev-docs/ARCH-TESTING.md §5 (mock-vs-real-backend).
+
+const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..')
+const API_DIR = path.join(REPO_ROOT, 'api')
+const FIXTURES_PATH = path.join(API_DIR, 'storage', 'app', 'e2e-fixtures.json')
+
+export default async function globalSetup(): Promise {
+ console.log('[e2e setup] Repo root:', REPO_ROOT)
+ console.log('[e2e setup] API dir:', API_DIR)
+
+ if (!existsSync(path.join(API_DIR, '.env'))) {
+ throw new Error(
+ '[e2e setup] api/.env not found. Run `cp api/.env.example api/.env` and configure DB_* + APP_KEY.',
+ )
+ }
+
+ console.log('[e2e setup] Running migrate:fresh + E2EBaselineSeeder against crewli_test...')
+
+ // --env=testing avoids the dangerous-bash hook's migrate:fresh guard
+ // (which requires --env=testing to permit the destructive op).
+ // DB_DATABASE override forces crewli_test even though the .env may
+ // point at the dev DB.
+ try {
+ execSync(
+ 'DB_DATABASE=crewli_test php artisan --env=testing migrate:fresh --force --seed --seeder="Database\\\\Seeders\\\\E2EBaselineSeeder"',
+ {
+ cwd: API_DIR,
+ stdio: 'inherit',
+ env: { ...process.env, DB_DATABASE: 'crewli_test' },
+ },
+ )
+ }
+ catch (err) {
+ throw new Error(`[e2e setup] migrate:fresh + seed failed: ${(err as Error).message}`)
+ }
+
+ if (!existsSync(FIXTURES_PATH)) {
+ throw new Error(
+ `[e2e setup] Expected fixtures file at ${FIXTURES_PATH} but it does not exist. `
+ + 'Did E2EBaselineSeeder run? Check the artisan output above.',
+ )
+ }
+
+ const fixtures = JSON.parse(readFileSync(FIXTURES_PATH, 'utf-8')) as Record
+
+ console.log('[e2e setup] Fixtures ready:', Object.keys(fixtures).join(', '))
+}
diff --git a/apps/app/tests/playwright-e2e/timetable/409-conflict.spec.ts b/apps/app/tests/playwright-e2e/timetable/409-conflict.spec.ts
new file mode 100644
index 00000000..571f0205
--- /dev/null
+++ b/apps/app/tests/playwright-e2e/timetable/409-conflict.spec.ts
@@ -0,0 +1,112 @@
+import { expect, test } from '@playwright/test'
+
+import { loginAsBaselineUser } from '../utils/auth'
+import { readFixtures } from '../utils/fixtures'
+
+// =============================================================================
+// TEST-CONTRACT-001 — 409 conflict shape on optimistic-locking failure
+// =============================================================================
+//
+// Why this test exists:
+// ---------------------
+// The timetable-stabilization sprint's B5 incident (commit ff056e3)
+// caught a schema-drift bug where the frontend's Zod schema for
+// MoveTimetableConflictResponse diverged from the backend's response
+// shape. Vitest+jsdom tests passed because they mocked both sides.
+// This test hits the REAL backend and validates the response shape
+// against what the SPA actually parses.
+//
+// Scenario:
+// ---------
+// 1. Login as e2e@test.local (seeded via E2EBaselineSeeder).
+// 2. POST /timetable/move with version=0 → expect 200 (succeeds; perf
+// version becomes 1).
+// 3. POST same move again with version=0 → expect 409 with shape:
+// { success: false, errors: { conflict: 'version_mismatch' }, ... }
+// 4. Assert structural properties of the 409 response so future
+// backend changes that drift this shape FAIL the test instead of
+// silently passing through mock-equipped Vitest assertions.
+//
+// What this test does NOT cover:
+// ------------------------------
+// - Multi-context race (two real browsers): single-context replay is
+// functionally equivalent for contract validation; the 409 is
+// returned by the server based on stored version, not on session
+// identity.
+// - UI rollback behaviour: that's a future UI-driven e2e that needs
+// the Vite webServer running. Out of B4 scope.
+// - Exhaustive 4xx/5xx variants: only the 409 shape is the documented
+// regression risk.
+// =============================================================================
+
+test.describe('TEST-CONTRACT-001 — timetable move 409 conflict', () => {
+ test('second move with stale version returns 409 with version_mismatch', async ({ browser }) => {
+ const fixtures = readFixtures()
+ const context = await browser.newContext({ baseURL: process.env.E2E_API_URL ?? 'http://localhost:8001' })
+ const { request, organisationId } = await loginAsBaselineUser(context)
+
+ expect(organisationId).toBe(fixtures.organisation_id)
+
+ const moveUrl = `/api/v1/organisations/${fixtures.organisation_id}/events/${fixtures.event_id}/timetable/move`
+
+ // Use a stable target time independent of the original seed, so
+ // we control where the perf lands. Take the seeded perf's
+ // engagement window and shift by an hour.
+ const targetStart = new Date()
+
+ targetStart.setUTCDate(targetStart.getUTCDate() + 2)
+ targetStart.setUTCHours(21, 0, 0, 0) // 23:00 Europe/Amsterdam summer
+
+ const targetEnd = new Date(targetStart.getTime() + 60 * 60 * 1000)
+
+ const fmt = (d: Date) => d.toISOString().slice(0, 19).replace('T', ' ')
+
+ const movePayload = {
+ performance_id: fixtures.performance_id,
+ target_stage_id: fixtures.stage_id,
+ target_start_at: fmt(targetStart),
+ target_end_at: fmt(targetEnd),
+ target_lane: 0,
+ version: 0,
+ }
+
+ // -----------------------------------------------------------------
+ // First move: expect 200, perf.version moves 0 → 1
+ // -----------------------------------------------------------------
+ const first = await request.post(moveUrl, {
+ data: movePayload,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Idempotency-Key': 'e2e-409-first',
+ },
+ })
+
+ expect(first.status(), `First move expected 200, got ${first.status()}: ${await first.text()}`).toBe(200)
+
+ // -----------------------------------------------------------------
+ // Second move with the SAME (now-stale) version=0: expect 409
+ // -----------------------------------------------------------------
+ const second = await request.post(moveUrl, {
+ data: movePayload,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Idempotency-Key': 'e2e-409-second',
+ },
+ })
+
+ expect(second.status()).toBe(409)
+
+ const conflictBody = await second.json() as {
+ success?: boolean
+ message?: string
+ errors?: { conflict?: string }
+ }
+
+ // Contract assertions — drift here means the SPA's Zod schema for
+ // MoveTimetableConflictResponse needs an update.
+ expect(conflictBody.errors?.conflict).toBe('version_mismatch')
+ expect(typeof conflictBody.message).toBe('string')
+
+ await context.close()
+ })
+})
diff --git a/apps/app/tests/playwright-e2e/utils/auth.ts b/apps/app/tests/playwright-e2e/utils/auth.ts
new file mode 100644
index 00000000..1a24e5f6
--- /dev/null
+++ b/apps/app/tests/playwright-e2e/utils/auth.ts
@@ -0,0 +1,60 @@
+import type { APIRequestContext, BrowserContext } from '@playwright/test'
+
+import { readFixtures } from './fixtures'
+
+// Login helper for Playwright e2e tests.
+//
+// Uses the SPA-style Bearer-via-cookie flow (see
+// api/.../Traits/SetAuthCookie.php): POST /api/v1/auth/login returns a
+// `crewli_app_token` httpOnly cookie. Subsequent /api/v1/* requests in
+// the same browser context carry it automatically because Playwright's
+// request fixture inherits cookies from the BrowserContext.
+//
+// NOT sanctum-stateful (CSRF-cookie + X-XSRF-TOKEN). The custom
+// CookieBearerToken middleware (api/bootstrap/app.php) reads the
+// auth cookie directly.
+
+export interface LoginResult {
+ request: APIRequestContext
+ userId: string
+ organisationId: string
+}
+
+/**
+ * Authenticates the e2e baseline user against a freshly-seeded
+ * Laravel test server. Returns the request context (auth cookie set)
+ * and the user/org IDs from the response.
+ */
+export async function loginAsBaselineUser(context: BrowserContext): Promise {
+ const fixtures = readFixtures()
+
+ const response = await context.request.post('/api/v1/auth/login', {
+ data: {
+ email: fixtures.user_email,
+ password: fixtures.user_password,
+ },
+ headers: { 'Content-Type': 'application/json' },
+ })
+
+ if (!response.ok()) {
+ throw new Error(
+ `Login failed: ${response.status()} — ${await response.text()}`,
+ )
+ }
+
+ const body = await response.json() as {
+ success: boolean
+ data: {
+ user: {
+ id: string
+ organisations: Array<{ id: string; role: string }>
+ }
+ }
+ }
+
+ return {
+ request: context.request,
+ userId: body.data.user.id,
+ organisationId: body.data.user.organisations[0].id,
+ }
+}
diff --git a/apps/app/tests/playwright-e2e/utils/fixtures.ts b/apps/app/tests/playwright-e2e/utils/fixtures.ts
new file mode 100644
index 00000000..1c7012af
--- /dev/null
+++ b/apps/app/tests/playwright-e2e/utils/fixtures.ts
@@ -0,0 +1,42 @@
+import { readFileSync } from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+
+// Reads the seeded IDs that E2EBaselineSeeder wrote during globalSetup.
+// Tests use these IDs to construct API URLs and verify response shapes.
+
+export interface E2EFixtures {
+ user_email: string
+ user_password: string
+ organisation_id: string
+ event_id: string
+ stage_id: string
+ performance_id: string
+}
+
+const FIXTURES_PATH = path.resolve(
+ __dirname,
+ '..',
+ '..',
+ '..',
+ '..',
+ '..',
+ 'api',
+ 'storage',
+ 'app',
+ 'e2e-fixtures.json',
+)
+
+let cached: E2EFixtures | null = null
+
+export function readFixtures(): E2EFixtures {
+ if (cached)
+ return cached
+
+ cached = JSON.parse(readFileSync(FIXTURES_PATH, 'utf-8')) as E2EFixtures
+
+ return cached
+}
diff --git a/dev-docs/ARCH-TESTING.md b/dev-docs/ARCH-TESTING.md
new file mode 100644
index 00000000..ee2c3e9c
--- /dev/null
+++ b/dev-docs/ARCH-TESTING.md
@@ -0,0 +1,342 @@
+# Crewli — Test Architecture
+
+> Authoritative reference for test-tier choices in the SPA. Read this
+> before adding a new test. Linked from `CLAUDE.md`.
+
+This document describes:
+
+1. The test pyramid Crewli uses, and what each tier is for
+2. When to use which tier (decision tree)
+3. Mock-vs-real-backend rules
+4. Visual baseline workflow
+5. CI integration status
+6. Conventions and anti-patterns
+7. Vuetify-during-PrimeVue-migration: the temporary state in test infra
+8. Host setup requirements
+9. Deferred work (BACKLOG references)
+
+---
+
+## 1. Test pyramid and scope per layer
+
+Crewli runs five test tiers in the SPA. Each has a narrow purpose;
+overlap is wasted work, gaps are silent risk. Pick the tier whose
+purpose matches what you're actually verifying.
+
+### Tier 1 — Unit (Vitest + happy-dom)
+
+**Run via:** `pnpm test` (filtered by `tests/unit/**`)
+**Environment:** Node + happy-dom, single module graph
+**Cost:** ~20 ms per test
+**For:** Pure logic, schema parsing, store reducers, isolated composable
+behaviour. No DOM. Fastest tier; safe for pre-commit if we ever add it.
+
+### Tier 2 — Component (Playwright Component Testing)
+
+**Run via:** `pnpm test:component`
+**Environment:** Real Chromium via `@playwright/experimental-ct-vue`
+**Cost:** ~300 ms per test (incl. Chromium reuse)
+**For:** Single-component verification. DOM rendering, click/keyboard,
+prop propagation, slot rendering, CSS resolution. Mocks API at axios
+layer. Provider stack (Vuetify [TEMP], Pinia, TanStack Query, Router) is
+wired in `apps/app/playwright/index.ts`'s `beforeMount` hook.
+
+### Tier 3 — Integration (Playwright CT, multi-component)
+
+**Run via:** Same `pnpm test:component` runner; placement convention
+distinguishes integration from single-component.
+**Cost:** ~500 ms per test
+**For:** Page-level mounting with mocked API responses. Tests
+cross-component coordination (drag from Wachtrij → canvas, popover
+→ mutation flow). Same provider stack as Tier 2.
+
+### Tier 4 — Visual regression (Playwright CT, `@visual` tag)
+
+**Run via:** `pnpm test:visual` (verify), `pnpm test:visual:update`
+(regenerate baselines)
+**Environment:** Real Chromium driving the canonical prototype HTML
+served by a tiny static-server fixture (`tests/playwright-ct/visual/
+static-server.mjs`).
+**Cost:** ~1.2 s per test
+**For:** Pixel baselines against canonical visual sources. The
+prototype HTML at `resources/Crewli - Artist Timetable Management/
+crewli-timetable.html` is the source of truth for Artist Management
+surfaces. F4 (component migration) extends visual coverage to live
+SPA components against the prototype.
+
+### Tier 5 — E2E (Playwright)
+
+**Run via:** `pnpm test:e2e`
+**Environment:** Real Laravel test server (`php artisan serve --port=
+8001`, DB `crewli_test`) + real Chromium browser context.
+**Cost:** ~5 s for the suite (includes migrate:fresh + seed)
+**For:** Contract verification end-to-end. Real network, real auth,
+real DB transactions. Currently only the 409-conflict optimistic-
+locking contract test (TEST-CONTRACT-001). Add tests sparingly — this
+is the most expensive tier.
+
+---
+
+## 2. When to use what — decision tree
+
+*First match wins — stop at the first YES.*
+
+```
+Is the thing under test pure logic with no DOM?
+ └─ YES → Unit (Vitest + happy-dom)
+
+Is it a single component? (props, events, slots, CSS, keyboard)
+ └─ YES → Component (Playwright CT)
+
+Is it cross-component coordination, but no real backend?
+ └─ YES → Integration (Playwright CT)
+
+Is it a contract between SPA and backend (request/response shape)?
+ └─ YES → E2E (Playwright + Laravel)
+
+Is it visual fidelity to a canonical baseline?
+ └─ YES → Visual (Playwright CT, @visual tag)
+```
+
+**Don't pick by speed.** Pick by what you're verifying. A unit test
+that mocks the backend cannot catch a contract-drift bug; an e2e test
+for pure logic is wasted CI time.
+
+---
+
+## 3. Mock-vs-real-backend choice rules
+
+### Mock when
+
+- The test verifies SPA behaviour given a known response shape
+- Backend availability would slow the test below the relevant tier's
+ cost budget
+- The path under test is independent of transactional / auth
+ semantics
+
+### Real backend when
+
+- The test verifies the contract between frontend and backend (Zod
+ schema vs. PHP Resource shape)
+- Authentication or authorisation flows are involved
+- Optimistic-locking, idempotency, or other multi-request semantics
+ matter
+
+**Anti-pattern: matching mocks to schemas.** Don't mock with the same
+shape your Zod schema validates — that creates self-confirming bias
+where both sides agree but neither matches reality. This is the
+exact failure mode TEST-CONTRACT-001 was created to catch (timetable-
+stabilization B5).
+
+---
+
+## 4. Visual baseline workflow
+
+### Capturing baselines
+
+```bash
+pnpm test:visual:update
+```
+
+Diffs are reviewed in PRs. Baselines live at:
+```
+apps/app/tests/playwright-ct/__screenshots__/visual//.png
+```
+Tracked via Git LFS (see `.gitattributes`). Pixel tolerance:
+`maxDiffPixelRatio: 0.001` (0.1%) per `playwright-ct.config.ts`.
+
+### Updating baselines (intentional UX change)
+
+1. Make the UX change (component edit, token edit, …)
+2. Run `pnpm test:visual:update` locally
+3. Review the diff PNG manually — does the new baseline match the
+ intended UX?
+4. Commit baseline + UX change in the **same PR**. Reviewer can
+ compare baseline change against the UX intent.
+5. Never update baselines to "make tests pass" without a UX-justified
+ reason in the PR description.
+
+### Updating baselines (unintentional diff in CI)
+
+1. Determine if the diff is environmental (font hinting, OS rendering,
+ timezone-based date formatting) or a real regression.
+2. Environmental → consider tightening determinism (lock fonts, fake
+ timers, fixed locale) before tweaking tolerance.
+3. Real regression → fix the regression, not the baseline.
+
+### Composite-over-isolated strategy (B3 baselines)
+
+Some surfaces enumerated in RFC §A.3's baseline list are captured as
+composite views rather than individual block-state baselines. Reason:
+the prototype's DOM exposes status only via inline `style.background`,
+no `data-*` attributes. Isolated locators (e.g. by artist name) lock
+the test to specific seed data and silently rot if data changes.
+
+The current 5 baselines cover the visual vocabulary:
+
+| File | Captures |
+| ----------------------------- | ------------------------------------------------------- |
+| `canvas-friday.png` | Status colors, b2b indicators, multi-lane stacking |
+| `canvas-saturday.png` | Conflict ring, capacity warning |
+| `stage-row-multilane.png` | First row in isolation |
+| `wachtrij-populated.png` | Sidebar list rendering, status badges, counts |
+| `popover.png` | Block-click popover layout |
+
+9 additional surfaces are documented as `test.skip()` in
+`tests/playwright-ct/visual/prototype.spec.ts` with the gap reason.
+F4 component migration adds isolated baselines using stable
+`data-test-id` attributes on Vue components.
+
+---
+
+## 5. CI integration
+
+**Status: deferred.** The repo currently has no CI runner configured.
+Local development workflow:
+
+- Vitest (`pnpm test`) — tier 1, runs on demand
+- Playwright Component (`pnpm test:component`) — tiers 2–4, runs on
+ demand
+- Playwright E2E (`pnpm test:e2e`) — tier 5, runs on demand against a
+ developer-managed Laravel test server
+
+CI design (Gitea Actions vs. GitHub Actions decision, Linux runner
+image with PHP+MySQL+Node+pnpm, screenshot-diff artifact upload,
+label-gated nightly e2e) is captured as `TEST-INFRA-002` in
+`dev-docs/BACKLOG.md`.
+
+When CI lands:
+
+- Pre-commit (lefthook): Vitest unit only. Fast, no Playwright launch.
+- PR-CI: Vitest unit + Playwright component + visual. Slower but full
+ coverage.
+- Nightly / label-gated: Playwright e2e against real Laravel + MySQL.
+ Most expensive tier.
+
+---
+
+## 6. Conventions
+
+- **Test file naming:** `*.spec.ts` for Playwright (CT + e2e),
+ `*.test.ts` for Vitest. The runner config glob keeps them apart.
+- **`@visual` tag:** required on all visual-regression tests so
+ `--grep @visual` filters them.
+- **Provider stack for CT:** wired in `apps/app/playwright/index.ts`'s
+ `beforeMount` hook, not at mount call time. Tests forward
+ per-test overrides via `hooksConfig` (see
+ `tests/playwright-ct/utils/mountWithProviders.ts`).
+- **E2E test isolation:** `globalSetup` runs `migrate:fresh + seed`
+ once per `pnpm test:e2e` invocation. Tests within one run share DB
+ state. Re-run = fresh DB.
+- **Pixel tolerance:** `maxDiffPixelRatio: 0.001` default
+ (`playwright-ct.config.ts`). Per-test exceptions allowed if
+ documented inline.
+- **Auth in e2e tests:** Bearer-via-cookie (`api/.../SetAuthCookie.php`).
+ POST `/api/v1/auth/login` returns `crewli_app_token` httpOnly cookie.
+ No CSRF dance, no Sanctum stateful flow. baseURL must be
+ `localhost:8001` (matching the cookie's `domain=localhost`),
+ **not** `127.0.0.1:8001`.
+
+### Anti-patterns to avoid
+
+1. **Mocking the same data shape that the schema validates** —
+ creates self-confirming bias. Use real backend for contract tests
+ (TEST-CONTRACT-001 catches this class of bug).
+2. **Updating baselines silently** without diff review or a UX-
+ justified PR description.
+3. **Adding Playwright tests for pure logic** that Vitest can cover
+ in 20 ms. Reserve Playwright for tests that need the browser.
+4. **Treating "small" UX changes as not needing visual updates** —
+ there is no small visual change in an enterprise product; the
+ user notices.
+5. **Brittle locators** by data values (artist names, stage names)
+ instead of stable test IDs. F4 will add `data-test-id` to Vue
+ components for this reason.
+
+---
+
+## 7. Vuetify in test infrastructure during the PrimeVue migration
+
+`apps/app/playwright/index.ts`'s `beforeMount` hook registers Vuetify
+as a Vue plugin. This is **intentional temporary state**.
+
+### Why
+
+The current SPA still ships Vuetify. Component-level Playwright CT
+tests must mount components against the same UI framework the live
+app uses, otherwise they would test a non-existent surface. Stripping
+Vuetify from test infra now would make CT tests un-runnable until
+F3 lands PrimeVue.
+
+### When it ends
+
+F3 (PrimeVue foundation, RFC-WS-FRONTEND-PRIMEVUE §6) replaces the
+Vuetify plugin line in `playwright/index.ts` with PrimeVue and
+updates `tests/playwright-ct/components/sanity-vuetify.spec.ts` to
+its PrimeVue equivalent. Estimated effort: ~2 hours (mechanical
+swap, no architecture change).
+
+### Why not abstract
+
+The instinct of "abstract the UI framework provider so we can swap
+without touching test code" is a **deferred-cost trap** here:
+
+1. We are NOT retaining Vuetify post-F3. The abstraction would itself
+ need to be removed in F4 alongside the framework swap.
+2. The swap is mechanical (~2 hours). An abstraction layer would take
+ longer to design well than the swap itself takes.
+3. Reviewers seeing "Vuetify in test infra in a PrimeVue migration
+ sprint" should read this section + the JSDoc on
+ `mountWithProviders.ts` for context.
+
+The forbidden pattern: do not propose "let's make a `UIFrameworkPlugin`
+interface and dependency-inject the provider per test" during F2/F3.
+That's exactly the abstraction this section forbids.
+
+---
+
+## 8. Host setup requirements
+
+For Playwright tests to run, the host must have:
+
+- **Node v22+** with **pnpm 10+** (matching `apps/app/`'s expectations)
+- **Chromium** installed via `pnpm exec playwright install chromium`
+ (downloads to `~/Library/Caches/ms-playwright` on macOS)
+- **Git LFS** installed (`brew install git-lfs` on macOS) and active
+ (`git lfs install --skip-repo` to avoid hook conflict with lefthook;
+ the LFS pre-push step is delegated through `lefthook.yml`)
+- **MySQL 8** running locally via `make services` for e2e tests, with
+ the `crewli_test` database created via `make test-db-create`
+- **PHP 8.2+ + composer** for the Laravel test server in e2e tests
+- **`api/.env`** present with valid `APP_KEY` (e2e `globalSetup`
+ inherits this; only `DB_DATABASE` is overridden to `crewli_test` on
+ the command line)
+
+### Known risks
+
+- **`unpkg.com` dependency** — the prototype HTML loads React + Babel
+ from unpkg.com via `