refactor(theme): Plan 2.5 P3 — dark mode class on <html> (AD-2.5-D1 + Fix 6)

Per RFC-WS-PRIMEVUE-PLAN-2-5 §4 AD-2.5-D1 and §5.6 Fix 6. Single class
on <html> 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 09:31:30 +02:00
parent 41af180168
commit d0dd45c03a
4 changed files with 81 additions and 11 deletions

View File

@@ -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 <html> 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,

View File

@@ -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 <html> 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: `
<div class="dark min-h-[200px] bg-[var(--p-content-background)]">
<div class="min-h-[200px] bg-[var(--p-content-background)]">
<AppTopbar />
</div>
`,

View File

@@ -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 <html>). 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)
})
})

View File

@@ -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 <html data-theme>/<html data-density>/.dark (composes with
// Aura darkModeSelector '.dark'); v2 bypasses Vuexy useSkins.ts.
// writes to <html data-density>/.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
// <html data-theme="..."> 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 <html>. 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<string, unknown> = {}): void {