From d0dd45c03a0555401887169ab41e95b475d614b4 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Wed, 20 May 2026 09:31:30 +0200 Subject: [PATCH] =?UTF-8?q?refactor(theme):=20Plan=202.5=20P3=20=E2=80=94?= =?UTF-8?q?=20dark=20mode=20class=20on=20=20(AD-2.5-D1=20+=20Fix=206?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per RFC-WS-PRIMEVUE-PLAN-2-5 §4 AD-2.5-D1 and §5.6 Fix 6. Single class on drives both PrimeVue darkModeSelector and Tailwind v4 @custom-variant dark — one toggle, two ecosystems react. Audit findings (pre-change): - applyDomAttributes was writing BOTH data-theme="dark" AND .dark on documentElement. The historic data-theme write is the design-doc §4 mechanism that AD-2.5-D1 supersedes; the .dark toggle was already correct (and is already paired with PrimeVue darkModeSelector: '.dark' in plugins/primevue/index.ts:31, verified in P1). - tailwind.css had NO @custom-variant dark directive — Tailwind v4 default is `prefers-color-scheme` (OS-controlled), so utility `dark:` variants would have ignored the topbar toggle entirely. - One stray .dark subtree wrapper in AppTopbar.stories.ts:56 (DarkTheme story) — deliberate Storybook isolation per its comment, but in violation of AD-2.5-D1's single-source-of-truth rule. Changes: - useShellUiStore.applyDomAttributes(): removed data-theme write, kept .dark class toggle on document.documentElement, kept data-density (P6 wires density-toggle UI; density is an orthogonal axis and unaffected). File-header comment updated to cite AD-2.5-D1 + reference the Tailwind & PrimeVue mirror sites. - assets/styles/tailwind.css: added `@custom-variant dark (&:where(.dark, .dark *))` so utility `dark:` classes resolve via the same .dark trigger. - components-v2/layout/AppTopbar.stories.ts: stripped class="dark" from the DarkTheme story's render wrapper. Story comment updated to flag that visual confirmation now comes via parity-batch Playwright (after Plan 2.5 closes), not Storybook autodocs. A proper documentElement-mutating decorator is a backlog item. - stores/__tests__/useShellUiStore.spec.ts: updated the existing applyDomAttributes assertion to drop the data-theme expectation (the write is gone); added a new `describe('applyDomAttributes — dark mode (AD-2.5-D1)', …)` block with 2 specs (class toggle reactive, no data-theme attribute written). Re-grep verification — all three return 0 hits: - stray .dark in v2 (excluding `dark:` utility prefixes) - data-theme setAttribute calls in stores/ - [data-theme=…] CSS selectors anywhere Suite delta: 573 → 575 (+2). vue-tsc clean. Scoped ESLint clean. Note: darkModeSelector: '.dark' was already set in plugins/primevue/index.ts:31 (verified in P1 audit) — the config dimension of AD-2.5-D1 was satisfied before this commit; P3 closes the store-side, Tailwind-side, and stray-class dimensions. Co-Authored-By: Claude Opus 4.7 --- apps/app/src/assets/styles/tailwind.css | 10 +++++ .../components-v2/layout/AppTopbar.stories.ts | 15 ++++--- .../stores/__tests__/useShellUiStore.spec.ts | 44 ++++++++++++++++++- apps/app/src/stores/useShellUiStore.ts | 23 ++++++++-- 4 files changed, 81 insertions(+), 11 deletions(-) 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 {