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