# RFC — PrimeVue Migration Plan 2.5: Shell Parity + Design Token Foundation
| Field | Value |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| **Status** | ✅ Complete (2026-06-03) |
| **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.
---
## Supersessions vs the governing RFC (RFC-WS-GUI-REDESIGN-CREWLI-STARTER)
During implementation, two decisions diverged from the governing RFC. Both are deliberate and documented here:
### §4 — Dark mode selector
- Governing RFC specified: `.dark` class on `
`.
- Plan 2.5 implemented (AD-2.5-D1, P3 commit `d0dd45c0`): `.dark` on ``.
- Rationale: PrimeVue's `darkModeSelector` and Tailwind v4's `@custom-variant dark (&:where(.dark, .dark *))` both target the document root by convention; placing the class on `` aligns the entire dark-mode stack (PrimeVue + Tailwind + Crewli utilities) and avoids the cascading mismatch where AppTopbar reacted but downstream surfaces did not.
- Regression lock: `useShellUiStore.applyDomAttributes()` writes the class to `document.documentElement`.
### §7.4 — Workspace switcher sub line
- Governing RFC specified option A: no sub line under the workspace name.
- Plan 2.5 initially implemented option A (P4–P5).
- After visual review against the crewli-starter SoT, **reversed**: a light-grey placeholder sub line is now rendered in the expanded trigger and dropdown rows; collapsed remains bare. The placeholder is a neutral string ('Organisatie'); real type + metrics data is deferred under backlog `WORKSPACE-DROPDOWN-SUB-CONTENT` (future backend work).
- Rationale: the two-line layout reads materially better; the placeholder honors the no-backend constraint without sacrificing the visual.
> **Cross-doc note.** Appendix A of this RFC asks for per-section supersession notes in `RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md` §4 / §7.4. That file does not have discrete dark-mode / workspace-sub sections under those numbers (its §4 is "Out of scope"; there is no §7.4) — the §4 / §7.4 numbering above refers to the design SoT, per §1.2 of this RFC. A single top-level pointer was added to the governing RFC's status block instead; **this Supersessions section is authoritative.**
---
## 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
{{ item.label }}
{{
item.label
}}
```
**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 `