Files
crewli/dev-docs/RFC-WS-PRIMEVUE-PLAN-2-5.md

1008 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<html>`, 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 `<lg`); no mobile-specific Plan 2.5 fixes | §3.4 |
| Q4 | Breadcrumb data source | Central navigation registry + `useBreadcrumb` composable, rendered via PrimeVue `Breadcrumb` | `AD-2.5-B1` |
| Q5 | Typography | Revert Public Sans → Inter via `@fontsource/inter` (lokaal package) | `AD-2.5-T1` |
Each is unpacked as a full Architecture Decision in §4.
---
## 3. Scope
### 3.1 In scope
- **Track A** — 10 shell-parity fixes against the documented design SoT (§5)
- **Track B** — `CREWLI-DESIGN-TOKENS.md` inventory file + Typography revert end-to-end + regression-lock spec (§6)
- Four Architecture Decisions: `AD-2.5-T1` (Typography), `AD-2.5-D1` (Dark-mode selector), `AD-2.5-W1` (Workspace sub), `AD-2.5-B1` (Breadcrumb source)
- One new composable + config: `apps/app/src/config/navigation.ts`, `apps/app/src/composables/useBreadcrumb.ts` (§4 AD-2.5-B1)
### 3.2 Track A scope summary
10 fixes against `crewli-starter` dashboard reference, all desktop-`≥lg`-viewport, all rooted in design-doc §4 / §7.4 / §13 or in `crewli-starter` source. No fix introduces a new component, new store, or schema change. Full details in §5.
### 3.3 Track B scope summary
The token audit is **phase-1 only** in Plan 2.5: inventory + classification of every `:root` CSS variable `crewli-starter` sets, plus end-to-end decision (revert + regression-lock) for **Typography only**. Remaining token decisions are tagged `DEFERRED` in `CREWLI-DESIGN-TOKENS.md` for follow-up. Scope rationale in §6.4.
### 3.4 Out of scope (explicit)
- **Plan 4 template layer.** Designed and RFC'd after Plan 2.5 merge + parity-batch capture, not before.
- **Parity-batch baseline captures for Plan 3 primitives.** The 4 DEFERRED-HITL captures execute as a separate work item _after_ Plan 2.5 merges. They are the _unblocking outcome_ of this RFC, not part of it.
- **Backend schema changes.** Explicitly: no `organisations.type` enum, no organisation-stats endpoint, no `withCount` queries on the org-list endpoint. Q1's C4 decision eliminates the only Plan 2.5-adjacent need for these.
- **Mobile-only divergences beyond design-doc §4.** Design-doc §4 specifies PrimeVue `Drawer` overlay on `<lg` — that pattern continues. Any mobile visual issues _not_ attributable to one of the 10 shell-parity fixes (which apply at all viewports) are tagged `MOBILE-PARITY-DEFERRED` and roll up into a future mobile-parity sprint.
- **Full design-token revert.** Phase-1 audit decides Typography only. All other tokens (color scale offsets, surface tones, focus ring, spacing) are inventoried but classification + decision is deferred.
- **`WorkspaceSwitcher` `sub` field implementation** beyond removal. The `sub` slot in the component template is removed in Plan 2.5; re-introducing it (C1/C2/C3 from chat discussion) requires a separate RFC.
---
## 4. Architecture Decisions
### AD-2.5-T1 — Typography: Inter via `@fontsource/inter`
**Status:** Decided 2026-05-19
**Context.** `apps/app/src/main.css` (or the equivalent token application site) sets `--p-font-family: 'Public Sans', ...` with no documented rationale. PrimeVue Aura preset ships no built-in font; the application controls typography. PrimeVue's own showcase, Figma UIKit, and Volt commercial templates use Inter; Aura's metrics are calibrated against Inter-class fonts.
**Decision.** Revert to **Inter** loaded locally via `@fontsource/inter` package. Apply at the theme layer (`apps/app/src/plugins/primevue/theme.ts` per `AD-2` in the governing RFC), not via ad-hoc `main.css` overrides.
**Rationale.**
1. **Inter is the industry-standard SaaS typography** — Linear, Stripe, Vercel, GitHub, Notion. Crewli's competitive set. New users perceive Inter as "professional SaaS" by default; Public Sans reads as "civic / public-sector tooling."
2. **`@fontsource/inter` is a local package** — no Google Fonts request, no CDN race condition, no GDPR signing-up-third-parties issue, no FOUT on slow connections.
3. **Metric alignment with Aura.** Aura's spacing tokens (`--p-form-field-padding-y`, `--p-button-padding-y`) are sized for Inter's x-height. Public Sans has slightly different metrics — sub-pixel drift visible in dense data tables, form-field stacks, and inline labels.
4. **No documented rationale for Public Sans.** The audit found neither a commit message, RFC reference, nor design-doc entry justifying the choice. The default position when there is no rationale is "match the framework's de-facto convention."
**Note on "PrimeVue default."** PrimeVue Aura technically _has no font default_ — the docs state: _"There is no design for fonts as UI components inherit their font settings from the application."_ The "Inter is PrimeVue default" claim is true in practice (showcase, UIKit, Volt) but not technically in the preset config. This AD documents the distinction so future contributors do not look for a `--p-font-family` token in the Aura preset and conclude wrongly that "it's there."
**Implementation site.** `apps/app/src/plugins/primevue/theme.ts`:
```ts
// theme.ts — typography lives here per RFC AD-2, not in main.css
import { definePreset } from "@primeuix/themes";
import Aura from "@primeuix/themes/aura";
import "@fontsource/inter/400.css";
import "@fontsource/inter/500.css";
import "@fontsource/inter/600.css";
import "@fontsource/inter/700.css";
export const CrewliPreset = definePreset(Aura, {
semantic: {
// ... existing teal primary ...
},
extend: {
crewli: {
// ... other Crewli-specific tokens ...
},
},
});
// Font is applied via CSS at the application layer, since Aura
// has no font token. The single application site is below.
```
`apps/app/src/main.css` (or the dedicated token site — verify in implementation):
```css
:root {
--crewli-font-family:
"Inter", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
}
html,
body {
font-family: var(--crewli-font-family);
}
```
**Removal scope.** Every Public Sans reference must be deleted, not commented out:
- `apps/app/src/main.css` `font-family` declarations
- `package.json` `@fontsource/public-sans` dependency (if present — verify)
- Any `<link>` 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",
'<link rel="stylesheet" href="/src/main.css">',
);
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 `<html>`
**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 `<html data-theme>` 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-`<html>`. 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., `<html>`). This **supersedes** design-doc §4's `<html data-theme>` 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 `<html>` (`@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 `<html data-theme>` 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-`<html>` 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 <html>.
// 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 `<html>` 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 `<html data-theme>` mechanism described here; the class-based selector on `<html>` 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
<!-- AD-2.5-W1: sub field removed. Re-introducing requires a separate RFC
with explicit data-source decision (organisations.city, member count,
subscription tier none of these are decided as of 2026-05-19). -->
<template>
<button class="workspace-cell">
<span class="initials-square" :style="{ background: gradient }">
{{ initials }}
</span>
<span class="workspace-name">{{ name }}</span>
<i class="pi pi-chevron-down" />
</button>
</template>
```
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 `<span class="workspace-sub">{{ sub }}</span>` 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<BreadcrumbItem[]>(() => {
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
<script setup lang="ts">
import { computed } from "vue";
import Breadcrumb from "primevue/breadcrumb";
import { useBreadcrumb } from "@/composables/useBreadcrumb";
const crumbs = useBreadcrumb();
// PrimeVue Breadcrumb expects MenuItem[] — map our shape.
const items = computed(() =>
crumbs.value.map((c) => ({
label: c.label,
route: c.routeName ? { name: c.routeName } : undefined,
})),
);
</script>
<template>
<Breadcrumb
:model="items"
:pt="{ root: { class: 'border-none p-0 bg-transparent' } }"
>
<template #item="{ item }">
<RouterLink
v-if="item.route"
:to="item.route"
class="text-sm text-surface-600 hover:text-surface-900"
>
{{ item.label }}
</RouterLink>
<span v-else class="text-sm text-surface-900 font-medium">{{
item.label
}}</span>
</template>
</Breadcrumb>
</template>
```
**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 `<AppBreadcrumb />` (the new primitive from `AD-2.5-B1`).
```vue
<!-- AppTopbar.vue, AFTER Fix 1 + Fix 2 -->
<template>
<header class="app-topbar">
<!-- left: breadcrumb (AD-2.5-B1) -->
<AppBreadcrumb />
<!-- right: actions (Fix 10 specifies exact order/state) -->
<div class="topbar-actions">
<!-- search / dark toggle / notifications / user menu -->
</div>
</header>
</template>
```
**Regression lock.** Component test asserting `AppTopbar` renders an `<AppBreadcrumb>` 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
<!-- AppSidebar.vue, AFTER Fix 3 + Fix 9 -->
<template>
<aside class="app-sidebar">
<SidebarHeader />
<SidebarNav />
<div class="sidebar-spacer flex-1" />
<!-- pushes switcher to bottom -->
<WorkspaceSwitcher />
</aside>
</template>
```
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 `<html>`; 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 `<main>` in `AppShellV2`:
```vue
<!-- AppShellV2.vue -->
<main class="flex-1 overflow-auto p-6">
<!-- p-6 already adds spacing; verify topbar height matches the spec -->
<slot />
</main>
```
If the topbar is `h-14` (current Plan 1 spec) and `<main>` 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\|▼\|&#9660;" 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 `<Button>`** to `AppTopbar.vue` between search trigger and dark-mode toggle.
3. **Identify the correct icon.** Implementation prompt requires Claude Code to read `crewli-starter`'s topbar source (Bert pastes in) and use the same icon. If it's a Tabler icon: add to `addCollection` at app bootstrap (per the established Iconify discipline — no runtime CDN fetches). If it's an inline SVG: port verbatim. **Do not guess the icon name.**
4. **Wire interaction + a11y:**
```vue
<Button
text
rounded
:aria-label="'Toggle density'"
:aria-pressed="shell.density === 'compact'"
data-testid="density-toggle"
@click="shell.toggleDensity()"
>
<Icon :icon="DENSITY_ICON" /> <!-- DENSITY_ICON identified per step 3 -->
</Button>
```
5. **Apply via `applyDomAttributes()`.** Density continues to write `data-density="<value>"` on `document.documentElement` (orthogonal to `AD-2.5-D1`'s `.dark` class on `<html>` — 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 <html> 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 15 (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 15 per RFC-WS-PRIMEVUE-PLAN-2-5 §5
6. Shell-parity fixes 610 (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 610 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 14 each block the next step (foundation has dependencies).
- Step 5 requires steps 14 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 | SmallMedium (audit-then-edit) |
| P4 | AD-2.5-W1 + sidebar/breadcrumb wiring | Medium |
| P5 | Shell fixes 15 | Medium |
| P6 | Shell fixes 610 (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-`<html>` replaces `<html data-theme>`)
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.**