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

55 KiB
Raw Permalink Blame History

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 — 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 BCREWLI-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:

// 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):

: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:

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:

app.use(PrimeVue, {
  theme: {
    preset: CrewliPreset,
    options: {
      prefix: "p",
      darkModeSelector: ".dark", // ← AD-2.5-D1
      cssLayer: false,
    },
  },
});

apps/app/src/stores/useShellUiStore.ts, applyDomAttributes():

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:
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:

<!-- 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):

// 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):

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):

<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:

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).

<!-- 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:

<!-- 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:
<!-- 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:

    <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:

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)

# 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 layerPageTemplate, 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.