vue/no-reserved-component-names is error-level; <dialog> is native HTML.
Matching is via the stubs key + findComponent reference, not the stub's
own name, so the rename is behaviour-neutral (14/14 tests still pass).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces crewli-starter's hand-rolled Teleport/scrim/keydown Escape
pattern with a typed PrimeVue Dialog wrapper. v-model:open via writable
computed; slots #tabs/#footer gated; 12 unit tests all passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds :key="component" to <component :is="Body"> so opening drawer B
while A is open fully remounts (A's instance/state can't leak into B) —
a real defect for a primary shell overlay. Same-name reopen still
relies on the body reacting to prop changes (documented inline).
Companion test asserts the cross-component switch swaps the body
cleanly (A unmounts, B mounts). Addresses the Task 5 code-review
Important finding.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds composables/drawerRegistry.ts (boundary-safe register-by-call map:
register/resolve, zero static component imports — composables zone may
not import components, RFC-WS-GUI-REDESIGN AD-G5). Extends useRightDrawer
with resolveDrawerComponent (thin facade, prior API/tests preserved).
RightDrawer.vue: PrimeVue <Drawer position=right>, v-model:visible via a
writable computed ↔ useRightDrawer isOpen/close; title/flush read from the
open() props object (A4); dynamic <component :is> via resolveDrawerComponent
with a graceful empty state on null; #actions header slot retained. 18
unit/component tests.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- FIX A (IMPORTANT): PrimeVue Breadcrumb ignores `route` key; map non-last
items with `command: () => router.push(item.to)` for real client-side nav
- FIX B: add type="button" to all 6 native <button> chrome elements
- FIX C: authStore.logout() bare call matches project no-void pattern
- FIX D: document param-route edge case in toBreadcrumbItems
- FIX E: regression test asserts command+push on non-last, no command on last,
no `route` key on any item
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- useBreadcrumb composable: pure toBreadcrumbItems() helper + thin
useRoute() wrapper; route-driven, no prop coupling
- AppTopbar: hamburger→setMobileOpen, theme/density toggles→shell store,
PrimeVue Breadcrumb/OverlayBadge/Popover/Avatar/Menu; replaces all
manual document.mousedown listeners with PrimeVue built-in dismissal;
notifications stubbed (useNotificationStore is a toast queue, not a
feed — TODO TECH-WS-GUI-REDESIGN); sign-out→authStore.logout()
- Unit tests: 10 breadcrumb + 6 AppTopbar assertions (16 total, all pass)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CRITICAL: replace `lg:hidden` on PrimeVue Drawer with `v-if="isMobile"` so the
teleported portal/overlay is never created on desktop viewports regardless of
mobileOpen state. Replace useMediaQuery raw string in SidebarHeader with
useBreakpoints(breakpointsTailwind).smaller('lg') shared by both components.
Add desktop/mobile comments; adapt tests to useBreakpoints mock; add
Drawer-absent-on-desktop and aside w-16/w-64 width-class assertions (21 tests).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ports crewli-starter's monolithic AppSidebar.vue into two typed production
components: SidebarHeader (the .brand block) and AppSidebar (composing
SidebarHeader + SidebarNav + WorkspaceSwitcher). AppSidebar renders a
permanent <aside> on desktop (lg+) and a PrimeVue Drawer on mobile, both
wired to useShellUiStore for collapse/mobile state.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ports crewli-starter WorkspaceSwitcher into the Crewli SPA as production
TypeScript: PrimeVue Popover replaces the manual click-outside listener,
data is derived from useAuthStore/useOrganisationStore (no new store), gradient
pairs are deterministic via a new pure util with full Vitest coverage.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- FIX 1: Replace <button @click="router.push"> with <RouterLink custom>
+ <a> for real link semantics (middle-click, ⌘-click, screen-reader);
custom isNavItemActive prefix-match stays the active source of truth;
adds :aria-current="page" on active items; drops useRouter/router.push.
RouterLink to prop cast via itemTo() helper (RouteLocationRaw from
unplugin-vue-router) to satisfy typed RouterLinkTyped<RouteNamedMap>.
- FIX 2: Align .nav-item comment to actual template values (py-[9px]
rounded-md, not CSS vars); replace inaccurate Tailwind v3/v4 before:
composability justification in <style scoped> with the real reason
(accent bar at left:-10px is clipped by the overflow-y-auto nav).
- FIX 3: text-left → text-start (logical property, RTL-safe).
- FIX 4: Document id=route-name assumption in useV2Nav.ts with a
one-line comment at the id: assignment.
- FIX 5: Reword misleading "dotted names" spec description to state
the real invariant (id = v1 route name, already kebab-case).
- FIX 6: Add 2 tests — useV2Nav wrapper .value equality, and
consecutive-headings edge case (empty-items group produced).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ports crewli-starter's sidebar nav into the SPA as production TS:
V2NavGroup/V2NavItem types, a pure toV2NavGroups adapter wrapped by
useV2Nav(items) (composables zone can't import @/navigation, so the
v1 nav array is passed in — the layout supplies orgNavItems in Task 7),
a pure isNavItemActive helper, and SidebarNav.vue (props-only,
router-driven nav, route-based active state, collapsed mode, main.css
translated to Tailwind inline). 16 unit tests. Icon import is
allowed via the components-foundation bridge (no eslint-disable).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the Task 3 stub in pages-v2/dashboard.vue with the boot-proof
page (data-testid v2-dashboard, correct definePage meta). Adds the
Playwright CT smoke (appshell-boot.spec.ts) that mounts AppShellV2 in
Chromium and asserts both the shell root and slot content are visible;
uses page scope for the root-element assertion (CT component locator
only matches descendants, not the root itself). Full Plan-1 gate green:
typecheck 0 new errors, eslint clean, 5 vitest files / 21 tests + 2
component tests, vite build succeeded, typed-router has v2-dashboard.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tailwind-grid shell skeleton with named slot regions (sidebar, topbar,
default, drawer). OrganizerLayoutV2 wires the skeleton with RouterView,
selectable via definePage meta. Vitest component mount test: 2 tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds three new eslint-plugin-boundaries element zones and their matrix
rows so the GUI-redesign v2 surface is structurally isolated: v1 code
cannot import from v2 (back-porting forbidden), v2 can reach the
narrow FormField/Icon bridge via the components-foundation zone, and
pages-v2 can import from components-v2. Backed by a Vitest spec
running via the ESLint Node API (node environment; happy-dom's
document object breaks the case-police resolver). Adds a placeholder
src/components-v2/shared/X.vue so the resolver can classify the
import target during the test (unresolvable imports are not boundary-
checked by the plugin).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Moves the `v2-` de-dup (needed because getPascalCaseRouteName folds the
v2/ URL segment into the base) into the unit-tested v2RouteName helper
and simplifies the vite.config.ts call site to v2RouteName(raw, nodePath).
Removes the duplicated isV2 detection. No behavioural change: /v2/dashboard
still resolves to route name v2-dashboard; v1 names unchanged. Addresses
the Task 3 code-review Important finding.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a second routesFolder (src/pages-v2 -> /v2/) and extends
getRouteName so v2 routes get a v2- NAME prefix, preventing collisions
with same-named v1 pages. getPascalCaseRouteName already folds the v2/
URL segment into the base name, so the leading v2- is stripped before
v2RouteName re-adds the canonical prefix (avoids v2-v2-dashboard).
Includes the regenerated typed-router.d.ts and a boot-proof
pages-v2/dashboard.vue placeholder.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tailwind's lg:hidden loses to PrimeVue's .p-button { display: inline-flex }
due to equal specificity but later cascade order. Resulted in the mobile
hamburger remaining visible on desktop, allowing the Drawer to open over
the already-visible permanent sidebar.
Fix: wrap mobile-only cluster (hamburger + title) in a plain <div lg:hidden>
so the wrapper owns the visibility toggle. The wrapper is not a PrimeVue
component, so no specificity competition.
The Drawer itself had the same anti-pattern (class="lg:hidden") and is
worse, because PrimeVue Drawer teleports to body — a wrapping div on the
parent does not isolate the teleported overlay, and a class on the Drawer
root loses to .p-drawer { display: flex } when visible. Converted to
v-if="!isLg" driven by useMediaQuery('(min-width: 1024px)'). Vue simply
does not render the component on lg+, so no display rule competes.
Audited all 5 layouts for the same anti-pattern:
- AppShell.vue — fixed (Button + Drawer described above)
- default.vue / OrganizerLayout.vue / PortalLayout.vue — delegate to
AppShell; no PrimeVue elements with responsive classes
- blank.vue — plain <div>, no PrimeVue
- PublicLayout.vue — plain <main>, no PrimeVue
useMediaQuery is auto-imported via unplugin-auto-import's @vueuse/core
entry in vite.config.ts; explicit imports get stripped by the post-edit
ESLint --fix hook as redundant.
F3-introduced bug (commit 43915501); surfaced during F3.5 testing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
main.ts explicitly calls installPrimeVue(app) AFTER registerPlugins(app)
per the comment in main.ts ("so PrimeVue lives outside the Vuexy @core
machine"). The intent was a single registration site outside the
auto-discovery loop.
Bug: registerPlugins (src/@core/utils/plugins.ts) globs
plugins/*/index.{ts,js} eagerly and invokes the `default` export of
each match. plugins/primevue/index.ts was exporting installPrimeVue
as the default, so registerPlugins also picked it up and called it.
End result: PrimeVue and its three services (Toast, Confirmation,
Dialog) were each registered twice on every app boot. Visible
symptoms: duplicate Toast emissions on a single Toast.add() call,
and ConfirmationService callbacks firing twice for one user
confirmation.
Fix: convert `export default function installPrimeVue` to a NAMED
export, and update main.ts's import to `{ installPrimeVue }`. The
registerPlugins glob still picks up the module path but the
`pluginImportModule.default?.(app)` invocation becomes a no-op via
optional chaining (no default export to call). main.ts remains the
single registration site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The PrimeVue side of the parallel-mode app renders icons via
@iconify/vue's <Icon> component (src/components/Icon.vue). At runtime
@iconify/vue resolves an icon name like "tabler-eye" by looking up
its data in the in-memory icon registry; on a miss it falls back to
fetching https://api.iconify.design/tabler/eye.json. The CSP blocks
that origin, so every Tabler icon used in AppShell, SidebarHeader,
SidebarUserCard, and the migrated login form rendered as an empty
<svg viewBox="0 0 16 16"></svg>.
New plugins/iconify.ts loads the full Tabler set
(@iconify-json/tabler/icons.json, already in package.json as 1.2.23)
and registers it via addCollection() at module-load time. main.ts
side-effect-imports it before any other import so the registry is
warm before the first Icon mounts.
This is a NEW concern, separate from the existing plugins/iconify/
(index.ts + icons.css) which generates Vuexy-style i-tabler-* CSS
classes for Vuetify's VIcon adapter. The two systems must coexist
during F3–F6 parallel mode; the legacy directory can be deleted
alongside Vuetify when F6 lands.
Bundle cost: ~1.9 MB uncompressed JSON, ~400 KB gzipped in the main
chunk. Per-icon imports are a future optimisation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
unplugin-vue-components' Components({ dirs }) in vite.config.ts only
scans src/components, src/@core/components, and src/views/demos. The
sub-components introduced in B1/B3 live under src/layouts/components/,
which is NOT in the auto-import scan path. Without an explicit script
import, Vue renders <SidebarHeader> and <SidebarUserCard> as unknown
HTML elements (no DOM output, no errors), which is why the topbar and
sidebar-bottom cards looked empty in browser inspection.
Adding the two imports inline with the existing Icon import keeps the
component graph explicit. The post-edit eslint --fix hook preserves
the imports because the template usages (already present from B1 and
B3) make vue-eslint-parser see them as used.
The original B1/B3 commits had the imports stripped by the hook
because the imports were added in a separate Edit *before* the
template usages — eslint --fix correctly removed them as unused at
that moment, and the next Edit added the template usage but not the
import.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Left side gains a desktop-only breadcrumb "Organisation / Page title"
using the current organisation from useAuthStore and a page title
resolved by:
1. route.meta.title (if a page sets it explicitly), then
2. matching the active route name against the navItems prop, then
3. humanizing the route name as a last-resort fallback.
The chevron separator is suppressed when either side is empty, so
portal and pre-org users see just the page title. Mobile preserves
the existing hamburger + title text (the breadcrumb is hidden on
<lg to keep the topbar single-row).
Right side gains a notification bell and a help icon. The bell is a
visual placeholder (no badge) — clicking shows a PrimeVue Toast
"Notificaties komen binnenkort beschikbaar" until the notification
framework lands as a separate sprint.
The help icon would normally open https://docs.crewli.app in a new
tab, but the host currently serves with a TLS cert that does not
cover the name (ERR_TLS_CERT_ALTNAME_INVALID), so the click handler
falls back to a Toast. A TODO comment in the source records the
target URL and the one-line switch to make once the cert is fixed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Consolidates the user menu into a single sidebar-bottom location.
SidebarUserCard.vue shows avatar (initial), full name, role (Dutch
label mapped from org pivot role or 'Super Admin' fallback) and a
chevron-up that opens a PrimeVue Menu with "Mijn Profiel" and
"Uitloggen". The Menu uses popup mode; PrimeVue v4's absolutePosition
logic auto-flips above the trigger when the panel would overflow the
viewport bottom — verify in Phase C.
AppShell loses the topbar avatar Button + Menu and the associated
state (userMenuRef, userInitial, userMenuItems, toggleUserMenu) plus
its imports (Avatar, Menu, useAuthStore, computed). The component is
now a pure layout shell with no auth-store coupling. The topbar's
right side is intentionally empty in this commit; B4 fills it with
breadcrumb / notification bell / help icon.
Layout: nav uses min-h-0 flex-1 overflow-y-auto so it shrinks under
viewport pressure and lets the user card stay pinned at the bottom
of the sidebar. Mobile Drawer's content pt-override sets the same
flex-column behaviour so the user card sits flush at the bottom of
the drawer overlay.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Section headings ("Beheer" / organisation name, "Platform") were
already uppercase + muted but read as bold paragraph dividers more
than as quiet group markers. Tighten letter-spacing, drop weight
from semibold to medium, lighten the color one step (surface-500 →
surface-400), and shrink text to 11px so the headings recede and
let the nav items themselves carry the visual weight.
Spacing nudged from mt-4/mb-2/px-2 → mt-6/mb-1/px-3: more breathing
room above each group, less below (the items already have py-2 on
top), and the heading left-edge now lines up with the icons of the
nav items beneath it (both at px-3).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces SidebarHeader.vue — a PrimeVue-only org-switcher that
replaces the centered Crewli wordmark at the top of the sidebar. The
component mirrors the legacy Vuetify OrganisationSwitcher (avatar with
org initials, organisation name, plan-tier placeholder, dropdown
chevron, PrimeVue Menu of available orgs) but cannot reuse it
directly per the R-10 layout-shell-isolation invariant.
Plan-tier shows a hardcoded "Pro" placeholder until the backend
Organisation resource exposes a plan field — tracked separately, not
in F3.5 scope. When the user has no active organisation (portal
users, fresh super_admin), the component degrades to the original
title block so PortalLayout continues to read "Crewli Portal".
Desktop sidebar width bumped w-64 → w-72 (256 → 288 px) to give the
org-switcher card breathing room and accommodate the user-info card
arriving in B3. Mobile Drawer width bumped 16rem → 18rem to match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Installs Storybook 10.4 in apps/app/ as a component-development and
autodoc tool. Configures viteFinal with all seven SPA aliases so
stories resolve imports identically to the dev/build pipeline.
preview.ts reuses @/plugins/primevue's installPrimeVue() so Storybook
stays in lock-step with main.ts whenever the PrimeVue config changes.
Only the addons we need are wired: addon-docs (autodocs) and
addon-a11y (axe-core checks). addon-interactions is intentionally
omitted — interaction testing stays in Playwright CT per the testing
architecture.
Seed stories: PrimeVue Button (Primary/Secondary/Danger), Tailwind
utility box, and FormField (Default/WithError/Disabled) wrapped in
@primevue/forms Form + Zod resolver.
Adds make storybook target alongside make app / make docs.
Replaces the Vuetify VForm + AppTextField + VBtn stack with the F3
form pattern: @primevue/forms' <Form> with a Zod resolver, the
project-owned <FormField> wrapper from B5, and PrimeVue InputText /
Password / Checkbox / Button at the input layer. Surrounding chrome
(VRow / VCol illustration column, VCard, VAlert reset-success banner,
auth-logo link, MfaChallengeCard) stays Vuetify until F4b migrates
the auth surface in full.
Zod schema:
- email: required, valid email format
- password: required
Both messages are Dutch (per F3 sprint plan convention).
422 error handling routes through useFormError() from B5. The Laravel
response shape (errors.<field>: string[]) feeds applyApiErrors directly.
rate_limited and other reason-only failures are synthesized into the
email field's error map so they surface visually under the email input,
preserving the existing UX.
The remember-me checkbox is rendered with PrimeVue Checkbox (no schema
coverage — it's UI state, not validated input). The password visibility
toggle is delegated to PrimeVue's Password component's built-in
toggle-mask prop (replaces the previous manual isPasswordVisible ref
and append-inner-icon plumbing).
Verification:
- pnpm typecheck — clean.
- pnpm test — 402 tests pass unchanged.
- pnpm build — succeeds; login chunk grew from ~21 KB to ~84 KB raw
due to @primevue/forms + Password/Checkbox component code (gzip 22 KB).
Will normalize during F4 as more pages share these modules.
- Manual browser test deferred to Phase C brand-review screenshot
capture.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Layout-shell rewrite per RFC AD-3, B7-option-B. R-10 isolation invariant
honored — this single commit is revertible to roll back the layout
change without losing B1–B6 progress.
New component (PrimeVue-only, no Vuetify imports per F3 hard constraint):
- apps/app/src/layouts/components/AppShell.vue (~210 lines)
- Desktop sidebar (Tailwind grid, lg+ breakpoint) renders nav items
as PrimeVue Buttons + Icons. Mobile (<lg) hides sidebar; PrimeVue
Drawer slides in on hamburger toggle.
- Top bar (Tailwind) has hamburger + title (mobile) and an Avatar +
Menu (PrimeVue) for the user dropdown with "Mijn Profiel" and
"Uitloggen" actions.
- Nav items accept the existing { title, to: { name }, icon: { icon } }
shape from src/navigation/vertical so call-sites stay terse.
Five top-level layouts delegate to AppShell (filename preserved per
AD-3 so vite-plugin-vue-meta-layouts continues to resolve routes
unchanged):
- default.vue — org + (super-admin) platform nav
- OrganizerLayout — same nav as default; matches authenticated org UX
- PortalLayout — portal-specific 2-item nav ("Mijn evenementen",
"Mijn Profiel")
- blank.vue — minimal chrome-less wrapper for login etc.
- PublicLayout — minimal wrapper for public form-fill routes;
uses <main> for semantic structure
F3 functional regressions (intentional — F4 sub-packages reintroduce
each item through PrimeVue):
- NavSearchBar (Vuetify-heavy combobox/overlay) — absent from top bar
- ContextSwitcher (Vuetify VBtn + VMenu) — absent
- NavbarThemeSwitcher (Vuetify IconBtn) — absent; dark mode driven by
PrimeVue's darkModeSelector: '.dark' continues to work via the
existing @core skin classes until F6 cleanup
- NavbarShortcuts (Vuetify-heavy) — absent
- NavBarNotifications (Vuetify-heavy) — absent
- UserProfile from @/layouts/components/ (Vuetify-heavy menu) — replaced
with the minimal Avatar + Menu dropdown described above; rich profile
panel returns in F4
- ImpersonationBanner — absent; super-admin impersonation UX is F4 work
- PortalLayout event-mode vs platform-mode topbar (route.meta.navMode
driven) — absent; F4 reintroduces via AppShell prop or slot
- Suspense + AppLoadingIndicator wrapping pages — dropped; pages handle
their own loading via PrimeVue ProgressSpinner
VApp at App.vue level still wraps everything, so Vuetify components
inside still-Vuetify pages continue to render correctly during the
parallel-mode window.
Test updates (no Vuetify in layout structure to assert against anymore):
- OrganizerLayout.spec.ts — mocks AppShell instead of the deleted
DefaultLayoutWithVerticalNav reference; provides Pinia.
- PortalLayout.spec.ts — same mock pattern; new structural assertions
go through AppShell stub; the new third test verifies
PortalLayout forwards portal nav items + title to AppShell.
- PublicLayout.vue — uses <main> for semantics; PublicLayout.spec.ts
still passes unchanged.
Auto-generated component/auto-import dts files refreshed for the new
AppShell component (committed for stable dev workflow).
Verification:
- pnpm typecheck — clean.
- pnpm test — 402 tests pass (test count unchanged after spec rewrites).
- pnpm build — succeeds in 14.05s; AppShell chunk is ~57 KB raw.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two additions complete the F3 runtime scaffolding:
apps/app/src/components/Icon.vue — generic Iconify renderer wrapping
@iconify/vue's <Icon> component. F4 migration substitutes
<VIcon icon="tabler-X" /> with <Icon name="tabler-X" /> at call-sites,
producing real SVG output and using the existing Crewli "tabler-*"
naming convention. Props: name (required, e.g. "tabler-eye"), optional
size. The component avoids @iconify/vue's auto-import for clarity at
call-sites.
apps/app/src/App.vue — mounts <Toast /> and <ConfirmDialog /> at the
template root inside VLocaleProvider. Both render alongside the
existing VSnackbar and VDialog confirm patterns during the F3–F4
parallel-mode window. F4 sub-packages migrate call-sites to PrimeVue's
useToast() / useConfirm() composables.
UnoCSS-style i-tabler-* utility-class rendering (RFC AD-5 v1.0 wording)
is not adopted — UnoCSS is not installed in the Crewli stack. The
RFC will be aligned in B9.
Verification:
- pnpm typecheck — clean.
- pnpm test — 402 tests pass unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new artifacts that together provide the F4 form-migration target:
apps/app/src/components/forms/FormField.vue — project-owned wrapper
around @primevue/forms' built-in FormField. The default slot accepts
the actual input (e.g. <InputText name="email" />); the wrapper renders
label (with optional required asterisk), error Message, and hint chrome
around it. Reads field state from the parent <Form> via the built-in
FormField's scoped slot, so call-sites do not need to thread $form
manually.
apps/app/src/composables/useFormError.ts — the API 422 bridge. Parent
component calls useFormError() once; the composable provides an
apiErrors ref through Vue inject. Each FormField in the component
reads its own field name from that map. applyApiErrors() reads the
Crewli backend's { errors: { field: string[] } } shape and surfaces
the first message per field; clearApiErrors() resets between submits.
Error precedence per RFC Appendix A: explicit apiError prop > inject
apiErrors map > Zod resolver error from $field.
Signature note: RFC's useFormError(formRef) is implemented as
useFormError() — the formRef parameter is unused in the provide/inject
implementation, and Crewli convention avoids unused parameters. RFC
will be aligned in B9 if it remains a meaningful spec gap during F4.
Verification:
- pnpm typecheck — clean.
- pnpm test — 402 tests pass unchanged.
- B8 will exercise the components end-to-end on the login page; F4d
validates against the public-registration multi-step form.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
installPrimeVue(app) runs AFTER registerPlugins(app) (which registers
Vuetify + router + Pinia via the Vuexy @core machine). Placing the
PrimeVue install outside @core/utils/plugins is deliberate — it keeps
PrimeVue free of the Vuexy plugin loader so F6 can remove @core/
without disturbing PrimeVue registration.
Both frameworks are now active at runtime. Existing Vuetify pages
continue to render unchanged; PrimeVue components become available
for the layout-shell rewrite (B7) and the FormField wrapper (B5).
Verification:
- pnpm typecheck — clean.
- pnpm build — succeeds in 14.26s, no PrimeVue or theme-related errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three additions wire Tailwind v4 into the SPA without disturbing the
existing Vuetify pipeline:
- apps/app/src/assets/styles/tailwind.css — Tailwind v4 CSS-first entry.
Uses @import "tailwindcss"; @plugin "tailwindcss-primeui"; and
@source pointing at apps/app/src/ to scan template content.
- apps/app/vite.config.ts — adds the @tailwindcss/vite plugin between
vue() and vuetify(). After vue() so it sees compiled template
content; before vuetify() so Vuetify's SCSS pipeline runs unimpeded.
- apps/app/src/main.ts — imports tailwind.css before the Vuetify/Vuexy
SCSS so utility classes are available alongside Vuetify's cascade.
optimizeDeps.exclude remains ['vuetify'] (no PrimeVue addition) — HMR
behaves correctly in dev with the current config; revisit if needed.
Verification:
- pnpm typecheck — clean.
- pnpm build — succeeds in 13.97s; CSS emitted per-route as expected.
- pnpm test — 402 tests pass unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Scaffolds apps/app/src/plugins/primevue/ as three files mirroring the
Vuetify plugin structure at apps/app/src/plugins/vuetify/:
- theme.ts — CrewliPreset extends Aura via definePreset(). Primary
palette (50–950) is the exact token plan from RFC Appendix B,
centered on Crewli teal #0D9394 (light primary, primary.500) and
#0B7F80 (dark primary, primary.600). Surface tokens use Aura
defaults. colorScheme.light/dark map primary.color, hover, active,
and contrastColor per Appendix B.
- defaults.ts — empty pt (PassThrough) defaults object. F3 ships this
scaffold; F4 sub-packages populate component-level defaults as each
Vuetify surface migrates.
- index.ts — installPrimeVue(app) registers PrimeVue with the preset,
Dutch locale (primelocale/nl.json → nl.nl), darkModeSelector: '.dark'
(matches Vuexy convention per AD-2), and the three services Toast,
Confirmation, Dialog.
Theme imports use @primeuix/themes (the maintained successor PrimeVue's
official docs prescribe), not RFC v1.0's @primevue/themes. See B1 commit
for substitution rationale. RFC will be aligned in B9.
The plugin is not yet registered in main.ts — that lands in B4 after
Tailwind v4 wiring (B3).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase A finding A5 traced this race in the browser logs:
GET .../performances?day={festival_id} → 200, 0 results ← wrong day
GET .../children → 200, 3 sub_events
GET .../performances?day={subevent_id} → 200, 13 results ← correct
The pre-fix `isFlatEvent` was:
computed(() => !subEvents.value || subEvents.value.length === 0)
While `subEvents` was still loading (undefined), `!undefined` is `true`,
so isFlatEvent erroneously returned `true` for festivals during the
loading window. dayOptions then took the flat-event branch and seeded
validSubEventIds with the FESTIVAL id. useActiveDay's corrective watcher
rewrote the URL to `?day={festival_id}` and fired a wasted query that
returned zero results (correct semantics — performances live at sub-event
level — but waste + visible URL flicker).
Fix:
computed(() => eventDetail.value?.event_type === 'event')
EventResource always serialises event_type (verified at
api/app/Http/Resources/Api/V1/EventResource.php:26). EventTabsNav
already consumes event_type / is_festival from the same shape
(apps/app/src/components/events/EventTabsNav.vue:175,266) so this is
the canonical signal, not a one-off addition.
New behavior trace:
- Both queries pending → eventDetail=undefined → isFlatEvent=false
→ festival branch returns (subEvents ?? []).map(...)
→ validSubEventIds=[] → activeDayId=null
→ usePerformances.enabled=false → NO fetch
- subEvents resolves first → festival branch populates dayOptions
→ fetch fires with correct sub-event id
- eventDetail resolves first to flat event → flat branch fires
→ fetch with eventDetail.id (correct)
- eventDetail resolves first to festival → still false until subEvents
→ no false-positive flat-event fetch
402 tests still pass; typecheck + lint + production build all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
apps/app/tests/unit/schemas/timetableContractShape.test.ts (NEW, 5 tests):
- base shape: one performance with stage assigned + full engagement
(Bert's browser-tested sample, field-for-field). Asserts decimal-as-
string contract on fee_amount/buma_percentage/vat_percentage AND
enum-label wrapper on booking_status AND nested computed object.
- parked shape: stage_id=null, stage=null (Wachtrij case)
- multi-perf shape: two performances sharing engagement_id
(RFC §D17 "Friday + Saturday under one combined deal")
- sanity: individual performanceSchema parses each fixture element
- regression guard: a payload with NUMBER fee_amount throws (locks
out the pre-B5 bug class)
Every fixture spells out explicit `null` for the schema's nullable-but-
required fields (timestamps, notes, deal_breakdown) so the
nullable() vs optional() distinction is exercised, not glossed over.
Schema surface change to support the test:
apps/app/src/schemas/timetable.ts now EXPORTS performanceArraySchema
(previously a private const inside useTimetable.ts).
apps/app/src/composables/api/useTimetable.ts imports the shared one
instead of redeclaring it locally — single source of truth for the
array shape consumers and tests share.
Test count: 397 → 402 (+5). Typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase A diagnosed the "Kon timetable niet laden" browser symptom as Zod
schema drift. The prompt's hypothesis (enum {value, label} mismatch) was
incorrect — the schema already uses the enumLabel() wrapper for every
enum field. The actual drift is decimal-cast columns: Laravel serialises
`decimal(N,M)` columns as strings to preserve precision, but the schema
expected numbers, so the very first response triggered a ZodError.
Affected fields, all on `artist_engagements`:
fee_amount decimal(10,2) → wire `"11503.58"`, schema was z.number()
buma_percentage decimal(5,2) → wire `"7.00"`, schema was z.number()
vat_percentage decimal(5,2) → wire `"21.00"`, schema was z.number()
deposit_percentage decimal(5,2) → wire `"…"`, schema was z.number()
Backend has no explicit `decimal:N` cast on these columns
(api/app/Models/ArtistEngagement.php:64-85 — the `casts()` method covers
the enums + booleans + dates + integers, but skips decimals).
Per the strategic decision (frontend adapts, backend stays):
- schemas/timetable.ts: four fields → z.string().nullable()
- types/timetable.ts: matching ArtistEngagement interface fields →
`string | null`
- PerformancePopover.vue:129: only consumer doing arithmetic on a
decimal field; coerce at the use site via Number(...).toFixed(2).
Single line.
- tests/component/PerformanceBlock.test.ts + tests/a11y/axe.test.ts:
spot-checked mocks; the two with hand-built engagement payloads
flipped fee_amount/buma_percentage/vat_percentage from numbers to
strings to match the new schema. No other mocks needed updating.
The {value, label} enum wrapper claim in the prompt was specifically
debunked in Phase A — every consumer (Wachtrij, PerformanceBlock,
WachtrijCard, PerformancePopover, AddPerformanceDialog, page entry)
already uses .value/.label access against an enumLabel-wrapped schema.
B6 will lock the wire-format contract with a real-API fixture
regression test.
All 397 tests still pass; typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructures the canvas so the spreadsheet-feel works correctly with
the seeder's 14 stages: horizontal scroll moves the rows AND the
TimeAxis together; vertical scroll moves the rows but keeps TimeAxis
pinned; both panes intersect at a fixed corner cell. Diagonal trackpad
scroll behaves naturally because there's only one scroll container.
DOM restructure (E2 — sticky resolves to its nearest scroll ancestor;
fixed by giving sticky elements the right scroll-container parent
instead of patching with absolute positioning):
.tt-page__canvas position: relative; overflow: auto
└ .tt-page__layout display: grid; grid-template-columns: 200px auto;
inline-size: max-content
├ .tt-page__corner sticky top:0 left:0 z=3
├ .tt-page__axis sticky top:0 z=2 (full 1872px wide, no clip)
└ for each stage:
├ .tt-page__header-cell sticky left:0 z=2
│ └ <StageHeaderCell :row-height-px="row.rowHeightPx">
└ .tt-page__row-cell normal z=1 (height = same value)
└ <StageRow>
Z-index ladder (E1) is documented in the page CSS:
corner=3, axis row=2, header rail=2, row content=1, blocks=auto.
Popover + AddPerformanceDialog stay above via Teleport-to-body.
Drops the broken pre-stabilization layout:
- `grid-template: "corner axis" 28px "stages rows" 1fr / 200px 1fr`
that put ALL stage headers in ONE grid cell (cause of "lanes too tall"
via headers stretching to 100% of the 570px cell)
- nested `overflow: auto` on `.tt-page__rows` (cause of horizontal-scroll
desync — only the rows pane scrolled, axis stayed put)
- `overflow: hidden` on `.tt-page__axis` (E4 — clipped axis ticks beyond
the 1fr cell width)
- `<GridBg :total-height="0" />` which was a no-op anyway; gridlines now
render directly on each `.tt-page__row-cell` background
`inline-size: max-content` on the layout grid forces it wider than the
canvas viewport, so `overflow: auto` on the canvas actually fires a
horizontal scrollbar. Without this, the `auto` second column shrinks to
viewport and nothing overflows.
The page now passes `:row-height-px` to StageHeaderCell (B2 seam, now
load-bearing). Both header and row cell get the same explicit blockSize
inline so the freeze panes align pixel-for-pixel under whatever
laneCount each stage resolves to.
Visual scroll/alignment proof is deferred to TEST-VISUAL-001 — jsdom
cannot verify position:sticky behavior, scrollbar visibility, or pixel
alignment of the freeze panes. This is a known limitation, not a test
gap. B4 covers the structural assertions jsdom CAN verify.
All 389 existing tests still pass; production build smoke clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure structural seam — no layout changes yet (B3 wires the page through).
apps/app/src/lib/timetable/row-height.ts (NEW):
computeStageRowHeight(laneCount, laneHeightPx, lanePadPx) — one-line pure
function with the existing math: max(1, laneCount) * (laneHeight + lanePad) + lanePad.
Math.max(1, laneCount) keeps an empty stage row visible at single-lane
height instead of collapsing.
apps/app/src/components/timetable/StageRow.vue:
Switches its inline rowHeightPx computation to call the helper. Behavior
identical (the math was the helper's body).
apps/app/src/components/timetable/StageHeaderCell.vue:
New optional `rowHeightPx?: number` prop. When provided (B3 will pass it
from the page via the same helper), the header root applies blockSize
inline so the sticky-left column aligns pixel-for-pixel with the row.
When omitted, the legacy `block-size: 100%` CSS still applies — every
existing call-site keeps working.
apps/app/src/lib/timetable/index.ts: re-export the new helper.
Tests still green (389 across 54 files); typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The timetable canvas page at /events/{event}/timetable was added in
RFC-TIMETABLE Session 4 but had no UI entry point. EventTabsNav now
exposes it as the "Programma" tab between Artiesten and Briefings on
flat events, and between Artiesten and Briefings on festivals (in the
re-ordered tab list, post-Artiesten / pre-Briefings).
Changes:
- baseTabs gains the Programma entry at position 6 (after Artiesten).
- The festival re-order computed switches from positional indexing
(baseTabs[5], [6], [7]) to name-based lookup via a findTab helper —
insertions to baseTabs no longer break the festival branch.
- Icon: tabler-calendar-time. Conservative Dutch label "Programma" —
doesn't collide with "Programmaonderdelen" (the festival sub-events
page) since festivals see both tabs side-by-side.
vitest.config.ts: extend the component-project AutoImport to include
'vue-router' so tests of components that auto-import useRoute/useRouter
mount cleanly. (EventTabsNav was the first such test.)
tests/component/EventTabsNav.test.ts (NEW, 4 assertions):
- Programma tab is rendered with the correct label
- it carries the tabler-calendar-time icon
- the route binding resolves to the events-id-timetable name with the
/events/.../timetable URL pattern
- the tab is also visible on a festival (re-ordered tab list path)
Mocks the useEvents composables so the component skips its skeleton/
error branches and renders tabs immediately.
Test count: 385 → 389.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Lint cleanup spotted during Phase C — `router.replace` returns Promise<void>
which the no-void rule rejects. The dropped void had no behavioural effect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three jsdom axe scans covering the user-facing surface of the canvas.
The scans surfaced two real a11y bugs which are fixed in this same
commit:
1. PerformancePopover — VProgressLinear (advancing aggregate) had no
accessible name. Added aria-label that announces "X van Y secties
afgerond (N%)".
2. AddPerformanceDialog — the icon-only close button (×) was missing
aria-label. Added 'Sluiten'.
Test scenarios:
- PerformanceBlock with focus
- PerformancePopover open
- AddPerformanceDialog open
Page-level axe rules (region, page-has-heading-one, landmark-one-main,
color-contrast) are disabled for fragment scans — they only make sense
on a full page, and color-contrast resolution is jsdom-blind. Both are
covered by Playwright CT in TEST-INFRA-001 / TEST-VISUAL-001.
Test count: 380 → 383.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Minimal seam in src/composables/api/useTimetableMutations.ts: the move()
mutation's onError now calls useNotificationStore().show(...) on a 409
status. Generic axios errors stay quiet here — the global response
handler in lib/axios/factory.ts already toasts those. RFC D14 wanted
the version-mismatch toast specifically.
apps/app/tests/component/useTimetableMutations.test.ts (NEW, 5 tests):
- on success: returns server payload with bumped version + sends the
Idempotency-Key supplied by the caller
- 409: rejects with VersionMismatchError + notification.show()
invoked once with the Dutch translation + 'error' level
- cascade: success with cascaded[] populated puts those peers into
the result.cascaded array
- Idempotency-Key uniqueness: two distinct logical move() calls send
distinct keys
- Idempotency-Key reuse: caller-controlled retry within the same
logical action sends the SAME key on the wire (so the backend's
60s idempotency middleware dedupes)
The two existing unit-project tests now register a Pinia instance
(createPinia + setActivePinia) so useNotificationStore() resolves.
Existing assertions unchanged.
Test count: 364 → 369.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4 component tests via mountWithVuexy:
- happy path: valid form values → POST /performances called with the
correct body shape (engagement_id, event_id mapped from dayId,
stage_id, start_at, end_at)
- end_at < start_at → submit blocked, schema-level error visible on
the end_at field
- empty engagement_id → submit blocked, error visible on the engagement_id
field
- cancel button → emits update:modelValue=false
Test seam: AddPerformanceDialog.vue gains `defineExpose({ form, errors,
submit })` so jsdom tests can drive validation deterministically without
piping through Flatpickr / VAutocomplete plumbing. Three lines, exposes
internal refs only — no behavioural change.
VDialog stubbed in the test (it teleports to body, which puts content
outside the wrapper); App* wrappers stubbed (we test the schema +
submit pipeline, not Flatpickr ergonomics).
Test count: 360 → 364.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per Phase A finding A6 — the previous three-watcher Pinia-store design had
no validation. Landing on /events/{e}/timetable?day=DOES_NOT_EXIST quietly
set store.activeDayId to that bogus value and showed an empty page.
Cross-org sub-event IDs were silently accepted (backend OrganisationScope
returned an empty perf list, so the UI looked broken without telling the
user).
New design (Session 4 follow-up Step 5):
- src/composables/timetable/useActiveDay.ts (NEW)
- The URL `?day` is the source of truth; Pinia does NOT hold this value.
- `activeDayId` is a computed: queryDay if it appears in `validIds`,
else the first valid id, else null when the list is empty.
- One corrective watcher (immediate:true, flush:'post') quietly rewrites
the URL when `?day` is missing or invalid; runs after Vue settles and
after validIds has been recomputed from a fresh fetch.
- `setActiveDay(id)` is the user-driven entry point — calls replace().
- Cross-org IDs are blocked transparently: OrganisationScope keeps them
out of validIds, so they fail the .includes() check and fall back.
- src/stores/useTimetableStore.ts
- Removed `activeDayId` state and `setActiveDay()` action; the store
docstring now documents that day-state lives at the URL.
- src/pages/events/[id]/timetable/index.vue
- Replaced the three watchers + onMounted bootstrap with one
`useActiveDay({ queryDay, validIds, replace })` call. The day-change
side-effect watcher (clear drag, deselect performance) stays.
- VTabs binds dayIdRef + setActiveDay directly.
- tests/unit/pages/timetableDaySync.test.ts (NEW, 9 tests)
- Valid ?day=X → activeDayId=X, no URL rewrite.
- Missing / invalid / cross-org ?day → fallback + URL replaced once.
- Empty validIds → activeDayId=null, URL untouched.
- setActiveDay(id) → calls replace.
- setActiveDay(null) → no-op.
- External URL change (browser back) → activeDayId follows.
- validIds populated AFTER mount → fallback fires correctly.
- tests/unit/stores/useTimetableStore.test.ts: assert that activeDayId
and setActiveDay are GONE from the store surface.
Test count: 324 → 333.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per Phase A finding A5 — Zod schemas in @/schemas/timetable.ts were
types-only; nothing parsed actual server responses. Backend → frontend
contract drift would only surface as TypeError deep in components.
useTimetable.ts queries now parse:
- useStages → stageArraySchema.parse()
- usePerformances → performanceArraySchema.parse()
- useWachtrij → performanceArraySchema.parse()
- useEngagement → artistEngagementSchema.parse()
useTimetableMutations.ts mutations now parse:
- move success → moveTimetableSuccessSchema.parse()
- move 409 errors → moveTimetableConflictSchema.parse() (the .errors
sub-object — see backend canon at TimetableMoveController:64)
- create / updateNotes → performanceSchema.parse()
- createStage / updateStage → stageSchema.parse()
The move() success parse runs OUTSIDE the try/catch so a Zod failure on
a 200 response surfaces as a true error rather than being misclassified
as a 409. Per Phase A finding A8 the conflict shape already matches
backend field-for-field; no schema correction needed, but the parse()
locks future drift in.
Regression test (tests/unit/composables/api/zodParseFailure.test.ts):
- move() success with missing fields → rejects with ZodError
- move() 409 with malformed errors payload → rejects with ZodError
- createStage() with missing fields → rejects with ZodError
Existing test fixture for createStage was missing created_at/updated_at;
fixed in same commit (real backend responses always include them).
Test count: 321 → 324.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>