feat(dark-mode): replace neutral-black tokens with navy-tinted palette + fix WCAG AA failure #166
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Problem
The current dark mode uses neutral black/gray tokens that have no connection to the brand-navy palette. This causes three classes of issues:
Issue 01 · Critical — Canvas is neutral black, not brand-navy
--c-canvas: #0d0d0dis the brand-dark text color (defined in the styleguide as "near-black text when maximum contrast is needed"), repurposed as a page background. It has no visual connection to brand-navy (#012851). Dark mode should feel like the night version of navy, not a generic charcoal.Issue 02 · Critical — Surface/overlay/muted are neutral grays
#1a1a1a,#242424,#252525are generic warm-grays — indistinguishable from Notion, GitHub dark, VS Code. An academic publisher's dark mode should feel like a candlelit reading room, not a generic dark UI.Issue 03 · Critical — WCAG AA failure in manual dark override
The
@media (prefers-color-scheme: dark)block correctly sets--c-ink-3: #8b97a5, but the:root[data-theme='dark']manual override (used by the theme toggle) sets--c-ink-3: #6b7280— the same value as light mode.#6b7280on#1a1a1a= 3.2:1 — fails WCAG AA (minimum 4.5:1 for normal text). This affects all secondary labels, metadata, and date text.Issue 04 · High — Header loses visual prominence in dark mode
In light mode, brand-navy header (
#012851) against white canvas = ~14:1 contrast. In dark mode, same header against#0d0d0dcanvas = only 2.1:1 — the header blends into the page.Issue 05 · High — Timeline row boundaries nearly invisible
bg-surface(#1a1a1a) onbg-canvas(#0d0d0d) = only ~10 lightness points delta. Rows merge into a single dark mass.Issue 06 · Medium — Borders are neutral gray
--c-line: #3d3d3dand--c-line-2: #2e2e2eare neutral grays with no brand identity.Solution
Replace neutral dark tokens with navy-tinted values derived from brand-navy (
#012851):--c-canvas#0d0d0d#010e1e--c-surface#1a1a1a#011526--c-overlay#242424#011e38--c-muted#252525#011a30--c-line#3d3d3d#0d3358--c-line-2#2e2e2e#092843--c-ink-3#6b7280⚠️#8b97a5--c-header#01335e--c-ink#f0efe9--c-ink-2#9ca3af--c-primary#a1dcd8All proposed values pass WCAG AAA (7:1+).
--c-ink-3 #8b97a5on#011526= 7.1:1 AAA ✓.Header token strategy
Add
--c-headerCSS variable:#012851in light mode,#01335ein dark mode. In+layout.svelte, replacebg-brand-navyon the<header>element withbg-header. No Svelte conditionals needed — pure CSS.Implementation
File:
frontend/src/app.css(orlayout.css)Changes apply to both
@media (prefers-color-scheme: dark) :root:not([data-theme='light'])and:root[data-theme='dark']:File:
frontend/src/routes/+layout.sveltebg-brand-navy→bg-headeron the<header>elementAcceptance Criteria
--c-ink-3is#8b97a5in both@mediaand[data-theme='dark']blocks (no more inconsistency)--c-headertoken added; header usesbg-headerinstead ofbg-brand-navySee
docs/specs/dark-mode-redesign-spec.htmlfor full mockups, contrast verification, and before/after comparison.👨💻 Felix Brandt — Senior Fullstack Developer
Questions & Observations
File name ambiguity: The issue says
frontend/src/app.css(orlayout.css). These are different files with different scopes. Which one actually holds the dark mode token blocks? This needs to be precise before implementation starts — touching the wrong file silently fixes nothing.Other
bg-brand-navyusages: The issue scopes the change to the<header>in+layout.svelte, butbg-brand-navymay appear elsewhere in the app (buttons, badges, banners). Should those all switch tobg-header, or only the header element? The rename has different semantics:bg-brand-navyis "use the brand navy color";bg-headeris "use the header background token". These are not interchangeable concepts — a primary button that happens to be brand-navy should not inherit the dark-mode header lightening.--c-headerlight mode default: The issue proposes adding--c-header: #012851to:root. That's correct, but I'd verify whether:rootin this codebase is the right place or whether light-mode tokens live in a specific named block (e.g.:root:not([data-theme='dark'])). If it's just:root, the light mode value will be inherited in dark mode before the dark mode override fires — that's fine, but it should be deliberate.TDD question: This is a visual/CSS change, so unit tests don't apply directly. What is the agreed test vehicle here? Playwright visual regression screenshots at 1440px in both system-dark (via
page.emulateMedia({ colorScheme: 'dark' })) and toggle-dark (data-theme='dark') are the obvious choice. Are these already in the suite? If not, this implementation should add them — the WCAG failure in Issue 03 is exactly the kind of regression a Playwright + axe run would have caught.Suggestions
bg-brand-navyusages before implementing, so the scope of the<header>change is consciously bounded and nothing is accidentally left behind or accidentally changed.🏗️ Markus Keller — Application Architect
Questions & Observations
Tailwind 4
@theme inlinesupport: The implementation snippet uses@theme inline { --color-header: var(--c-header); }. This is the Tailwind CSS v4 pattern for exposing CSS custom properties as utility classes. Worth confirming the exact syntax is correct for this project's Tailwind version before implementation — v4 has had several beta API changes around@theme.Semantic token abstraction is the right call: The
--c-headertoken approach is architecturally sound. It separates what the header should look like from which specific brand color achieves that at a given mode. This is the correct direction — all mode-specific overrides live in CSS, zero Svelte conditionals needed. Good.Token naming consistency: The existing tokens use a
--c-*prefix (canvas, surface, overlay, etc.) mapped to--color-*in@theme inline. The new--c-headershould follow the same pattern. Is there already a--color-headerTailwind utility name that doesn't conflict with anything in Tailwind's built-in utilities? Quick check warranted.Scope of this change: This is entirely a frontend CSS concern — no backend, no database, no API contract change. That's a good containment boundary. The risk surface is narrow: one CSS file + one Svelte template attribute. Low complexity, low coupling.
Future-proofing thought: Once
--c-headerexists, other themed surfaces (sidebar, footer, modals) will naturally want the same treatment. That's fine — this establishes the pattern. Just noting it so the implementor doesn't feel pressure to generalize now. KISS: only add what Issue 04 specifically requires.Suggestions
app.cssvslayout.css) is architectural — getting it wrong means the override never fires for one of the dark mode paths.--c-headerlight-mode definition explaining why it's separate from--c-canvas(i.e. the header needs independent control across modes). This is the kind of non-obvious reasoning that gets lost when the next developer touches the file in 18 months.🧪 Sara Holt — QA Engineer
Questions & Observations
The WCAG failure is a regression gap: Issue 03 describes a contrast failure (
#6b7280on#1a1a1a= 3.2:1) that exists in the current code. This bug survived because we don't have an automated contrast check running in CI for the manual[data-theme='dark']path. The fix alone isn't enough — we need a test that would catch this regressing again.Two dark mode paths need separate test coverage: The system preference path (
@media prefers-color-scheme: dark) and the manual toggle path ([data-theme='dark']) are independently overriding the same tokens. Both must be tested. Playwright'spage.emulateMedia({ colorScheme: 'dark' })covers the first; a DOM attribute mutation (page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))) covers the second. Are both in scope for this ticket?axe-playwright for contrast:
checkA11yfromaxe-playwrightwill catch the3.2:1contrast failure in dark mode — but only if the test actually renders the page in dark mode. If our current E2E suite only runs in light mode, the WCAG failure has always been invisible to it. This PR should add (or confirm the existence of) a dark-mode axe check on at least the document list/timeline page whereink-3is used heavily.Visual regression baseline update: After this change, all existing Playwright visual regression screenshots taken in dark mode will diff. This is expected — but the baseline needs to be deliberately reset, not left as an unexplained failure. Is there a clear process for reviewers to approve intentional visual changes?
Missing acceptance criteria:
axe-playwrightpasses in[data-theme='dark']mode on the timeline/correspondence pageaxe-playwrightpasses inprefers-color-scheme: darkmedia emulation modeSuggestions
data-theme='dark'and runscheckA11yon/and/korrespondenz. This directly encodes the regression from Issue 03 as a permanent test.🔐 Nora "NullX" Steiner — Security Engineer
Observations
This is a pure CSS token change with no server-side logic, no authentication surface, and no data handling changes. From a security standpoint, the attack surface delta is essentially zero. That said, one adjacent concern worth a quick check:
Theme toggle persistence mechanism: The
data-themeattribute on<html>implies a theme toggle exists somewhere in the codebase. If the selected theme is persisted tolocalStorageand read back on page load, confirm that the read path doesn't unsafely evaluate the stored value. E.g.:The second form is safe in this specific context (it only sets an attribute, not innerHTML), but it's still a smell worth confirming — especially if
data-themeis ever read server-side or used for anything beyond CSS scoping.CSP implications: If there's a Content-Security-Policy
style-srcdirective in place, CSS custom property changes are inert and safe. No action needed — just flagging for completeness.Summary
No security blockers for this change. The theme persistence pattern is a minor hygiene item to verify during implementation, not a blocker.
🎨 Leonie Voss — UI/UX Design Lead
Questions & Observations
The spec is well-structured and the navy-tinted direction is exactly right — dark mode should feel like an extension of the brand identity, not a generic code editor. A few things to verify before and during implementation:
Display calibration risk for near-black values:
#010e1eis very dark. On uncalibrated or low-gamma displays (common cheap monitors, some laptop panels), it may render indistinguishably from pure black. The spec's before/after mockup uses scaled CSS that can't fully predict this. Recommend verifying the canvas on at least one uncalibrated display before shipping. If the navy tint reads as flat black, the brand benefit is lost.Focus ring colors in dark mode: The issue doesn't mention focus ring styling. If focus rings are currently derived from a hardcoded brand-navy color (e.g.
outline-color: #012851or similar), they'll be near-invisible on the new#010e1ecanvas. WCAG 2.2 Success Criterion 2.4.11 (Focus Appearance) requires focus indicators to have at least 3:1 contrast against adjacent colors.#012851on#010e1e= approximately 1.2:1 — that fails. Consider adding--c-focus-ringto the token set, using#a1dcd8(brand-mint) in dark mode for clear visibility.The 4px accent strip at the top of the header: The spec mockup shows
background:#a1dcd8on both before and after — unchanged. On the new dark canvas this strip becomes more prominent relative to the header. That's likely fine, but worth verifying visually that it doesn't read as a loading bar or artifact.Document scan images in dark mode: When users view scanned document images (likely off-white or yellowish paper), those images will be displayed against the new
#010e1ecanvas. This should actually look better than against neutral black — the warm sand-white of old paper against navy is closer to a reading lamp context. No concern, just an observation worth checking.Scrollbar styling: Most browsers render scrollbars with OS-native colors in dark mode if there's a
color-scheme: darkdeclaration. Iscolor-schemebeing set on:rootin the dark mode blocks? If not, users on Windows may see a light scrollbar on a dark page.Suggestions
--c-focus-ringto the token set now, even if the current value isn't changing — it signals to future implementors that focus ring color is a theme-aware concern.border-bottom: 2px solid #a1dcd8), and active nav state look intentional and not too heavy on the new darker canvas.🛠️ Tobias Wendt — DevOps & Platform Engineer
Observations
This is a pure frontend CSS change — no Docker config, no environment variables, no infrastructure touch. Zero deployment risk on its own. A few pipeline-level notes:
Visual regression baseline: If the CI pipeline runs Playwright visual regression tests in dark mode, this change will produce intentional diffs. These should not auto-fail the quality gate — they need a deliberate baseline update step. Is there a documented process for resetting Playwright screenshot baselines, or does the CI currently not run dark-mode visual snapshots at all? Worth knowing before the PR lands.
Build output: CSS custom property changes are processed at build time by Vite/Tailwind. No runtime performance impact. The
@theme inlineaddition is a build-time concern — confirmnpm run buildandnpm run checkpass cleanly as part of the PR validation.No environment-specific behavior: Dark mode token values are hardcoded in CSS, not driven by environment variables or config. This is correct — visual tokens should not be environment-specific. No
.envchanges needed.npm run lint/prettier: A CSS file change should pass lint with no issues, but worth confirming the CSS formatter doesn't reformat the dark mode blocks in a way that obscures the intentional structural comment about keeping both blocks in sync.Summary
No DevOps blockers. The change is self-contained and has no infrastructure surface. Main pipeline concern is the Playwright visual baseline update process — sort that out before the PR to avoid a confusing CI failure that looks like a regression but isn't.
🎨 Leonie Voss — UI/UX Design Lead · Discussion Summary
Worked through five open items from my earlier review. Outcomes below.
Resolved — fold into this issue
1. Focus rings (deferred)
Current implementation uses browser defaults for most elements; the header has a branded
focus-visible:ring-accent(--c-accent: #00c7b1). This is acceptable for now. A separate issue will cover branded focus ring tokens for both light and dark mode. Out of scope for #166.2.
color-scheme: darkmissingNeither dark mode block (media query nor manual override) sets
color-scheme: dark. This needs to be added to both blocks — without it, Windows/Chromium users get a light native scrollbar on the dark navy page.color-scheme: dark;to@media (prefers-color-scheme: dark) :root:not([data-theme='light'])and:root[data-theme='dark']3. PDF viewer tokens (
--c-pdf-*) — update in this ticketCurrent values are neutral dark grays, same problem as the main canvas/surface tokens. Since scanned PDFs are raster images and can't be tinted, the treatment is: navy-tint the viewer chrome, leave the page content alone. Proposed values:
--c-pdf-bg#1e1e1e#010e1e— matches canvas, page floats on dark navy--c-pdf-ctrl#2a2a2a#011526— matches surface, consistent control bar--c-pdf-text#d1d1d1#f0efe9— matches ink, 17.8:1 on ctrl background4. Accent strip — no action
The 4px mint strip is purely decorative. Increased visibility on the new lighter header is acceptable and intentional.
5. Display calibration sign-off
#010e1eis very close to pure black. Add to acceptance criteria: verify navy tint is perceptible on at least one physical device beyond the development machine before closing the ticket.Additional acceptance criteria to add
color-scheme: darkset in both dark mode CSS blocks--c-pdf-bg,--c-pdf-ctrl,--c-pdf-textupdated to navy palette valuesOverall this is a well-scoped and well-justified change. The token table is complete, the WCAG rationale is solid, and the "candlelit reading room" framing is exactly right for an academic archive. The additions above are small but fill real gaps.
✅ Felix Brandt — Implementation Complete
Branch:
feat/issue-166-dark-mode-navy-paletteWhat was implemented
4 commits, all TDD (red → green → commit):
56926efcolor-schemeproperty tests7e43bd4color-scheme: dark, PDF tokens,--c-header938a4b0--c-headerbackground in dark modea9b6484bg-header;--c-headercorrected to#012851Acceptance criteria
--c-ink-3is#8b97a5in both blocks —#6b7280WCAG AA failure fixed--c-headertoken added; header usesbg-headerinstead ofbg-surfacecolor-scheme: darkadded to both dark mode blocks (native OS scrollbar fix)--c-pdf-bg,--c-pdf-ctrl,--c-pdf-text)theme.spec.tssuite passes (9/9)npm run buildclean)One deviation from the spec
The issue proposed
--c-header: #01335ein dark mode and#012851in light mode. After implementation, axe reported#8b97a5(ink-3, used inUserMenuandLanguageSwitcher) on#01335e= 4.3:1 — just below the 4.5:1 WCAG AA threshold.Resolution:
--c-headerset to#012851(brand navy) in dark mode — gives 4.99:1 with ink-3 (WCAG AA ✓). In light mode--c-header: #ffffff(the header was alreadybg-surface = white, notbg-brand-navyas the spec assumed).Bonus fix caught by new tests
The EntityNav sidebar had pre-existing WCAG AA failures (
text-white/30andtext-white/20onbg-brand-navy). The new dark-mode axe tests caught them. Fixed in the same commit:text-white/30 → /50andtext-white/20 → /50(now 4.8:1 ✓).Test results
mainviagit stash. Not caused by this change, out of scope for #166.