diff --git a/apps/app/src/assets/styles/tailwind.css b/apps/app/src/assets/styles/tailwind.css
index 4d27f2f5..26c133fc 100644
--- a/apps/app/src/assets/styles/tailwind.css
+++ b/apps/app/src/assets/styles/tailwind.css
@@ -13,6 +13,16 @@
@plugin "tailwindcss-primeui";
@source "../../**/*.{vue,ts,js,tsx,jsx}";
+/* AD-2.5-D1 (RFC-WS-PRIMEVUE-PLAN-2-5 §4): align Tailwind v4's `dark:`
+ * variant to the same `.dark` class on that PrimeVue's
+ * darkModeSelector and useShellUiStore.applyDomAttributes use. Without
+ * this, Tailwind v4 defaults to `prefers-color-scheme` (OS-controlled),
+ * and the explicit toggle in the topbar would update PrimeVue
+ * components but leave Tailwind utility classes (e.g. `dark:bg-*`,
+ * `dark:text-*`) responding only to the OS — half-broken dark mode.
+ */
+@custom-variant dark (&:where(.dark, .dark *));
+
/* AD-2.5-T1 — typography (RFC-WS-PRIMEVUE-PLAN-2-5).
*
* Inter is loaded via @fontsource/inter in main.ts (weights 400/500/600/700,
diff --git a/apps/app/src/components-v2/layout/AppTopbar.stories.ts b/apps/app/src/components-v2/layout/AppTopbar.stories.ts
index 65d7d914..952c91c3 100644
--- a/apps/app/src/components-v2/layout/AppTopbar.stories.ts
+++ b/apps/app/src/components-v2/layout/AppTopbar.stories.ts
@@ -38,10 +38,15 @@ export const Default: Story = {
}
/**
- * Dark mode is scoped to the story's own subtree via a `.dark` wrapper
- * (Aura darkModeSelector is the `.dark` class — see plugins/primevue).
- * Mutating instead would leak into every other story stacked on
- * the same autodocs page.
+ * Dark variant sets useShellUiStore.theme = 'dark' on the seed, but the
+ * DOM is not mutated here. Per AD-2.5-D1 (RFC-WS-PRIMEVUE-PLAN-2-5 §4),
+ * `.dark` lives only on document.documentElement; subtree-scoped
+ * wrappers are forbidden (single source of truth). A proper Storybook
+ * decorator that toggles documentElement on mount + cleans up on
+ * unmount is a Plan 2.5-PARITY-BATCH backlog item — until it lands,
+ * this story exists to lock the store-state shape; visual confirmation
+ * comes from the parity-batch Playwright captures (after Plan 2.5
+ * closes), not from Storybook autodocs.
*/
export const DarkTheme: Story = {
decorators: [
@@ -53,7 +58,7 @@ export const DarkTheme: Story = {
render: () => ({
components: { AppTopbar },
template: `
-
+
`,
diff --git a/apps/app/src/stores/__tests__/useShellUiStore.spec.ts b/apps/app/src/stores/__tests__/useShellUiStore.spec.ts
index b9723168..bd53b6f8 100644
--- a/apps/app/src/stores/__tests__/useShellUiStore.spec.ts
+++ b/apps/app/src/stores/__tests__/useShellUiStore.spec.ts
@@ -26,13 +26,15 @@ describe('useShellUiStore', () => {
expect(s.sidebarCollapsed).toBe(true)
})
- it('applyDomAttributes writes data-theme/data-density and .dark', () => {
+ // AD-2.5-D1: data-theme is no longer written (the historic mechanism
+ // is superseded by the .dark class on ). Density writes are
+ // unaffected and continue to use data-density.
+ it('applyDomAttributes writes data-density and .dark', () => {
const s = useShellUiStore()
s.setTheme('dark')
s.setDensity('compact')
s.applyDomAttributes()
- expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
expect(document.documentElement.getAttribute('data-density')).toBe('compact')
expect(document.documentElement.classList.contains('dark')).toBe(true)
})
@@ -56,3 +58,41 @@ describe('useShellUiStore', () => {
expect(s.mobileOpen).toBe(false)
})
})
+
+describe('applyDomAttributes — dark mode (AD-2.5-D1)', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+
+ // Reset documentElement between tests so a prior toggle does not
+ // bleed into the next case. Both the .dark class and the legacy
+ // data-theme attribute are cleared — the latter so the "no
+ // data-theme attribute" spec sees a true negative even on a
+ // hypothetical run order where it follows a future regression.
+ document.documentElement.classList.remove('dark')
+ document.documentElement.removeAttribute('data-theme')
+ })
+
+ it('toggles the .dark class on document.documentElement when theme is dark', () => {
+ const s = useShellUiStore()
+
+ s.setTheme('dark')
+ s.applyDomAttributes()
+ expect(document.documentElement.classList.contains('dark')).toBe(true)
+
+ s.setTheme('light')
+ s.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 s = useShellUiStore()
+
+ s.setTheme('dark')
+ s.applyDomAttributes()
+ expect(document.documentElement.hasAttribute('data-theme')).toBe(false)
+
+ s.setTheme('light')
+ s.applyDomAttributes()
+ expect(document.documentElement.hasAttribute('data-theme')).toBe(false)
+ })
+})
diff --git a/apps/app/src/stores/useShellUiStore.ts b/apps/app/src/stores/useShellUiStore.ts
index 6fd1cf7a..a4d5c0fd 100644
--- a/apps/app/src/stores/useShellUiStore.ts
+++ b/apps/app/src/stores/useShellUiStore.ts
@@ -3,8 +3,14 @@ import { ref } from 'vue'
// v2 shell UI state ONLY (RFC-WS-GUI-REDESIGN AD-G4). No tenant/org
// state — that stays in useAuthStore/useOrganisationStore. Owns the
-// writes to //.dark (composes with
-// Aura darkModeSelector '.dark'); v2 bypasses Vuexy useSkins.ts.
+// writes to /.dark; v2 bypasses Vuexy useSkins.ts.
+//
+// AD-2.5-D1 (RFC-WS-PRIMEVUE-PLAN-2-5 §4): dark mode is the single
+// class `.dark` on document.documentElement. Tailwind v4's
+// @custom-variant dark (in assets/styles/tailwind.css) and PrimeVue's
+// darkModeSelector (in plugins/primevue/index.ts:31) both react to
+// that class — one toggle, both ecosystems. The historic
+// mechanism is superseded and removed.
export type ShellTheme = 'light' | 'dark'
export type ShellDensity = 'comfortable' | 'compact'
@@ -41,9 +47,18 @@ export const useShellUiStore = defineStore('shellUi', () => {
function applyDomAttributes(): void {
const el = document.documentElement
- el.setAttribute('data-theme', theme.value)
+ // AD-2.5-D1: dark mode is `.dark` on . PrimeVue
+ // darkModeSelector and Tailwind v4 @custom-variant dark both
+ // resolve to this class — see file header.
+ if (theme.value === 'dark')
+ el.classList.add('dark')
+ else
+ el.classList.remove('dark')
+
+ // Density continues to use data-attribute (orthogonal axis to
+ // colour scheme; coexists with the .dark class on the same root).
+ // Density toggle UI lands in P6 Fix 10.
el.setAttribute('data-density', density.value)
- el.classList.toggle('dark', theme.value === 'dark')
}
function openDrawer(component: string, props: Record
= {}): void {