feat(dark-mode): replace neutral-black tokens with navy-tinted palette + fix WCAG AA failure #166

Closed
opened 2026-03-31 09:47:34 +02:00 by marcel · 8 comments
Owner

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: #0d0d0d is 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, #252525 are 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.

#6b7280 on #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 #0d0d0d canvas = only 2.1:1 — the header blends into the page.

Issue 05 · High — Timeline row boundaries nearly invisible

bg-surface (#1a1a1a) on bg-canvas (#0d0d0d) = only ~10 lightness points delta. Rows merge into a single dark mass.

Issue 06 · Medium — Borders are neutral gray

--c-line: #3d3d3d and --c-line-2: #2e2e2e are neutral grays with no brand identity.


Solution

Replace neutral dark tokens with navy-tinted values derived from brand-navy (#012851):

Token Current Proposed Status
--c-canvas #0d0d0d #010e1e Change
--c-surface #1a1a1a #011526 Change
--c-overlay #242424 #011e38 Change
--c-muted #252525 #011a30 Change
--c-line #3d3d3d #0d3358 Change
--c-line-2 #2e2e2e #092843 Change
--c-ink-3 #6b7280 ⚠️ #8b97a5 Fix (WCAG AA)
--c-header (not set) #01335e Add
--c-ink #f0efe9 unchanged Keep
--c-ink-2 #9ca3af unchanged Keep
--c-primary #a1dcd8 unchanged Keep

All proposed values pass WCAG AAA (7:1+). --c-ink-3 #8b97a5 on #011526 = 7.1:1 AAA ✓.

Header token strategy

Add --c-header CSS variable: #012851 in light mode, #01335e in dark mode. In +layout.svelte, replace bg-brand-navy on the <header> element with bg-header. No Svelte conditionals needed — pure CSS.


Implementation

File: frontend/src/app.css (or layout.css)

Changes apply to both @media (prefers-color-scheme: dark) :root:not([data-theme='light']) and :root[data-theme='dark']:

/* Light mode — add header token */
:root {
  --c-header: #012851;
}

/* @theme inline — add mapping */
@theme inline {
  --color-header: var(--c-header);
}

/* Dark mode — both blocks */
/* REMOVE */
--c-canvas: #0d0d0d;
--c-surface: #1a1a1a;
--c-overlay: #242424;
--c-muted: #252525;
--c-line: #3d3d3d;
--c-line-2: #2e2e2e;
--c-ink-3: #6b7280;  /* BUG: same as light mode */

/* ADD */
--c-canvas: #010e1e;
--c-surface: #011526;
--c-overlay: #011e38;
--c-muted: #011a30;
--c-line: #0d3358;
--c-line-2: #092843;
--c-ink-3: #8b97a5;  /* now consistent with @media block */
--c-header: #01335e; /* elevated header above dark canvas */

File: frontend/src/routes/+layout.svelte

  • Replace bg-brand-navybg-header on the <header> element

Acceptance Criteria

  • Navy-tinted dark canvas/surface/overlay/muted tokens applied in both dark mode blocks
  • --c-ink-3 is #8b97a5 in both @media and [data-theme='dark'] blocks (no more inconsistency)
  • --c-header token added; header uses bg-header instead of bg-brand-navy
  • All contrast ratios verified ≥ 4.5:1 for normal text in dark mode
  • Theme toggle still works (manual dark ↔ light ↔ system)
  • No visual regression in light mode

See docs/specs/dark-mode-redesign-spec.html for full mockups, contrast verification, and before/after comparison.

## 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: #0d0d0d` is 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`, `#252525` are 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. **`#6b7280` on `#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 `#0d0d0d` canvas = only **2.1:1** — the header blends into the page. ### Issue 05 · High — Timeline row boundaries nearly invisible `bg-surface` (`#1a1a1a`) on `bg-canvas` (`#0d0d0d`) = only ~10 lightness points delta. Rows merge into a single dark mass. ### Issue 06 · Medium — Borders are neutral gray `--c-line: #3d3d3d` and `--c-line-2: #2e2e2e` are neutral grays with no brand identity. --- ## Solution Replace neutral dark tokens with navy-tinted values derived from brand-navy (`#012851`): | Token | Current | Proposed | Status | |---|---|---|---| | `--c-canvas` | `#0d0d0d` | `#010e1e` | Change | | `--c-surface` | `#1a1a1a` | `#011526` | Change | | `--c-overlay` | `#242424` | `#011e38` | Change | | `--c-muted` | `#252525` | `#011a30` | Change | | `--c-line` | `#3d3d3d` | `#0d3358` | Change | | `--c-line-2` | `#2e2e2e` | `#092843` | Change | | `--c-ink-3` | `#6b7280` ⚠️ | `#8b97a5` | Fix (WCAG AA) | | `--c-header` | _(not set)_ | `#01335e` | Add | | `--c-ink` | `#f0efe9` | unchanged | Keep | | `--c-ink-2` | `#9ca3af` | unchanged | Keep | | `--c-primary` | `#a1dcd8` | unchanged | Keep | All proposed values pass WCAG AAA (7:1+). `--c-ink-3 #8b97a5` on `#011526` = **7.1:1 AAA ✓**. ### Header token strategy Add `--c-header` CSS variable: `#012851` in light mode, `#01335e` in dark mode. In `+layout.svelte`, replace `bg-brand-navy` on the `<header>` element with `bg-header`. No Svelte conditionals needed — pure CSS. --- ## Implementation **File: `frontend/src/app.css` (or `layout.css`)** Changes apply to **both** `@media (prefers-color-scheme: dark) :root:not([data-theme='light'])` and `:root[data-theme='dark']`: ```css /* Light mode — add header token */ :root { --c-header: #012851; } /* @theme inline — add mapping */ @theme inline { --color-header: var(--c-header); } /* Dark mode — both blocks */ /* REMOVE */ --c-canvas: #0d0d0d; --c-surface: #1a1a1a; --c-overlay: #242424; --c-muted: #252525; --c-line: #3d3d3d; --c-line-2: #2e2e2e; --c-ink-3: #6b7280; /* BUG: same as light mode */ /* ADD */ --c-canvas: #010e1e; --c-surface: #011526; --c-overlay: #011e38; --c-muted: #011a30; --c-line: #0d3358; --c-line-2: #092843; --c-ink-3: #8b97a5; /* now consistent with @media block */ --c-header: #01335e; /* elevated header above dark canvas */ ``` **File: `frontend/src/routes/+layout.svelte`** - Replace `bg-brand-navy` → `bg-header` on the `<header>` element --- ## Acceptance Criteria - [ ] Navy-tinted dark canvas/surface/overlay/muted tokens applied in both dark mode blocks - [ ] `--c-ink-3` is `#8b97a5` in both `@media` and `[data-theme='dark']` blocks (no more inconsistency) - [ ] `--c-header` token added; header uses `bg-header` instead of `bg-brand-navy` - [ ] All contrast ratios verified ≥ 4.5:1 for normal text in dark mode - [ ] Theme toggle still works (manual dark ↔ light ↔ system) - [ ] No visual regression in light mode See `docs/specs/dark-mode-redesign-spec.html` for full mockups, contrast verification, and before/after comparison.
marcel added the featureui labels 2026-03-31 09:47:40 +02:00
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Questions & Observations

  • File name ambiguity: The issue says frontend/src/app.css (or layout.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-navy usages: The issue scopes the change to the <header> in +layout.svelte, but bg-brand-navy may appear elsewhere in the app (buttons, badges, banners). Should those all switch to bg-header, or only the header element? The rename has different semantics: bg-brand-navy is "use the brand navy color"; bg-header is "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-header light mode default: The issue proposes adding --c-header: #012851 to :root. That's correct, but I'd verify whether :root in 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

  • Add an explicit search for all bg-brand-navy usages before implementing, so the scope of the <header> change is consciously bounded and nothing is accidentally left behind or accidentally changed.
  • The two CSS blocks (media query + data-theme) are intentionally identical after this change. A comment directly above both blocks noting "keep these in sync" would prevent future drift — this is a non-obvious constraint, which is exactly when a comment is warranted (per our style guide: only why, never what).
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Questions & Observations - **File name ambiguity**: The issue says `frontend/src/app.css` *(or `layout.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-navy` usages**: The issue scopes the change to the `<header>` in `+layout.svelte`, but `bg-brand-navy` may appear elsewhere in the app (buttons, badges, banners). Should those all switch to `bg-header`, or only the header element? The rename has different semantics: `bg-brand-navy` is "use the brand navy color"; `bg-header` is "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-header` light mode default**: The issue proposes adding `--c-header: #012851` to `:root`. That's correct, but I'd verify whether `:root` in 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 - Add an explicit search for all `bg-brand-navy` usages before implementing, so the scope of the `<header>` change is consciously bounded and nothing is accidentally left behind or accidentally changed. - The two CSS blocks (media query + data-theme) are intentionally identical after this change. A comment directly above both blocks noting "keep these in sync" would prevent future drift — this is a non-obvious constraint, which is exactly when a comment is warranted (per our style guide: *only why, never what*).
Author
Owner

🏗️ Markus Keller — Application Architect

Questions & Observations

  • Tailwind 4 @theme inline support: 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-header token 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-header should follow the same pattern. Is there already a --color-header Tailwind 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-header exists, 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

  • Confirm the actual CSS file path before implementation. The ambiguity (app.css vs layout.css) is architectural — getting it wrong means the override never fires for one of the dark mode paths.
  • Consider adding a short inline comment on the --c-header light-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.
## 🏗️ Markus Keller — Application Architect ### Questions & Observations - **Tailwind 4 `@theme inline` support**: 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-header` token 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-header` should follow the same pattern. Is there already a `--color-header` Tailwind 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-header` exists, 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 - Confirm the actual CSS file path before implementation. The ambiguity (`app.css` vs `layout.css`) is architectural — getting it wrong means the override never fires for one of the dark mode paths. - Consider adding a short inline comment on the `--c-header` light-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.
Author
Owner

🧪 Sara Holt — QA Engineer

Questions & Observations

  • The WCAG failure is a regression gap: Issue 03 describes a contrast failure (#6b7280 on #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's page.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: checkA11y from axe-playwright will catch the 3.2:1 contrast 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 where ink-3 is 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-playwright passes in [data-theme='dark'] mode on the timeline/correspondence page
    • axe-playwright passes in prefers-color-scheme: dark media emulation mode
    • Theme toggle transitions: switching light → dark → light produces no flash of incorrect token values

Suggestions

  • Add a Playwright test that explicitly sets data-theme='dark' and runs checkA11y on / and /korrespondenz. This directly encodes the regression from Issue 03 as a permanent test.
  • Tag new dark-mode visual snapshots with a comment in the test file so the next reviewer knows they were intentionally updated as part of this change.
## 🧪 Sara Holt — QA Engineer ### Questions & Observations - **The WCAG failure is a regression gap**: Issue 03 describes a contrast failure (`#6b7280` on `#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's `page.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**: `checkA11y` from `axe-playwright` will catch the `3.2:1` contrast 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 where `ink-3` is 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-playwright` passes in `[data-theme='dark']` mode on the timeline/correspondence page - [ ] `axe-playwright` passes in `prefers-color-scheme: dark` media emulation mode - [ ] Theme toggle transitions: switching light → dark → light produces no flash of incorrect token values ### Suggestions - Add a Playwright test that explicitly sets `data-theme='dark'` and runs `checkA11y` on `/` and `/korrespondenz`. This directly encodes the regression from Issue 03 as a permanent test. - Tag new dark-mode visual snapshots with a comment in the test file so the next reviewer knows they were intentionally updated as part of this change.
Author
Owner

🔐 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-theme attribute on <html> implies a theme toggle exists somewhere in the codebase. If the selected theme is persisted to localStorage and read back on page load, confirm that the read path doesn't unsafely evaluate the stored value. E.g.:

    // Fine — allowlist check before applying
    const theme = localStorage.getItem('theme');
    if (theme === 'dark' || theme === 'light') {
      document.documentElement.setAttribute('data-theme', theme);
    }
    
    // Smell — unchecked value applied directly
    document.documentElement.setAttribute('data-theme', localStorage.getItem('theme'));
    

    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-theme is ever read server-side or used for anything beyond CSS scoping.

  • CSP implications: If there's a Content-Security-Policy style-src directive 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.

## 🔐 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-theme` attribute on `<html>` implies a theme toggle exists somewhere in the codebase. If the selected theme is persisted to `localStorage` and read back on page load, confirm that the read path doesn't unsafely evaluate the stored value. E.g.: ```javascript // Fine — allowlist check before applying const theme = localStorage.getItem('theme'); if (theme === 'dark' || theme === 'light') { document.documentElement.setAttribute('data-theme', theme); } // Smell — unchecked value applied directly document.documentElement.setAttribute('data-theme', localStorage.getItem('theme')); ``` 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-theme` is ever read server-side or used for anything beyond CSS scoping. - **CSP implications**: If there's a Content-Security-Policy `style-src` directive 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.
Author
Owner

🎨 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: #010e1e is 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: #012851 or similar), they'll be near-invisible on the new #010e1e canvas. WCAG 2.2 Success Criterion 2.4.11 (Focus Appearance) requires focus indicators to have at least 3:1 contrast against adjacent colors. #012851 on #010e1e = approximately 1.2:1 — that fails. Consider adding --c-focus-ring to 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:#a1dcd8 on 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 #010e1e canvas. 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: dark declaration. Is color-scheme being set on :root in the dark mode blocks? If not, users on Windows may see a light scrollbar on a dark page.

Suggestions

  • Add --c-focus-ring to 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.
  • Explicitly verify the accent strip, header nav link underline (border-bottom: 2px solid #a1dcd8), and active nav state look intentional and not too heavy on the new darker canvas.
  • Note in the spec or acceptance criteria: verify on at least one uncalibrated display before closing the issue.
## 🎨 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**: `#010e1e` is *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: #012851` or similar), they'll be near-invisible on the new `#010e1e` canvas. WCAG 2.2 Success Criterion 2.4.11 (Focus Appearance) requires focus indicators to have at least 3:1 contrast against adjacent colors. `#012851` on `#010e1e` = approximately 1.2:1 — that fails. Consider adding `--c-focus-ring` to 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:#a1dcd8` on 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 `#010e1e` canvas. 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: dark` declaration. Is `color-scheme` being set on `:root` in the dark mode blocks? If not, users on Windows may see a light scrollbar on a dark page. ### Suggestions - Add `--c-focus-ring` to 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. - Explicitly verify the accent strip, header nav link underline (`border-bottom: 2px solid #a1dcd8`), and active nav state look intentional and not too heavy on the new darker canvas. - Note in the spec or acceptance criteria: verify on at least one uncalibrated display before closing the issue.
Author
Owner

🛠️ 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 inline addition is a build-time concern — confirm npm run build and npm run check pass 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 .env changes 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.

## 🛠️ 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 inline` addition is a build-time concern — confirm `npm run build` and `npm run check` pass 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 `.env` changes 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.
Author
Owner

🎨 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: dark missing
Neither 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.

  • Add color-scheme: dark; to @media (prefers-color-scheme: dark) :root:not([data-theme='light']) and :root[data-theme='dark']
  • Add as an explicit acceptance criterion

3. PDF viewer tokens (--c-pdf-*) — update in this ticket
Current 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:

Token Current Proposed
--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 background

4. 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
#010e1e is 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: dark set in both dark mode CSS blocks
  • --c-pdf-bg, --c-pdf-ctrl, --c-pdf-text updated to navy palette values
  • Navy tint verified as perceptible on a second physical display before closing

Overall 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.

## 🎨 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: dark` missing** Neither 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. - Add `color-scheme: dark;` to `@media (prefers-color-scheme: dark) :root:not([data-theme='light'])` and `:root[data-theme='dark']` - Add as an **explicit acceptance criterion** **3. PDF viewer tokens (`--c-pdf-*`) — update in this ticket** Current 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: | Token | Current | Proposed | |---|---|---| | `--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 background | **4. 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** `#010e1e` is 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: dark` set in both dark mode CSS blocks - [ ] `--c-pdf-bg`, `--c-pdf-ctrl`, `--c-pdf-text` updated to navy palette values - [ ] Navy tint verified as perceptible on a second physical display before closing --- Overall 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.
Author
Owner

Felix Brandt — Implementation Complete

Branch: feat/issue-166-dark-mode-navy-palette


What was implemented

4 commits, all TDD (red → green → commit):

Commit Type Description
56926ef test Failing axe dark-mode tests + failing color-scheme property tests
7e43bd4 feat Navy-tinted palette, WCAG AA fix, color-scheme: dark, PDF tokens, --c-header
938a4b0 test Failing test: header uses --c-header background in dark mode
a9b6484 fix Header switched to bg-header; --c-header corrected to #012851

Acceptance criteria

  • Navy-tinted canvas/surface/overlay/muted tokens in both dark mode CSS blocks
  • --c-ink-3 is #8b97a5 in both blocks — #6b7280 WCAG AA failure fixed
  • --c-header token added; header uses bg-header instead of bg-surface
  • color-scheme: dark added to both dark mode blocks (native OS scrollbar fix)
  • PDF viewer tokens updated to navy palette (--c-pdf-bg, --c-pdf-ctrl, --c-pdf-text)
  • Theme toggle still works — full theme.spec.ts suite passes (9/9)
  • No new type or build errors (npm run build clean)

One deviation from the spec

The issue proposed --c-header: #01335e in dark mode and #012851 in light mode. After implementation, axe reported #8b97a5 (ink-3, used in UserMenu and LanguageSwitcher) on #01335e = 4.3:1 — just below the 4.5:1 WCAG AA threshold.

Resolution: --c-header set 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 already bg-surface = white, not bg-brand-navy as the spec assumed).


Bonus fix caught by new tests

The EntityNav sidebar had pre-existing WCAG AA failures (text-white/30 and text-white/20 on bg-brand-navy). The new dark-mode axe tests caught them. Fixed in the same commit: text-white/30 → /50 and text-white/20 → /50 (now 4.8:1 ✓).


Test results

  • 15 pass (all new dark-mode axe + theme tests, all existing theme tests)
  • 4 pre-existing failures in the existing light-mode axe suite (home, persons, admin, login) — confirmed to pre-exist on main via git stash. Not caused by this change, out of scope for #166.
## ✅ Felix Brandt — Implementation Complete Branch: `feat/issue-166-dark-mode-navy-palette` --- ### What was implemented **4 commits, all TDD (red → green → commit):** | Commit | Type | Description | |---|---|---| | `56926ef` | test | Failing axe dark-mode tests + failing `color-scheme` property tests | | `7e43bd4` | feat | Navy-tinted palette, WCAG AA fix, `color-scheme: dark`, PDF tokens, `--c-header` | | `938a4b0` | test | Failing test: header uses `--c-header` background in dark mode | | `a9b6484` | fix | Header switched to `bg-header`; `--c-header` corrected to `#012851` | --- ### Acceptance criteria - ✅ Navy-tinted canvas/surface/overlay/muted tokens in both dark mode CSS blocks - ✅ `--c-ink-3` is `#8b97a5` in both blocks — `#6b7280` WCAG AA failure fixed - ✅ `--c-header` token added; header uses `bg-header` instead of `bg-surface` - ✅ `color-scheme: dark` added to both dark mode blocks (native OS scrollbar fix) - ✅ PDF viewer tokens updated to navy palette (`--c-pdf-bg`, `--c-pdf-ctrl`, `--c-pdf-text`) - ✅ Theme toggle still works — full `theme.spec.ts` suite passes (9/9) - ✅ No new type or build errors (`npm run build` clean) --- ### One deviation from the spec The issue proposed `--c-header: #01335e` in dark mode and `#012851` in light mode. After implementation, axe reported `#8b97a5` (ink-3, used in `UserMenu` and `LanguageSwitcher`) on `#01335e` = **4.3:1** — just below the 4.5:1 WCAG AA threshold. Resolution: `--c-header` set 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 already `bg-surface = white`, not `bg-brand-navy` as the spec assumed). --- ### Bonus fix caught by new tests The EntityNav sidebar had pre-existing WCAG AA failures (`text-white/30` and `text-white/20` on `bg-brand-navy`). The new dark-mode axe tests caught them. Fixed in the same commit: `text-white/30 → /50` and `text-white/20 → /50` (now 4.8:1 ✓). --- ### Test results - **15 pass** (all new dark-mode axe + theme tests, all existing theme tests) - **4 pre-existing failures** in the existing light-mode axe suite (home, persons, admin, login) — confirmed to pre-exist on `main` via `git stash`. Not caused by this change, out of scope for #166.
Sign in to join this conversation.
No Label feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#166