diff --git a/dev-docs/RFC-WS-PRIMEVUE-PLAN-2-5.md b/dev-docs/RFC-WS-PRIMEVUE-PLAN-2-5.md new file mode 100644 index 00000000..f1574da9 --- /dev/null +++ b/dev-docs/RFC-WS-PRIMEVUE-PLAN-2-5.md @@ -0,0 +1,1007 @@ +# RFC — PrimeVue Migration Plan 2.5: Shell Parity + Design Token Foundation + +| Field | Value | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | +| **Status** | Draft — awaiting approval | +| **Date** | 2026-05-19 | +| **Author** | Bert + Claude Chat | +| **Predecessors** | Plan 2 (`1429abf4` on `main`), Plan 3 (range `537ec098..637d77b3`, suite 564, 4 DEFERRED-HITL baselines) | +| **Successor** | Plan 4 (template layer) — explicitly gated on Plan 2.5 closure + parity-batch capture | +| **Governing RFC** | [`RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md`](./RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md) — this RFC is a Plan 2.5 sub-spec under §10 | +| **Source design system** | `crewli-starter/` (sibling working directory) | +| **Sync manifest SHA** | `637d77b3` | + +--- + +## 0. TL;DR + +Visual verification on `/v2/dashboard` against the crewli-starter design SoT (per `AD-G3`) surfaced **10 divergences** introduced or left unfixed by Plan 2. The Tier-1 primitive parity-batch baselines (4 DEFERRED-HITL, captured by Plan 3) are gated on a correct shell — capturing them against the divergent shell would lock in the wrong visual. In parallel, the typography audit identified **Public Sans** live in `main` with no documented rationale, conflicting with PrimeVue's de-facto Inter. + +Plan 2.5 closes both gaps in **two parallel tracks under one RFC**: + +- **Track A — Shell parity.** 10 targeted fixes against the design SoT. No new components, no schema changes, no new stores. +- **Track B — Design token foundation.** Generate `CREWLI-DESIGN-TOKENS.md` inventory; decide Typography end-to-end (Inter revert); defer remaining token decisions to a follow-up sub-sprint or Plan 4 when more pages exist and visual impact is clear. + +Closure of Plan 2.5 unblocks Plan 3 parity-batch baseline capture and establishes the token-decision framework Plan 4 will build on. + +--- + +## 1. Context + +### 1.1 Where Plan 2 / Plan 3 landed + +Plan 2 (commit `1429abf4`) shipped the v2 shell components (`AppSidebar`, `SidebarNav`, `WorkspaceSwitcher`, `AppTopbar`, `RightDrawer`, `AppDialog`, layouts-v2 boundary zone) plus Storybook stories. The shell renders correctly at the _structural_ level — slot regions, store wiring, store-driven sidebar collapse, drawer open/close — and the Vitest mount-test suite passes. + +Plan 3 (range `537ec098..637d77b3`) shipped 8 Tier-1 primitives (`StatusTag`, `StatCard`, `PageHead`, `StateBlock`, `TagsInput`, `EnergyDots`, `EnergyPicker`, `DraggableBlock`) plus `statusSeverity.ts` SoT, all co-located vue/spec/stories under `apps/app/src/components-v2/shared/`. Suite is at **564**, gzip delta **+0.82%**. Four Playwright CT baselines are **DEFERRED-HITL** in the parity-batch pending shell stabilisation (see §1.3). Three backlog items: `ENERGYDOTS-NAN`, `DRAGGABLEBLOCK-POINTERCANCEL` (Aria-A2 blocked), `AD3-MENUBAR`. + +### 1.2 The visual audit finding + +A visual diff between `/v2/dashboard` (current main, post-Plan 3) and the `crewli-starter` dashboard reference revealed 10 specific divergences. The divergences are not subjective design re-evaluations — each is a deviation from the documented design SoT in `RFC-WS-GUI-REDESIGN-CREWLI-STARTER` §4 / §7.4 / §13 or from `crewli-starter` directly. The complete list is in §5. + +### 1.3 Why parity-batch baselines are gated + +Plan 3's 4 DEFERRED-HITL baselines (`AppTopbar`, `WorkspaceSwitcher` open + closed states, `AppShellV2` integrated layout) cannot be captured against the current divergent shell. A baseline locks the _current rendered state_ as the regression target — capturing now would freeze the wrong visual as the contract and force Plan 2.5's fixes to re-baseline (negating the test value). + +Parity-batch capture is therefore explicitly **not** in Plan 2.5 scope — it is the _unblocking outcome_ of Plan 2.5 merging, executed as a separate work item. + +### 1.4 The typography audit trigger + +While inventorying the v2 shell's effective `:root` CSS variables, **Public Sans** was found as the live `font-family` on `main`. No commit message, RFC reference, or design-doc entry justifies the choice. PrimeVue Aura ships with no built-in font (UI components inherit from the application), and PrimeVue's own showcase, Figma UIKit, and Volt commercial templates all use Inter. The Aura preset's spacing, line-height, and x-height ratios are calibrated against Inter-class metrics. Public Sans has marginally different metrics — sub-pixel drift visible in dense data tables and form-field stacks. + +This audit finding expanded Plan 2.5 scope to include a first-pass design-token audit before more pages get built on potentially-wrong tokens. The audit framework is in §6. + +--- + +## 2. Decisions taken (chat session 2026-05-19) + +Five open questions were resolved in chat before this RFC was authored: + +| # | Question | Decision | AD | +| --- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ----------- | +| Q1 | `WorkspaceSwitcher` `sub` field content | **C4**: no `sub` field in Plan 2.5 (name + initials + gradient only) | `AD-2.5-W1` | +| Q2 | `.dark` class scope | PrimeVue canonical class-based selector on ``, aligned with Tailwind v4 | `AD-2.5-D1` | +| Q3 | Mobile scope in Plan 2.5 | Mobile behaviour follows existing design-doc §4 (`Drawer` overlay on `` tag in `index.html` loading Public Sans (if present — verify) + +**Regression lock.** New Vitest spec `apps/app/tests/unit/styles/typography.spec.ts`: + +```ts +import { describe, expect, it } from "vitest"; + +describe("Typography regression lock (AD-2.5-T1)", () => { + it("body font-family resolves Inter as first declared family", () => { + document.head.insertAdjacentHTML( + "beforeend", + '', + ); + const computed = getComputedStyle(document.body).fontFamily; + // First declared family must be Inter (with or without quotes). + expect(computed).toMatch(/^['"]?Inter['"]?[,\s]/); + }); + + it("Public Sans is not present in the font stack", () => { + const computed = getComputedStyle(document.body).fontFamily; + expect(computed).not.toMatch(/Public Sans/i); + }); +}); +``` + +This spec is **mandatory** in `DoD` — its absence means the revert is undefended against silent regression. + +--- + +### AD-2.5-D1 — Dark mode selector: PrimeVue canonical class-based on `` + +**Status:** Decided 2026-05-19 + +**Context.** The current implementation puts a `.dark` class on `AppTopbar` only (Bert's visual audit observation, item 6 of 10). The design-doc §4 specifies `` mechanism (data-attribute approach). PrimeVue's canonical _toggle-able_ dark mode is class-based on the document root. Tailwind v4's default class-based dark variant is also class-on-``. The current state aligns with none of these three patterns. + +**Decision.** Use **PrimeVue's canonical class-based selector**: `darkModeSelector: '.dark'` in `theme.ts`, with the class toggled on `document.documentElement` (i.e., ``). This **supersedes** design-doc §4's `` reference, which is now an obsolete pre-implementation note. + +**Rationale.** + +1. **PrimeVue canonical toggle pattern.** The PrimeVue docs example: `darkModeSelector: '.my-app-dark'` with toggle via `document.documentElement.classList.toggle('my-app-dark')`. We use `.dark` instead of `.my-app-dark` for convergence (see below). +2. **Convergence with Tailwind v4.** Tailwind v4's canonical class-based dark variant uses `.dark` on `` (`@custom-variant dark (&:where(.dark, .dark *))`). Using a single class for both Tailwind utility-class variants _and_ PrimeVue component tokens means **one toggle, two systems react** — no out-of-sync windows, no Tailwind `dark:` utilities that activate while PrimeVue components stay light, no inverse. +3. **`system` default rejected.** PrimeVue Aura's actual default is `darkModeSelector: 'system'` (matches `prefers-color-scheme`). Crewli has an explicit dark-mode toggle button in the topbar — the `system` default would prevent the toggle from working. `system` is fine for apps that defer entirely to OS preference; Crewli is not one of those apps. +4. **Why `` was wrong.** Data-attribute selectors work, but they require Tailwind v4 to be configured to `@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *))` _and_ PrimeVue `darkModeSelector: '[data-theme="dark"]'`. Both can be done — but it's two non-canonical configurations for no benefit over the standard class-on-`` pattern. Design-doc §4 was authored before implementation; in practice the class-based path is simpler and matches both ecosystems' defaults. + +**Implementation site.** `apps/app/src/plugins/primevue/theme.ts`: + +```ts +app.use(PrimeVue, { + theme: { + preset: CrewliPreset, + options: { + prefix: "p", + darkModeSelector: ".dark", // ← AD-2.5-D1 + cssLayer: false, + }, + }, +}); +``` + +`apps/app/src/stores/useShellUiStore.ts`, `applyDomAttributes()`: + +```ts +applyDomAttributes() { + // AD-2.5-D1: dark mode is a single class on . + // Tailwind v4 @custom-variant dark and PrimeVue darkModeSelector + // both react to this class. + const root = document.documentElement + if (this.theme === 'dark') { + root.classList.add('dark') + } else { + root.classList.remove('dark') + } + // density still uses data-attribute (orthogonal axis to colour scheme). + root.setAttribute('data-density', this.density) +} +``` + +**Removal scope.** Every `.dark` class application _not_ on `` must be deleted, not adapted. Specifically: + +- Audit all v2 components for hardcoded `class="dark"` or `:class="{ dark: ... }"` — these are tactical patches and must be removed +- Audit `applyDomAttributes` for any prior `setAttribute('data-theme', ...)` writes — replace with the class toggle +- Audit `apps/app/src/main.css` (and any Tailwind config) for `[data-theme="dark"]` selectors — replace with `.dark` + +**Verification.** Two-pronged: + +1. Manual smoke: open `/v2/dashboard`, toggle dark mode, both topbar AND sidebar AND content area transition (not just topbar). +2. Vitest spec `apps/app/tests/unit/stores/useShellUiStore.spec.ts` extended: + +```ts +it("toggles the .dark class on document.documentElement (AD-2.5-D1)", () => { + const store = useShellUiStore(); + store.theme = "dark"; + store.applyDomAttributes(); + expect(document.documentElement.classList.contains("dark")).toBe(true); + + store.theme = "light"; + store.applyDomAttributes(); + expect(document.documentElement.classList.contains("dark")).toBe(false); +}); + +it("does not write a data-theme attribute (AD-2.5-D1 supersedes design-doc §4)", () => { + const store = useShellUiStore(); + store.theme = "dark"; + store.applyDomAttributes(); + expect(document.documentElement.hasAttribute("data-theme")).toBe(false); +}); +``` + +**Cross-doc impact.** `RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md` §4 must be updated in Plan 2.5 closure docs to note: _"AD-G2.5-D1 supersedes the `` mechanism described here; the class-based selector on `` is the live convention as of Plan 2.5."_ + +--- + +### AD-2.5-W1 — WorkspaceSwitcher `sub` field: no sub in Plan 2.5 + +**Status:** Decided 2026-05-19 + +**Context.** Design-doc §7.4 specifies `WorkspaceSwitcher` shape `{ initials, name, sub, gradient }`, derived via computed properties over `useAuthStore` + `useOrganisationStore`. The `sub` field is described as _"org metadata line"_ — vague, not committed to a specific data source. Three chat-discussed options: + +- C1: ledental (`"12 leden"`) — requires `withCount('users')` on org-list endpoint +- C2: plan tier — requires `organisations.subscription_tier` column (does not exist per `SCHEMA.md`) +- C3: locatie/city — requires `organisations.city` (per `SCHEMA.md` not present at column level) +- C4: nothing — only name + initials + gradient + +**Decision.** **C4**: in Plan 2.5, `WorkspaceSwitcher` and its dropdown items render `name` + `initials` + `gradient` only. The `sub` slot in the component template is **removed**, not made conditional. + +**Rationale.** + +1. **Zero scope creep.** C1/C2/C3 each require either backend work or schema additions. None of them is the Plan 2.5 purpose (shell parity). Forcing one in pulls Plan 2.5 into a tenant-data-shape discussion. +2. **Design-doc compliant.** Design-doc §7.4 says `sub` is `"org metadata line"` — an optional descriptor, not a load-bearing UX element. Removing it is within design intent. +3. **Visual delta is minimal.** In `crewli-starter`, `sub` renders at ~50% opacity under `name`. Its absence collapses the workspace cell by ~16px vertical — well within the design's visual tolerance for the bottom-of-sidebar workspace switcher. +4. **Re-introducing later is cheap.** Removing the slot now and adding it back via a separate RFC (with proper data-source decision) costs ~30 lines of code. The reverse — committing to a half-built C1 and rolling back — costs significantly more. + +**Implementation site.** `apps/app/src/components-v2/layout/WorkspaceSwitcher.vue`: + +```vue + + +``` + +Dropdown items follow the same shape — `gradient + name + current-checkmark`, no `sub` line. + +**Removal scope.** Every prior `sub` reference must be deleted, not commented out: + +- Component template: remove the `{{ sub }}` line +- Component props/computed: remove `sub` from the derived computed in §7.4 wiring +- Storybook stories: remove `sub` from all WorkspaceSwitcher story arg defaults +- Any tests asserting `sub`: delete those specs (don't comment-out) + +**Cross-doc impact.** `RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md` §7.4 must be updated in Plan 2.5 closure docs to note: _"AD-G2.5-W1 reduces the WorkspaceSwitcher shape to `{ initials, name, gradient }` for Plan 2.5; `sub` may be re-introduced under a future RFC with explicit data-source decision."_ + +--- + +### AD-2.5-B1 — Breadcrumb data source: central navigation registry + `useBreadcrumb` composable + +**Status:** Decided 2026-05-19 + +**Context.** Design-doc §4 specifies PrimeVue `Breadcrumb` as the primitive for the topbar breadcrumb. Data-source mechanism was unspecified. Chat discussion established the crewli-starter convention is _"own setup based on total application navigation (modules, submodules, etc.)"_ — i.e., the breadcrumb derives from the same navigation tree the sidebar reads. + +**Decision.** Plan 2.5 introduces a **central navigation registry** as the SoT for both sidebar nav structure AND breadcrumb chain. A new composable `useBreadcrumb()` reads the current route and walks the registry to produce the breadcrumb item list, which is passed to PrimeVue's `Breadcrumb` primitive for rendering. + +**Why a registry, not route meta.** + +1. **Single source of truth.** Sidebar reads `APP_NAVIGATION` for nav rendering; `useBreadcrumb` reads the same `APP_NAVIGATION` for breadcrumb derivation. One file changes, two surfaces update. +2. **Avoids meta-drift.** Route meta requires every route definition to carry `meta.breadcrumb` independently; nothing enforces consistency between sidebar definition and meta strings. Drift inevitable. +3. **Test-friendly.** Walk-tree function is pure — `walkNavTree(registry, routeName)` is unit-testable without mounting components or stubbing `vue-router`. +4. **No PrimeVue Breadcrumb data-model coupling.** PrimeVue `Breadcrumb` accepts a `MenuItem[]` — our composable returns exactly that. No translation layer. + +**Implementation site.** + +`apps/app/src/config/navigation.ts` (new): + +```ts +// AD-2.5-B1: central navigation registry. +// Single source of truth for sidebar rendering AND breadcrumb derivation. +import type { Component } from "vue"; + +export interface NavItem { + key: string; // stable identifier (e.g., 'events.volunteers') + label: string; // display string + routeName?: string; // vue-router route name (leaf nodes) + icon?: string; // tabler icon name (e.g., 'tabler:calendar') + children?: NavItem[]; // submodule nesting + hidden?: boolean; // exclude from sidebar but allow breadcrumb walk +} + +export const APP_NAVIGATION: NavItem[] = [ + { + key: "dashboard", + label: "Dashboard", + routeName: "v2-dashboard", + icon: "tabler:home", + }, + { + key: "events", + label: "Events", + icon: "tabler:calendar", + children: [ + { key: "events.list", label: "Events", routeName: "v2-events" }, + { + key: "events.volunteers", + label: "Volunteers", + routeName: "v2-event-volunteers", + }, + // ... + ], + }, + // ... etc, see Plan 2.5 implementation for complete registry +]; +``` + +`apps/app/src/composables/useBreadcrumb.ts` (new): + +```ts +import { computed } from "vue"; +import { useRoute } from "vue-router"; +import { APP_NAVIGATION, type NavItem } from "@/config/navigation"; + +export interface BreadcrumbItem { + label: string; + routeName?: string; +} + +/** + * Walks APP_NAVIGATION to find the path from the root to the leaf + * matching the given route name. Returns an empty array if no match. + * + * AD-2.5-B1: pure function, unit-testable without router or component mount. + */ +export function walkNavTree( + tree: NavItem[], + routeName: string, + acc: BreadcrumbItem[] = [], +): BreadcrumbItem[] { + for (const node of tree) { + const next = [...acc, { label: node.label, routeName: node.routeName }]; + if (node.routeName === routeName) return next; + if (node.children) { + const childMatch = walkNavTree(node.children, routeName, next); + if (childMatch.length > 0) return childMatch; + } + } + return []; +} + +export function useBreadcrumb() { + const route = useRoute(); + return computed(() => { + if (!route.name) return []; + return walkNavTree(APP_NAVIGATION, String(route.name)); + }); +} +``` + +`apps/app/src/components-v2/layout/AppBreadcrumb.vue` (new primitive — also addresses Fix 2): + +```vue + + + +``` + +**Sidebar consumes the same registry.** `SidebarNav` is refactored to read `APP_NAVIGATION` (replacing whatever current source it uses). This is the "single source of truth" payoff — both the sidebar rendering and the breadcrumb derivation read the same file. Adding a new page = adding one entry, both UIs update. + +**Tests.** + +`apps/app/tests/unit/composables/useBreadcrumb.spec.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { walkNavTree } from "@/composables/useBreadcrumb"; +import type { NavItem } from "@/config/navigation"; + +const FIXTURE: NavItem[] = [ + { key: "a", label: "A", routeName: "a" }, + { + key: "b", + label: "B", + children: [{ key: "b.1", label: "B-1", routeName: "b-1" }], + }, +]; + +describe("walkNavTree (AD-2.5-B1)", () => { + it("returns the chain for a leaf route", () => { + expect(walkNavTree(FIXTURE, "b-1")).toEqual([ + { label: "B", routeName: undefined }, + { label: "B-1", routeName: "b-1" }, + ]); + }); + + it("returns empty for an unmatched route", () => { + expect(walkNavTree(FIXTURE, "nonexistent")).toEqual([]); + }); + + it("returns single-entry chain for a top-level leaf", () => { + expect(walkNavTree(FIXTURE, "a")).toEqual([{ label: "A", routeName: "a" }]); + }); +}); +``` + +**Future scope.** A central navigation registry naturally extends to: role-based filtering (`NavItem.requiresPermission`), feature-flag gates (`NavItem.featureFlag`), and dynamic ordering. Plan 2.5 does **not** implement these — the registry has only the fields shown above. Extensions are explicit follow-up RFCs. + +--- + +## 5. Shell-parity fixes (Track A) + +Each fix follows the same structure: **current state**, **design SoT reference**, **fix**, **regression lock**. All fixes apply at desktop ≥`lg` viewport; mobile follows existing design-doc §4 (Drawer overlay) — no Plan 2.5-specific mobile work. + +### 5.1 Fix 1 — Remove duplicate Crewli brand from topbar + +**Current state.** Both `SidebarHeader` (sidebar top) and `AppTopbar` render a Crewli logo + wordmark. The sidebar logo is correct per design-doc §4 (`SidebarHeader.vue # logo + collapse toggle`). The topbar logo is a Plan 2 implementation artifact not in the design. + +**Design SoT.** `crewli-starter` dashboard: topbar `#start` slot contains **breadcrumb only**, no brand. Brand lives in `SidebarHeader` exclusively. + +**Fix.** Delete the brand block from `AppTopbar.vue`. The `#start` slot is repurposed in Fix 2. + +**Regression lock.** Component test asserting `AppTopbar` does NOT render an element with `data-testid="topbar-brand"`. Visual: parity-batch captures the brand-free topbar after Plan 2.5 closes. + +--- + +### 5.2 Fix 2 — `AppTopbar` `#start`: replace brand with `AppBreadcrumb` + +**Current state.** `#start` renders the brand block (see Fix 1). + +**Design SoT.** Design-doc §4: _"AppTopbar # Breadcrumb (PrimeVue) + actions"_. `crewli-starter` confirms breadcrumb left, actions right. + +**Fix.** Replace `#start` content with `` (the new primitive from `AD-2.5-B1`). + +```vue + + +``` + +**Regression lock.** Component test asserting `AppTopbar` renders an `` child. Unit test for `useBreadcrumb` already covered in `AD-2.5-B1`. + +--- + +### 5.3 Fix 3 — Move `WorkspaceSwitcher` from top to bottom of sidebar + +**Current state.** `AppSidebar` renders `WorkspaceSwitcher` at the top (between header and nav). Plan 2 regression against design-doc. + +**Design SoT.** Design-doc §4 (literal): _"WorkspaceSwitcher.vue # **bottom** switcher (custom visual + PrimeVue Popover)"_. The "bottom switcher" phrasing is explicit and is not a stylistic choice. + +**Fix.** Reorder children in `AppSidebar.vue` template: + +```vue + + +``` + +The `flex-1` spacer is the canonical way to bottom-anchor in a `flex-col` container — no absolute positioning, no `mt-auto` hacks. + +**Regression lock.** Component test asserting that within `AppSidebar`, the DOM order is `SidebarHeader → SidebarNav → WorkspaceSwitcher` (the spacer is presentational, not a testable target). Visual: parity-batch captures the bottom-anchored switcher. + +--- + +### 5.4 Fix 4 — Workspace label scheme: implement `AD-2.5-W1` (no sub) + +**Current state.** `WorkspaceSwitcher` renders `name` + `sub` (currently `"Stichting Feestfabriek / org_admin"` — an internal-tooling-looking label). + +**Design SoT.** `AD-2.5-W1` (this RFC). + +**Fix.** Per `AD-2.5-W1`: remove `sub` slot entirely. Render `initials` + `name` + `gradient` only. + +**Regression lock.** Component test asserting `WorkspaceSwitcher` does NOT render an element with `data-testid="workspace-sub"`. Storybook story `WithSub` is deleted (not commented out). Type definition for the computed shape drops the `sub` field. + +--- + +### 5.5 Fix 5 — Workspace dropdown items per `crewli-starter` + +**Current state.** Dropdown contents depend on prior implementation; need verification against `crewli-starter` reference. + +**Design SoT.** Design-doc §7.4: _"Panel content (Workspaces header + Manage link, list with gradient logos + current checkmark, footer New workspace / Invite) stays custom markup."_ + +**Fix.** Restructure dropdown panel: + +- Header: `"Workspaces"` label + `Manage` link (right-aligned) +- List: each item = `gradient square (initials) + name + current-checkmark-if-active`. No `sub` (per `AD-2.5-W1`). +- Footer: `New workspace` + `Invite` actions + +Reuses existing org list from `useAuthStore().organisations`. No new store, no new endpoint. + +**Regression lock.** Component test rendering `WorkspaceSwitcher` with a 3-org fixture, asserting: + +- Header row contains text `"Workspaces"` and a link with text `"Manage"` +- List row count equals fixture org count +- Footer row contains both `"New workspace"` and `"Invite"` triggers + +Visual: parity-batch captures the dropdown-open state. + +--- + +### 5.6 Fix 6 — Dark mode class scope: implement `AD-2.5-D1` + +**Current state.** `.dark` class lives on `AppTopbar` only (per audit observation). Tailwind `dark:` utilities and PrimeVue `darkModeSelector` are out of sync. + +**Design SoT.** `AD-2.5-D1` (this RFC). + +**Fix.** Per `AD-2.5-D1`: configure `darkModeSelector: '.dark'` in `theme.ts`; rewrite `useShellUiStore.applyDomAttributes()` to toggle `.dark` on `document.documentElement`; delete every `.dark` class application elsewhere. + +**Regression lock.** Two specs covered in `AD-2.5-D1` (`applyDomAttributes` toggles `.dark` on ``; no `data-theme` attribute is written). + +--- + +### 5.7 Fix 7 — Content top-offset: title falls behind topbar + +**Current state.** Page title at the top of the main content scrolls beneath the topbar (no top padding or sticky offset). + +**Design SoT.** `crewli-starter`: topbar is sticky / positioned, main content has a top offset matching topbar height. + +**Fix.** Two-step: + +1. Make topbar sticky-or-fixed (whichever matches `crewli-starter`). Verify against `crewli-starter` source — most likely `position: sticky; top: 0` with appropriate `z-index`. +2. Apply matching top padding to `
` in `AppShellV2`: + +```vue + +
+ + +
+``` + +If the topbar is `h-14` (current Plan 1 spec) and `
` already has sufficient top padding, this may be a non-fix in code — but **must be visually verified at all viewports** before claiming closure. + +**Regression lock.** Visual baseline only — no good unit assertion for scroll behaviour. Parity-batch captures the scrolled state. + +--- + +### 5.8 Fix 8 — Remove mysterious ▼ arrow at content bottom + +**Current state.** A `▼` chevron renders at the bottom of the content area on `/v2/dashboard`. Origin unknown — likely a leftover stub or a misplaced `Breadcrumb` chevron. + +**Design SoT.** Not present in `crewli-starter`. + +**Fix.** Locate and delete. Implementation prompt instructs Claude Code to `grep -rn "pi-chevron-down\|▼\|▼" apps/app/src/components-v2/ apps/app/src/layouts/` to find candidate sites, identify the offending element, and remove it. Most likely candidates: a placeholder in `AppShellV2.vue` from Plan 1 scaffold; a stray `Breadcrumb` separator; or a Pinia DevTools indicator (excluded from prod build). + +**Regression lock.** Visual baseline. No specific unit assertion — the element shouldn't have existed in the first place. + +--- + +### 5.9 Fix 9 — Sidebar bottom region per `crewli-starter` + +**Current state.** Sidebar bottom is empty / has only the (mis-positioned) WorkspaceSwitcher. + +**Design SoT.** `crewli-starter` sidebar bottom region contains: the bottom-anchored `WorkspaceSwitcher` (Fix 3) plus surrounding spacing/dividers per the design. + +**Fix.** This fix is **partially implicit** in Fix 3 (WorkspaceSwitcher moves to bottom). The remainder: any divider, padding, or visual structure surrounding the bottom region must match `crewli-starter`. Implementation prompt requires Claude Code to **paste the `crewli-starter` sidebar source** (specifically the sidebar bottom region) into the prompt, then port the structural markup to `AppSidebar.vue` and `WorkspaceSwitcher.vue`. + +**Requires from Bert:** the `crewli-starter` sidebar source file — paste into the implementation-prompt phase. + +**Regression lock.** Visual baseline. Component test for sidebar DOM order from Fix 3 covers structural ordering. + +--- + +### 5.10 Fix 10 — Topbar right-side items: order + state per `crewli-starter` (incl. density toggle) + +**Current state.** Topbar right-side renders some combination of search / dark-toggle / notifications / user menu, with unverified ordering. **Density toggle is missing entirely** despite `useShellUiStore.density` state existing since Plan 2 (per governing RFC §4). + +**Design SoT.** `crewli-starter` topbar (mobile screenshot, matching desktop): **search → density toggle → dark-toggle → notifications (with badge) → user avatar**. The icon that renders as `0|0` between search and dark-toggle is the **density toggle** (chat clarification 2026-05-19): switches between compact (less spacing) and regular (more spacing) GUI modes. + +> **Icon caveat.** `0|0` is Iconify's fallback glyph for an icon name that doesn't resolve in the registered collection. It is almost certainly not the intended icon — `crewli-starter` references a real Tabler (or inline SVG) icon that didn't load in the screenshot context. Implementation prompt **must verify the actual icon** from `crewli-starter` source rather than reproduce the fallback. + +**Fix.** Reorder `AppTopbar.vue` right-side actions to match the design SoT order, AND wire the density toggle to `useShellUiStore`: + +1. **Verify store API.** `useShellUiStore.density` already exists per design-doc §4 (values: `'compact' | 'regular' | 'comfy'`). Verify `toggleDensity()` action exists; if not, add it as a binary cycle (`compact ⇔ regular`, **ignoring `comfy`** for the topbar trigger). `comfy` remains available for components that opt in explicitly (e.g., `DraggableBlock` per design-doc §7.1). +2. **Add density toggle ` + ``` + +5. **Apply via `applyDomAttributes()`.** Density continues to write `data-density=""` on `document.documentElement` (orthogonal to `AD-2.5-D1`'s `.dark` class on `` — density and colour-scheme are independent axes; both can coexist on the same root element without conflict). + +**Regression lock.** Component test asserting topbar right-side renders, in order: + +1. Search trigger +2. Density toggle (with `data-testid="density-toggle"`) +3. Dark-mode toggle +4. Notifications (with `OverlayBadge`) +5. User menu avatar + +Store spec extension `apps/app/tests/unit/stores/useShellUiStore.spec.ts`: + +```ts +it("toggleDensity cycles compact ⇔ regular (comfy is not toggle-reachable)", () => { + const store = useShellUiStore(); + store.density = "regular"; + store.toggleDensity(); + expect(store.density).toBe("compact"); + store.toggleDensity(); + expect(store.density).toBe("regular"); +}); + +it("toggleDensity from comfy resets to regular (not compact)", () => { + const store = useShellUiStore(); + store.density = "comfy"; + store.toggleDensity(); + expect(store.density).toBe("regular"); +}); + +it("applyDomAttributes writes data-density on documentElement", () => { + const store = useShellUiStore(); + store.density = "compact"; + store.applyDomAttributes(); + expect(document.documentElement.getAttribute("data-density")).toBe("compact"); +}); +``` + +The "comfy → regular" behaviour is deliberate: comfy is opt-in per component, never user-cycled. Resetting to regular (not compact) is the conservative recovery if a stale comfy value persists through hydration. + +Visual: parity-batch captures both density states of the topbar after Plan 2.5 closes. + +**Requires from Bert (P6 prompt phase):** the `crewli-starter` topbar source file, specifically the density-toggle button markup and icon reference. + +--- + +## 6. Design token audit (Track B) + +### 6.1 Process + +The audit produces `dev-docs/CREWLI-DESIGN-TOKENS.md` as the **inventory and decision register** for every `:root` CSS variable `crewli-starter` sets. The doc has three sections: + +1. **Inventory** — full table of every variable, source value, classification. +2. **Classification per variable** — one of three categories (defined in §6.2). +3. **Decision** — for each token classified as `Generic`, an explicit decision: keep `crewli-starter` value (with rationale) or revert to Aura default. + +Plan 2.5 fully decides one row (Typography); the rest are inventoried and tagged `DEFERRED`. The framework is in place for Plan 2.5b or Plan 4 to make the remaining decisions. + +### 6.2 Classification scheme + +Each token is one of: + +- **Brand-essential** — token carries Crewli brand identity. Keep `crewli-starter` value. Examples: `--p-primary-color` (teal), gradient definitions. +- **Bespoke** — token has a non-Aura-default value with deliberate design intent. Keep `crewli-starter` value with rationale. Examples: custom focus-ring width if `crewli-starter` widened it; custom border-radius scale. +- **Generic** — token has a non-Aura-default value with no documented intent. Default decision: **revert to Aura**. Override requires explicit rationale in the decision register. Examples: font-family (per `AD-2.5-T1`), arbitrary surface-tone shifts. + +### 6.3 `CREWLI-DESIGN-TOKENS.md` structure (template) + +```markdown +# Crewli Design Tokens — Audit & Decision Register + +| Field | Value | +| ---------------------- | ---------------------- | +| Source design system | crewli-starter/ | +| Aura preset version | @primeuix/themes@4.5.x | +| Audit date | 2026-05-19 | +| Phase-1 decided tokens | Typography (AD-2.5-T1) | + +## Token inventory + +| Token | crewli-starter value | Aura default | Classification | Decision | RFC | +| ---------------------- | -------------------- | ------------------- | ----------------------------------------------- | --------------- | ------------------ | +| `--p-font-family` | `'Public Sans', ...` | n/a (inherited) | Generic | Revert to Inter | AD-2.5-T1 | +| `--p-primary-color` | `#0D9394` | `#10b981` (emerald) | Brand-essential | Keep | governing RFC AD-2 | +| `--p-focus-ring-width` | `2px` (verify) | `2px` (verify) | (DEFERRED — verify both values, classify after) | DEFERRED | — | +| ... | ... | ... | ... | ... | ... | + +## Decisions + +### Typography — Inter via @fontsource/inter + +See AD-2.5-T1 in RFC-WS-PRIMEVUE-PLAN-2-5.md. + +### (DEFERRED tokens listed here as section stubs, no decisions yet) +``` + +### 6.4 Why Phase-1 stops at Typography + +The token audit is a multi-day effort if done exhaustively. Plan 2.5's purpose is shell parity, not full token reconciliation. Stopping at Typography: + +- **Lets Plan 2.5 ship.** Each token decision can require visual smoke-test at multiple density / dark-mode combinations. End-to-end decisions for ~30+ tokens is its own sprint. +- **Establishes the framework.** The doc, the classification scheme, the decision-register format — these are durable artifacts. Future decisions append; they don't restart. +- **Typography has the largest visual surface impact.** Font choice affects every text element in the app. Other tokens (e.g., `--p-focus-ring-width`) are localised. +- **The regression-lock pattern is now in place.** `AD-2.5-T1`'s computed-style test is the template; future token reverts copy the pattern. + +Phase-2 candidates (for Plan 2.5b or absorbed into Plan 4): font-size scale (root rem base), surface tone (`--p-surface-*`), focus-ring (`--p-focus-ring-*`), border-radius scale. + +--- + +## 7. Test strategy + +### 7.1 Unit / component + +All Vitest / Vue Test Utils based: + +- `useBreadcrumb` walkNavTree (4 specs minimum: leaf, no-match, top-level, deep nesting) +- `useShellUiStore.applyDomAttributes` dark-mode toggle (2 specs: positive + data-theme-absence) +- `useShellUiStore.toggleDensity` density toggle (3 specs: compact⇔regular cycle, comfy→regular reset, data-density write) +- `WorkspaceSwitcher` no-`sub` regression (1 spec: `data-testid="workspace-sub"` does not render) +- `AppTopbar` Fix 1+2 (1 spec: no brand, AppBreadcrumb present) +- `AppSidebar` Fix 3 (1 spec: DOM order) +- `AppTopbar` Fix 10 (2 specs: 5-item action order including density toggle, density toggle click calls `shell.toggleDensity`) +- Typography regression lock (`AD-2.5-T1`: 2 specs) + +Estimated new spec count: **~16 specs**, all unit-level. Suite delta: 564 → ~580. + +### 7.2 Visual regression (Playwright CT) + +**Plan 2.5 does not add new visual baselines.** Reason: visual baselines are what's gated. The 4 DEFERRED-HITL captures and any Plan 2.5 shell baselines are taken **after** Plan 2.5 merges, in the unblocked parity-batch work item. + +Plan 2.5 may **update** the existing AppShellV2 mount-test snapshot (a structural test, not a pixel-diff visual) to reflect the new sidebar DOM order. + +### 7.3 Token regression lock + +`AD-2.5-T1` mandates the typography computed-style spec. This is the pattern: every decided token in `CREWLI-DESIGN-TOKENS.md` requires a corresponding computed-style spec. Phase-1 has one such spec; Phase-2 will add more per token decided. + +### 7.4 What's deliberately not tested + +- **`crewli-starter` source code parity.** We do not unit-test that our markup matches `crewli-starter` line-for-line — that's what visual baselines are for, after Plan 2.5 merges. +- **Mobile-specific behaviour.** No new mobile specs in Plan 2.5. Existing mobile drawer tests (if any) continue to pass. +- **Cross-browser dark-mode.** PrimeVue + Tailwind handle the actual style application; we test our toggle logic only. + +--- + +## 8. Sequencing + +**Strict linear order. No parallel work.** Each step gates the next. + +``` +1. Foundation: AD-2.5-T1 + AD-2.5-D1 + AD-2.5-B1 file scaffolding + ├── theme.ts: definePreset + darkModeSelector + Inter import + ├── apps/app/src/config/navigation.ts: APP_NAVIGATION + ├── apps/app/src/composables/useBreadcrumb.ts: walkNavTree + useBreadcrumb + └── apps/app/src/components-v2/layout/AppBreadcrumb.vue: primitive + +2. AD-2.5-T1 implementation + ├── @fontsource/inter installed + ├── Public Sans deletions (verify package.json, main.css, index.html) + ├── typography.spec.ts (regression lock) + └── COMMIT: feat(theme): revert font to Inter per AD-2.5-T1 + +3. AD-2.5-D1 implementation + ├── useShellUiStore.applyDomAttributes() rewrite + ├── Stray .dark class removals + ├── useShellUiStore.spec.ts extension + └── COMMIT: refactor(theme): dark mode class on per AD-2.5-D1 + +4. AD-2.5-W1 + AD-2.5-B1 implementations + ├── WorkspaceSwitcher.vue: remove sub slot + Storybook story update + ├── SidebarNav refactor to read APP_NAVIGATION + ├── useBreadcrumb.spec.ts (4 specs) + └── COMMIT: feat(layout): central navigation registry per AD-2.5-B1 + +5. Shell-parity fixes 1–5 (topbar + sidebar structure) + ├── Fix 1: remove topbar brand + ├── Fix 2: AppTopbar #start = AppBreadcrumb + ├── Fix 3: WorkspaceSwitcher to bottom + ├── Fix 4: no sub (implementation already in step 4) + ├── Fix 5: dropdown panel per crewli-starter + └── COMMIT: fix(shell): parity fixes 1–5 per RFC-WS-PRIMEVUE-PLAN-2-5 §5 + +6. Shell-parity fixes 6–10 (remaining) + ├── Fix 6: dark mode (implementation already in step 3) + ├── Fix 7: content top-offset verification + ├── Fix 8: remove ▼ arrow + ├── Fix 9: sidebar bottom region (requires crewli-starter source paste) + ├── Fix 10: topbar right-side order/state + density toggle wired to useShellUiStore (+ crewli-starter source paste for icon ID) + └── COMMIT: fix(shell): parity fixes 6–10 per RFC-WS-PRIMEVUE-PLAN-2-5 §5 + +7. CREWLI-DESIGN-TOKENS.md + ├── Inventory section: every :root variable from crewli-starter + ├── Classification per variable + ├── Typography section: full AD-2.5-T1 record + ├── DEFERRED section stubs + └── COMMIT: docs(theme): CREWLI-DESIGN-TOKENS.md phase-1 inventory + +8. Closure docs + ├── Update RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md §4 (dark mode supersession) + ├── Update RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md §7.4 (WorkspaceSwitcher sub note) + ├── Update BACKLOG.md: close MIGRATION-PRIMEVUE-PLAN-2-5; open MIGRATION-PRIMEVUE-PLAN-2-5-PARITY-BATCH and MIGRATION-PRIMEVUE-PLAN-2-5B (deferred tokens) + └── COMMIT: docs(rfc): close PrimeVue Plan 2.5 + +9. Merge to main +``` + +**Gates:** + +- Steps 1–4 each block the next step (foundation has dependencies). +- Step 5 requires steps 1–4 complete (uses AppBreadcrumb, WorkspaceSwitcher, etc.). +- Step 6 requires step 3 complete (Fix 6 = `AD-2.5-D1`). +- Step 9 requires `pnpm exec vitest run` green AND `pnpm exec eslint apps/app/src/components-v2 apps/app/src/composables` green (scoped lint, not whole-codebase due to known formatter OOM). + +--- + +## 9. Definition of Done + +A Plan 2.5 closure is complete only when **all** of the following hold: + +- [ ] All 10 shell-parity fixes implemented and visually verified at `≥lg` +- [ ] All 4 ADs (`T1`, `D1`, `W1`, `B1`) implemented in code +- [ ] All ~12 new unit/component specs green +- [ ] Full Vitest suite green: `pnpm exec vitest run` exits 0 +- [ ] Scoped lint green: `pnpm exec eslint apps/app/src/components-v2 apps/app/src/composables apps/app/src/config apps/app/src/composables apps/app/src/stores` +- [ ] No new `any` types introduced; `pnpm exec vue-tsc --noEmit -p apps/app/tsconfig.json` clean +- [ ] `CREWLI-DESIGN-TOKENS.md` exists with Typography section fully populated +- [ ] `RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md` §4 and §7.4 updated with supersession notes +- [ ] `BACKLOG.md` updated: close `MIGRATION-PRIMEVUE-PLAN-2-5`, open follow-ups +- [ ] Public Sans **fully removed** from code, dependencies, and HTML (`grep -rn "public.sans\|Public Sans" apps/app/` returns no hits) +- [ ] Every commit references the RFC: `per RFC-WS-PRIMEVUE-PLAN-2-5 §X` or `per AD-2.5-X` +- [ ] Drift-check: `.claude-sync/` regenerated and uploaded to Project Knowledge +- [ ] `git push origin main` after final closure commit + +--- + +## 10. Implementation plan + +Plan 2.5 will be executed by Claude Code in sequential phases matching §8. Each phase corresponds to one Claude Code prompt, authored by Claude Chat in a follow-up message: + +| Phase | Prompt scope | Estimated complexity | +| ----- | -------------------------------------------------------------------- | ------------------------------ | +| P1 | Foundation: theme.ts + navigation.ts + useBreadcrumb + AppBreadcrumb | Medium | +| P2 | AD-2.5-T1: Inter revert + regression lock | Small | +| P3 | AD-2.5-D1: dark mode rewrite | Small–Medium (audit-then-edit) | +| P4 | AD-2.5-W1 + sidebar/breadcrumb wiring | Medium | +| P5 | Shell fixes 1–5 | Medium | +| P6 | Shell fixes 6–10 (incl. crewli-starter source paste for Fix 9) | Medium | +| P7 | CREWLI-DESIGN-TOKENS.md authoring | Medium (inventory work) | +| P8 | Closure docs (RFC updates + BACKLOG.md) | Small | + +**Prompts are written one phase at a time**, each in its own chat message, copy-paste-ready for Claude Code. Each prompt begins with `Read /CLAUDE.md and /dev-docs/RFC-WS-PRIMEVUE-PLAN-2-5.md before starting.` + +--- + +## 11. Open items / deferred + +### Deferred to a follow-up RFC + +- **`WorkspaceSwitcher.sub` data-source decision** — see `AD-2.5-W1` rationale; re-introducing `sub` requires a separate RFC with explicit C1/C2/C3 choice +- **CREWLI-DESIGN-TOKENS Phase-2 decisions** — surface tones, focus-ring, border-radius, font-size scale, spacing rhythm. Either Plan 2.5b or absorbed into Plan 4. +- **Mobile parity sprint** — any mobile visual divergences beyond design-doc §4 — separate sprint, post-Plan 4 or in parallel + +### Deferred to unblocked parity-batch work item + +- The 4 DEFERRED-HITL Plan 3 baseline captures (`AppTopbar`, `WorkspaceSwitcher` open/closed, `AppShellV2` integrated) +- Any new Plan 2.5 shell baselines (post-fix capture) +- `ENERGYDOTS-NAN`, `DRAGGABLEBLOCK-POINTERCANCEL`, `AD3-MENUBAR` Plan 3 backlog items remain Plan 3 backlog — not absorbed into Plan 2.5 + +### Plan 4 scope (informational, not part of this RFC) + +Plan 4 designs and implements the **template layer** — `PageTemplate`, `DataTablePage`, `DetailPage`, `FormPage` higher-order layouts that compose Plan 3 primitives + Plan 2 shell. Plan 4 RFC will be authored after Plan 2.5 merges and parity-batch baselines capture cleanly. Plan 4 prerequisites: + +1. Plan 2.5 closed (this RFC) +2. Parity-batch baselines captured against the corrected shell +3. `CREWLI-DESIGN-TOKENS.md` Phase-1 in place (template layer reads from token decisions) + +--- + +## Appendix A — Cross-doc updates required at closure + +At Plan 2.5 closure, the following dev-docs receive **content updates** (not just SHA bumps in the sync manifest): + +1. **`RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md` §4** — add supersession note for `AD-2.5-D1` (dark mode class-on-`` replaces ``) +2. **`RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md` §7.4** — add supersession note for `AD-2.5-W1` (sub removed in Plan 2.5) +3. **`BACKLOG.md`** — close `MIGRATION-PRIMEVUE-PLAN-2-5`; open `MIGRATION-PRIMEVUE-PLAN-2-5-PARITY-BATCH` (visual capture) and `MIGRATION-PRIMEVUE-PLAN-2-5B-TOKENS` (deferred token decisions) +4. **`CLAUDE.md`** — no update required if it doesn't reference dark-mode mechanism or workspace shape; verify +5. **`CREWLI-DESIGN-TOKENS.md`** — newly created, Phase-1 content per §6.3 + +The `.claude-sync/` regeneration runs automatically via the post-commit hook. Manual re-upload to Project Knowledge is required (no public upload API). + +--- + +## Appendix B — Why not absorb Plan 2.5 into Plan 3 closure docs + +Considered: extend the Plan 3 closure docs commit (`637d77b3`) with the 10 shell fixes and call it Plan 3 completion. + +Rejected because: + +1. **Scope coherence.** Plan 3 is "Tier-1 primitives." The 10 fixes are shell parity. Distinct concerns; mixing them dilutes commit history. +2. **AD ownership.** The four ADs in this RFC are architectural decisions, not primitive bugfixes. They deserve a named RFC for future reference (someone in 2027 finding a `.dark` class on a component-root in a code review can search "AD-2.5-D1" and land here). +3. **Parity-batch gating.** Plan 3 closure happened _before_ the shell-parity audit. Backporting fixes into Plan 3 obscures that the audit identified the gap _after_ Plan 3 — the chronology matters for understanding why the DEFERRED-HITL baselines were chosen. + +--- + +**End of RFC.**