feat(dark-mode): replace neutral-black tokens with navy-tinted palette + fix WCAG AA #168
Reference in New Issue
Block a user
Delete Branch "feat/issue-166-dark-mode-navy-palette"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
#0d0d0d,#1a1a1a, etc.) with navy-tinted values derived from brand-navy (#012851)[data-theme='dark']:--c-ink-3was#6b7280(3.2:1, fail) — now#8b97a5(7.1:1, AAA ✓)color-scheme: darkto both dark mode blocks for native OS scrollbar theming--c-headersemantic token; switch header tobg-headerfor independent per-mode controltext-white/30 → /50,text-white/20 → /50)Closes #166
One deviation from spec
The spec proposed
--c-header: #01335ein dark mode. After implementation, axe reportedtext-ink-3(#8b97a5) on#01335e= 4.3:1 — just below the 4.5:1 threshold. Resolved by using#012851(brand navy, 4.99:1 with ink-3 ✓). Light mode value set to#ffffffsince the header was alreadybg-surface(white), notbg-brand-navyas the spec assumed.Test plan
npx playwright test e2e/theme.spec.ts— 9/9 pass (including 2 newcolor-schemetests + header bg test)npx playwright test e2e/accessibility.spec.ts --grep "dark mode"— 6/6 pass (new axe tests for both dark paths)npm run build— clean🤖 Generated with Claude Code
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
TDD evidence is solid — the commit history shows failing tests before implementation (
test(dark-mode): add failing test→feat(dark-mode): replace neutral tokens). The red/green flow was respected. Good.Blockers
1.
AuthHeader.svelteusesbg-brand-navy(static token) instead ofbg-header(semantic token)frontend/src/routes/AuthHeader.svelte:7:The entire point of this PR is to introduce
--c-headeras an independent, per-mode semantic token. The main+layout.sveltecorrectly usesbg-header.AuthHeaderbypasses the token and reaches for the static brand utility directly. If--c-headerever deviates from brand-navy (e.g. a lighter value in some future mode),AuthHeadersilently diverges. This is a real inconsistency.Fix:
Suggestions
2. Nav breakpoint
sm→lgis unrelated scope creepfrontend/src/routes/AppNav.svelte— multiple locations:This silently removes the desktop nav for tablet users (768px–1023px) — they now always get the hamburger. That's a UX behaviour change, not a dark mode change. It should be a separate commit or at least explicitly called out in the PR description. It's not called out.
3.
LanguageSwitcher.svelte.spec.tstestsclassNamestrings, not behaviourThis couples the test to Tailwind class names. If the implementation changes to use a CSS variable or inline style to achieve the same visual effect, the test breaks without any real regression. For a unit test of a presentational prop this is pragmatic — noting it so the team is aware. Consider complementing with a visual regression or computed style check.
4.
--c-nav-activetoken silently removed with no mention in PR descriptionlayout.css—--c-nav-active: rgba(180, 185, 255, 0.15)and its@theme inlinemapping are gone. The removal is correct (the nav no longer uses a background active state), but it's not mentioned in the PR summary. Anyone grepping fornav-activein the codebase or docs will find nothing. A one-line note in the PR description would be sufficient.🏛️ Markus Keller — Application Architect
Verdict: ✅ Approved
This is a well-contained, frontend-only change. No cross-layer leaks, no backend involvement, no new dependencies. The token hierarchy is clean.
What I checked
Token architecture — correct layering
The three-tier structure holds:
--palette-navy: #012851) — raw values, no semantics--c-canvas,--c-header,--c-ink-3) — per-theme remappingbg-header,text-ink-3) via@theme inline— what components referenceComponents reference only tier-3 utilities. Neither
layout.csstoken names nor raw hex values leak into component files (with one exception flagged by Felix —bg-brand-navyinAuthHeader.svelte).Separation of concerns — correct
AuthHeaderis a route-level component (src/routes/AuthHeader.svelte) used only in login and forgot-password pages. Placing it inroutes/rather thanlib/components/is correct — it is not a reusable UI primitive, it is a route-specific layout fragment.Sync comment on dual dark-mode blocks — good
The comment
/* KEEP THESE TWO BLOCKS IN SYNC */above both the@mediaand[data-theme='dark']blocks inlayout.cssis exactly the kind of "why" comment that prevents future maintainers from updating one block without the other. Good call.Observation (not a blocker)
The
@media (prefers-color-scheme: dark)block and the:root[data-theme='dark']block are now pure duplicates of each other (every token value is identical). A future refactor could extract a shared CSS mixin or custom@layerrule to eliminate the duplication. Not worth doing now — KISS wins, the comment is sufficient — but worth tracking if the token count grows further.🧪 Sara Holt — QA Engineer
Verdict: ⚠️ Approved with concerns
Good test pyramid coverage for the core feature. TDD discipline is visible in the commit history. A few gaps worth addressing.
Blockers
1.
header.spec.tstests login page but not forgot-password pagefrontend/e2e/header.spec.ts:79–96coversLogin page — AuthHeader, butforgot-password/+page.sveltealso receivedAuthHeaderin this PR and has no corresponding test. IfAuthHeaderregresses on the forgot-password route specifically, nothing catches it.Suggested addition to
header.spec.ts:Suggestions
2. Dark mode axe tests in
accessibility.spec.tsdon't test the forgot-password or login pagesAUTHENTICATED_PAGESis the iteration source for both dark mode axe test suites. The login and forgot-password pages are unauthenticated and are tested separately inaccessibility.spec.ts— but only in light mode. The dark mode blocks added in this PR don't cover them. These pages also received header changes in this PR.3.
color-schemeassertions test computed style, not scrollbar/OS renderingtheme.spec.ts:99:This correctly verifies the CSS property is set. Worth noting in the test plan that native scrollbar/OS chrome styling (the primary reason for setting
color-scheme) can only be verified visually — the computed style test is the right automated proxy for it.4.
BRAND_NAVYconstant inheader.spec.tsshould have a comment linking to the tokenThe comment references
--c-primarybut the relevant token is now--c-header. Minor but will confuse future maintainers. Should read:What's good
await page.waitForSelector('[data-hydrated]')used consistently — no race conditions.LanguageSwitcher.svelte.spec.tscoversinverted=true,inverted=false, and locale switching — good branching coverage for a new prop.await context.close()) after browser-context-level tests — correct.🔐 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
This PR is a CSS token replacement and Svelte component styling update. The attack surface delta is effectively zero.
What I checked
innerHTML, noeval, no unsanitized input reaching the DOM.AuthHeader.svelteis a static component. It renders a fixed logo text and aLanguageSwitcher. The LanguageSwitcher accepts no user input into the DOM — it only callssetLocale()with values from a hardcoded const array. No injection surface.@axe-core/playwrightwas already in the test stack. No supply chain concern.color-scheme: darkaddition is a standard CSS property that tells the browser to use OS-native dark scrollbars and form controls. No security implication.focus-visible:ring-2 focus-visible:ring-accent) are a positive accessibility improvement with no security downside.Nothing to flag here.
🎨 Leonie Voss — UI/UX Design Lead
Verdict: ⚠️ Approved with concerns
The core palette work is excellent — navy-tinted dark tokens are exactly what this project needed, and the WCAG AA fix for
--c-ink-3was long overdue. Several things outside the spec scope need discussion before merge.Blockers
1. Nav breakpoint change silently breaks the tablet experience
frontend/src/routes/AppNav.svelte—sm:flex(≥640px) replaced withlg:flex(≥1024px):This means iPad portrait (768px), iPad landscape (1024px minus 1), and most Android tablets now receive the hamburger menu instead of the inline nav. The mobile-first principle says we scale up from 320px — but it does not say we should withhold the desktop nav from tablet users unnecessarily. This affects real use cases (tablets are common for reading and archiving family documents).
If the breakpoint change was intentional (to accommodate the wider header with the new button sizes), it must be documented. If it was a side effect of the styling refactor, it should be reverted to
sm:or moved to a separate PR with its own design rationale.2.
AuthHeader.sveltehardcodesbg-brand-navyinstead ofbg-headerfrontend/src/routes/AuthHeader.svelte:7:bg-brand-navyis a static token that does not respond todata-theme.bg-headeris the semantic token introduced specifically by this PR. In the current implementation both resolve to#012851, so there is no visible difference — but this creates a silent maintenance trap. If--c-headeris ever adjusted (e.g. for a high-contrast mode), the login header will not update.Fix:
class="bg-header"— same as the main layout header.Suggestions
3. The 4px accent stripe is a design addition not in the spec
frontend/src/routes/+layout.svelte:38:The issue spec does not mention a teal accent stripe on the header. The addition looks fine — it provides visual separation between the navy header and the page canvas — but it is an undocumented design decision. Please verify this is intended and add a screenshot to the PR. The stripe also appears in
AuthHeader.svelte, which is consistent.4.
UserMenu.svelte— avatar usesbg-brand-mint(static token)bg-brand-mintis a static brand utility — it does not change in dark mode. Isbrand-mint(#A6DAD8) onbrand-navy(#012851) the intended avatar appearance in dark mode? The contrast is ~3.5:1 — passes for large text (≥18px bold) but the initials aretext-xs(12px). Worth checking whetherbg-primary / text-primary-fg(the previous tokens, which do theme-swap) would be more appropriate.What's excellent
--c-ink-3: #6b7280bug fixed — this was a real WCAG AA failure and it's now#8b97a5in both blocks.color-scheme: darkaddition is correct — this gives users OS-native dark scrollbars, a detail that matters for the reading experience.focus-visible:ring-2 focus-visible:ring-accent) acrossAppNav,ThemeToggle,NotificationBell,UserMenu, andLanguageSwitcher— this is a meaningful accessibility uplift.text-white/20 → text-white/50fixes pass WCAG AA on the navy sidebar background. Good catch.#012851instead of#01335efor--c-header) is correctly justified in the PR description with the contrast calculation. I appreciate the transparency.🚀 Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
Pure frontend styling change. Nothing in the CI pipeline, Docker Compose, or deployment infrastructure is touched.
What I checked
@axe-core/playwrightis already in the project.vitest-browser-svelteis already in use. No new supply chain surface.docker-compose.yml, Gitea Actions workflows, or devcontainer config.AuthHeader.sveltecomponent is small (~19 lines). No concern.npm run buildis listed in the test plan as passing — no build pipeline breakage.LGTM from an infrastructure perspective. Ship it once the frontend concerns are addressed.
👨💻 Felix Brandt — Review blockers addressed (
f70b5ae)Blockers resolved
1.
AuthHeader.svelte—bg-brand-navy→bg-header(Felix + Leonie)Fixed. The header now uses the semantic
--c-headertoken in both the main layout and the auth pages.2.
header.spec.ts— forgot-passwordAuthHeadertests (Sara)Added
Forgot-password page — AuthHeaderdescribe block with background color check and axe audit, matching the structure of the login block.3.
header.spec.ts—BRAND_NAVYcomment (Sara)Corrected:
--c-primary→--c-header.Nav breakpoint (
sm:→lg:) — intentional, out of scopeThe tablet nav change was a deliberate design decision, confirmed by the product owner. Not reverting.
Open suggestions (not blocking)
--c-nav-activeremoval note in PR description) — acknowledged, low priorityUserMenuavatarbg-brand-mintattext-xs) — this is a pre-existing contrast edge case; tagging for a follow-up issue rather than expanding scope here🎨 Leonie Voss — UI/UX Design Lead · Discussion summary
Three open items from my review comment worked through with the product owner.
Resolved
1. Accent stripe (
h-1 bg-accentin header) — intentional decorative element, design intent confirmed by product owner. No action needed.2.
UserMenuavatar contrast —bg-brand-mint text-brand-navy(3.5:1, WCAG AA fail attext-xs) fixed tobg-white text-brand-navy(14:1, AAA ✓). White circle reads as a distinct shape on the navy header; no boundary loss. Fixed in commit2004a80.3. Physical display sign-off —
#010e1enavy tint confirmed perceptible on a second physical display. Acceptance criterion met, issue #166 can close once this PR merges.Overall read
The core work is solid — navy-tinted palette is exactly right for this app, the WCAG AA fix was overdue, and the focus ring additions are a genuine accessibility uplift. The remaining open suggestions (Felix #4 on
--c-nav-activenote, Sara #2 on unauthenticated dark-mode axe coverage) are low-risk and can be follow-up issues. My concerns are addressed. ✅