Theme Leaflet's zoom and attribution controls in dark mode #183

Merged
rob merged 3 commits from feature/task-246-leaflet-theming into main 2026-08-17 06:20:05 +00:00
Owner

Task 246. Leaflet's own controls (lib/leaflet/leaflet.css) hardcode a light palette with no awareness of prefers-color-scheme, so the zoom buttons and the attribution box stayed white regardless of scheme. Adds wwwroot/css/leaflet-theme.css, an override stylesheet bound to theme.css's existing tokens, loaded after both vendored stylesheets — the vendored files themselves are untouched.

Scope check, per control:

  • Zoom control (.leaflet-bar a) — themed: background/border/glyph now follow --colour-surface/--colour-control-border/--colour-text, with hover/focus and .leaflet-disabled covered too, plus the touch-only border variant (.leaflet-touch .leaflet-bar).
  • Attribution control — themed, and made opaque rather than the vendor's translucent white: legibility here is an OSM tile-usage-policy obligation, and a translucent fill can't guarantee contrast against tiles of unknown colour. Also found and fixed a second, latent bug: the attribution link's colour was never actually following a { color: var(--colour-accent) } in app.css at all, in either scheme — leaflet.css's own .leaflet-container a { color: #0078A8 } outranks that plain-tag rule on specificity regardless of stylesheet load order.
  • Cluster markers — already fine, no change needed. MarkerCluster.Default.css (the sheet with the default green/yellow/orange classes) was already deliberately not linked; map.js draws every cluster as an inline-SVG divIcon with its own explicit fill and a solid white stroke, so it's unaffected by either scheme already.
  • Popups/tooltips — not applicable; map.js never calls bindPopup/bindTooltip, so Leaflet never renders either.

Contrast, computed independently per scheme (all against --colour-surface):

  • Zoom glyph (--colour-text): 17.09:1 light, 13.70:1 dark.
  • Zoom divider (--colour-control-border): 3.30:1 light, 3.33:1 dark — reuses ADR-0087's own measured pairing rather than computing a second one.
  • Disabled zoom glyph (--colour-text-muted): 5.82:1 light, 6.97:1 dark — visibly dimmer than enabled without fading to illegible.
  • Attribution text (--colour-text-muted): 5.82:1 light, 6.97:1 dark.
  • Attribution link (--colour-accent): 5.18:1 light, 6.83:1 dark.

All exceed the 3:1 floor for non-text UI, and the attribution pairs exceed WCAG AA's 4.5:1 for text.

Verification: bUnit can't evaluate prefers-color-scheme, so I used Playwright's colour-scheme emulation. LeafletControlThemingTests (new, in the existing opt-in E2E suite) drives a real Chromium instance per scheme and asserts each control's computed style against a hidden probe element carrying the same custom property — this keeps discriminating the override if a token's value changes later, and if leaflet-theme.css's selectors ever lose the cascade to leaflet.css's own. The disabled-zoom-out state is forced deterministically via element.placeMarkMap.setZoom(getMinZoom()) and waited for through Expect(...).ToBeVisibleAsync()'s own polling, not a fixed delay, to avoid task 233's known intermittent.

I also independently verified the fix outside the app (no server, no ports) by loading the real vendored + override stylesheets in a static harness under Chromium with colour-scheme emulation, reading getComputedStyle back, and confirming it reverts to the vendor's hardcoded white/black when leaflet-theme.css is removed — the same defect Rob reported.

No ADR: this applies ADR-0122's and the theme layer's existing decisions rather than making a new one.

Task 246. Leaflet's own controls (`lib/leaflet/leaflet.css`) hardcode a light palette with no awareness of `prefers-color-scheme`, so the zoom buttons and the attribution box stayed white regardless of scheme. Adds `wwwroot/css/leaflet-theme.css`, an override stylesheet bound to `theme.css`'s existing tokens, loaded after both vendored stylesheets — the vendored files themselves are untouched. **Scope check, per control:** - **Zoom control** (`.leaflet-bar a`) — themed: background/border/glyph now follow `--colour-surface`/`--colour-control-border`/`--colour-text`, with hover/focus and `.leaflet-disabled` covered too, plus the touch-only border variant (`.leaflet-touch .leaflet-bar`). - **Attribution control** — themed, and made opaque rather than the vendor's translucent white: legibility here is an OSM tile-usage-policy obligation, and a translucent fill can't guarantee contrast against tiles of unknown colour. Also found and fixed a second, latent bug: the attribution link's colour was never actually following `a { color: var(--colour-accent) }` in `app.css` at all, in either scheme — leaflet.css's own `.leaflet-container a { color: #0078A8 }` outranks that plain-tag rule on specificity regardless of stylesheet load order. - **Cluster markers** — already fine, no change needed. `MarkerCluster.Default.css` (the sheet with the default green/yellow/orange classes) was already deliberately not linked; `map.js` draws every cluster as an inline-SVG `divIcon` with its own explicit fill and a solid white stroke, so it's unaffected by either scheme already. - **Popups/tooltips** — not applicable; `map.js` never calls `bindPopup`/`bindTooltip`, so Leaflet never renders either. **Contrast, computed independently per scheme** (all against `--colour-surface`): - Zoom glyph (`--colour-text`): 17.09:1 light, 13.70:1 dark. - Zoom divider (`--colour-control-border`): 3.30:1 light, 3.33:1 dark — reuses ADR-0087's own measured pairing rather than computing a second one. - Disabled zoom glyph (`--colour-text-muted`): 5.82:1 light, 6.97:1 dark — visibly dimmer than enabled without fading to illegible. - Attribution text (`--colour-text-muted`): 5.82:1 light, 6.97:1 dark. - Attribution link (`--colour-accent`): 5.18:1 light, 6.83:1 dark. All exceed the 3:1 floor for non-text UI, and the attribution pairs exceed WCAG AA's 4.5:1 for text. **Verification:** bUnit can't evaluate `prefers-color-scheme`, so I used Playwright's colour-scheme emulation. `LeafletControlThemingTests` (new, in the existing opt-in E2E suite) drives a real Chromium instance per scheme and asserts each control's *computed* style against a hidden probe element carrying the same custom property — this keeps discriminating the override if a token's value changes later, and if `leaflet-theme.css`'s selectors ever lose the cascade to `leaflet.css`'s own. The disabled-zoom-out state is forced deterministically via `element.placeMarkMap.setZoom(getMinZoom())` and waited for through `Expect(...).ToBeVisibleAsync()`'s own polling, not a fixed delay, to avoid task 233's known intermittent. I also independently verified the fix outside the app (no server, no ports) by loading the real vendored + override stylesheets in a static harness under Chromium with colour-scheme emulation, reading `getComputedStyle` back, and confirming it reverts to the vendor's hardcoded white/black when `leaflet-theme.css` is removed — the same defect Rob reported. No ADR: this applies ADR-0122's and the theme layer's existing decisions rather than making a new one.
Theme Leaflet's zoom and attribution controls in dark mode
Some checks failed
CI / build (pull_request) Successful in 3m20s
CI / container-images (pull_request) Successful in 3s
CI / e2e (pull_request) Failing after 2m51s
8df50171b0
Discard setZoom's chainable return value in the theming E2E test
All checks were successful
CI / build (pull_request) Successful in 2m58s
CI / container-images (pull_request) Successful in 4s
CI / e2e (pull_request) Successful in 5m26s
fd8529f55d
rob left a comment

Verdict: mergeable

Checked the specific risks called out for this one rather than taking the PR body at face value:

  • leaflet-theme.css is tokens-only — no hardcoded colour, no second prefers-color-scheme block. Satisfies theme.css's own rule.
  • The probe technique isn't vacuous. Vendor's hardcoded values (#fff/black on .leaflet-bar a, #f4f4f4/#bbb disabled, rgba(255,255,255,0.8)/#333 attribution) differ from the token values in every case except the light-scheme zoom-control background (#fff happens to equal --colour-surface there) — so unlinking the override would still redden every one of these tests via the color assertion. See inline note on the one case where that's the sole discriminator.
  • Attribution-link specificity diagnosis is right, not a red herring about load order: vendor's .leaflet-container a is (0,1,1); the new .leaflet-container .leaflet-control-attribution a is (0,2,1) — correctly scoped to the attribution box only, doesn't touch the popup close button or any other link under .leaflet-container.
  • setZoom() fix (fd8529f) is genuinely test-harness-only. None of map.js's own exported interop functions (setView, panIntoView, etc.) hand a chainable Leaflet return value back across the Blazor JS-interop boundary the way the expression-bodied Playwright EvaluateAsync call did — production code can't hit the same serialisation failure.
  • Spot-checked zoom glyph (17.09:1 / 13.70:1) and disabled glyph (5.82:1 / 6.97:1) independently via WCAG relative luminance from the raw hex values in theme.css — both match exactly. Disabled state stays visibly distinct from enabled in both schemes (5.82/6.97 vs 17.09/13.70), not just contrast-legal.
  • Cluster/popup/tooltip scope claims check out by grep of map.js: no bindPopup/bindTooltip calls anywhere, MarkerCluster.Default.css not linked in index.html, clusterIcon draws an inline SVG with explicit fill/stroke.
  • Load order, vendored files untouched, no AI attribution: all fine.

One loose end, not a blocker: PlaceMarkAppFixture's class remarks still say the E2E suite "runs on a schedule rather than per PR (ADR-0091)". ci.yml's e2e job actually triggers on pull_request/push to main, same as build — there's no schedule: trigger anywhere in the workflow. This PR touches that exact file (adding NewPageAsync(ColorScheme)), and the Vikunja comment notes the suite "turned out to run per-PR already", but the stale sentence itself wasn't corrected while the file was open. Worth a one-line follow-up.

Verdict: mergeable Checked the specific risks called out for this one rather than taking the PR body at face value: - `leaflet-theme.css` is tokens-only — no hardcoded colour, no second `prefers-color-scheme` block. Satisfies theme.css's own rule. - The probe technique isn't vacuous. Vendor's hardcoded values (`#fff`/`black` on `.leaflet-bar a`, `#f4f4f4`/`#bbb` disabled, `rgba(255,255,255,0.8)`/`#333` attribution) differ from the token values in every case *except* the light-scheme zoom-control background (`#fff` happens to equal `--colour-surface` there) — so unlinking the override would still redden every one of these tests via the `color` assertion. See inline note on the one case where that's the sole discriminator. - Attribution-link specificity diagnosis is right, not a red herring about load order: vendor's `.leaflet-container a` is (0,1,1); the new `.leaflet-container .leaflet-control-attribution a` is (0,2,1) — correctly scoped to the attribution box only, doesn't touch the popup close button or any other link under `.leaflet-container`. - `setZoom()` fix (fd8529f) is genuinely test-harness-only. None of `map.js`'s own exported interop functions (`setView`, `panIntoView`, etc.) hand a chainable Leaflet return value back across the Blazor JS-interop boundary the way the expression-bodied Playwright `EvaluateAsync` call did — production code can't hit the same serialisation failure. - Spot-checked zoom glyph (17.09:1 / 13.70:1) and disabled glyph (5.82:1 / 6.97:1) independently via WCAG relative luminance from the raw hex values in theme.css — both match exactly. Disabled state stays visibly distinct from enabled in both schemes (5.82/6.97 vs 17.09/13.70), not just contrast-legal. - Cluster/popup/tooltip scope claims check out by grep of `map.js`: no `bindPopup`/`bindTooltip` calls anywhere, `MarkerCluster.Default.css` not linked in `index.html`, `clusterIcon` draws an inline SVG with explicit `fill`/`stroke`. - Load order, vendored files untouched, no AI attribution: all fine. One loose end, not a blocker: `PlaceMarkAppFixture`'s class remarks still say the E2E suite "runs on a schedule rather than per PR (ADR-0091)". `ci.yml`'s `e2e` job actually triggers on `pull_request`/`push` to `main`, same as `build` — there's no `schedule:` trigger anywhere in the workflow. This PR touches that exact file (adding `NewPageAsync(ColorScheme)`), and the Vikunja comment notes the suite "turned out to run per-PR already", but the stale sentence itself wasn't corrected while the file was open. Worth a one-line follow-up.
@ -0,0 +21,4 @@
{
[E2EFact]
public async Task ZoomControl_LightScheme_UsesSurfaceAndTextTokens() =>
await AssertControlMatchesTokensAsync(ColorScheme.Light, ".leaflet-control-zoom-in", "--colour-surface", "--colour-text");
Author
Owner

Note for future maintenance: in light scheme, .leaflet-control-zoom-in's background alone doesn't discriminate the override here — leaflet.css's hardcoded #fff already equals --colour-surface's #ffffff, so this test currently only catches an unlinked/removed override through the color assertion below it. Not a bug, but if --colour-text ever converges with vendor's black too, this specific test (not the others) would stop discriminating without any change to the test itself.

Note for future maintenance: in light scheme, `.leaflet-control-zoom-in`'s background alone doesn't discriminate the override here — leaflet.css's hardcoded `#fff` already equals `--colour-surface`'s `#ffffff`, so this test currently only catches an unlinked/removed override through the `color` assertion below it. Not a bug, but if `--colour-text` ever converges with vendor's `black` too, this specific test (not the others) would stop discriminating without any change to the test itself.
Correct the stale schedule claim and strengthen the light-scheme zoom test
All checks were successful
CI / build (pull_request) Successful in 3m24s
CI / container-images (pull_request) Successful in 2s
CI / e2e (pull_request) Successful in 2m57s
f733a913e0
Author
Owner

Both addressed, pushed as f733a91:

  • PlaceMarkAppFixture's remarks corrected: the schedule-only claim was true of ADR-0091's original design but ADR-0092 partially superseded exactly that paragraph on Rob's own instruction, so the suite now gates every pull request via ci.yml's e2e job. Left the ADR-0091 citation on the Chromium-install sentence alone — that part genuinely still stands, per ADR-0092's own "Everything else here stands" note — and didn't touch either ADR's frozen body: both already carry the correct Partially superseded by/supersession metadata, so ADR-0107's Answered by mechanism doesn't apply here (that's for an explicit deferred question, not this kind of partial reversal).
  • Inline nit: agreed, and fixed rather than just noted. Added a border-bottom-color assertion (--colour-control-border) to the two zoom-control "enabled" tests, which gives the light-scheme case a second, independent discriminator (the vendor's #ccc divider never coincides with the token in either scheme) and adds coverage of the border override that nothing here checked before. Verified against the real vendored+override CSS outside the test run: light rgb(140,142,140) = #8c8e8c, dark rgb(102,113,126) = #66717e, both matching --colour-control-border exactly.

CI on f733a91 is green.

Both addressed, pushed as `f733a91`: - `PlaceMarkAppFixture`'s remarks corrected: the schedule-only claim was true of ADR-0091's original design but ADR-0092 partially superseded exactly that paragraph on Rob's own instruction, so the suite now gates every pull request via `ci.yml`'s `e2e` job. Left the ADR-0091 citation on the Chromium-install sentence alone — that part genuinely still stands, per ADR-0092's own "Everything else here stands" note — and didn't touch either ADR's frozen body: both already carry the correct `Partially superseded by`/supersession metadata, so ADR-0107's `Answered by` mechanism doesn't apply here (that's for an explicit deferred question, not this kind of partial reversal). - Inline nit: agreed, and fixed rather than just noted. Added a border-bottom-color assertion (`--colour-control-border`) to the two zoom-control "enabled" tests, which gives the light-scheme case a second, independent discriminator (the vendor's `#ccc` divider never coincides with the token in either scheme) and adds coverage of the border override that nothing here checked before. Verified against the real vendored+override CSS outside the test run: light `rgb(140,142,140)` = `#8c8e8c`, dark `rgb(102,113,126)` = `#66717e`, both matching `--colour-control-border` exactly. CI on `f733a91` is green.
rob left a comment

Verdict: mergeable

Re-reviewed at f733a91. Everything cleared at fd8529f5 still stands; narrowed to the two changes plus the process point.

  1. ADR-0092 citation checks out. Read both records directly: ADR-0092's own "Supersedes" line names ADR-0091's "Consequence for where the suite runs" paragraph specifically — the exact sentence the old stale comment quoted (...therefore runs on a schedule ... rather than through ci.yml) — and ADR-0091's header carries the matching "Partially superseded by: ADR-0092" note. Both already show correct supersession metadata (docs/adr/README.md's generated table agrees), and this PR touches neither frozen body. Not a substituted-but-wrong citation.

  2. The border assertion genuinely closes the gap. Vendor's .leaflet-bar a sets border-bottom: 1px solid #ccc; neither --colour-control-border value (#8c8e8c light, #66717e dark) coincides with #ccc in either scheme, so it's a real second discriminator, not the same coincidence twice. Correctly targets .leaflet-control-zoom-in (first-child), not -zoom-out, avoiding the vendor's :last-child { border-bottom: none } rule that would have muddied that check.

  3. The log-archive workaround doesn't matter here. Nothing I verified this round (ADR text, CSS, specificity, contrast) depended on a suite total from a log; matching by job status on the full head SHA is the right call given the documented quirk, and it's the same failure mode ADR-0092 itself already worked around.

  4. Flagging a genuine conflict, not a tooling glitch. The PR shows mergeable: false against current main. Cause: #185 rewrote PlaceMarkAppFixture's class-level <remarks> paragraph (new StubOidcProvider/OIDC sign-in content) on the same lines this PR's ADR-0092 correction touches — a real textual conflict. Needs a rebase/merge of main before the merge action will succeed. #185's other changes to that file (constants, _oidcProvider field, InitializeAsync) don't overlap anything this PR edits, so resolving it should be a manual splice of the two remarks edits, not a re-review of substance.

Verdict: mergeable Re-reviewed at `f733a91`. Everything cleared at `fd8529f5` still stands; narrowed to the two changes plus the process point. 1. **ADR-0092 citation checks out.** Read both records directly: ADR-0092's own "Supersedes" line names ADR-0091's *"Consequence for where the suite runs"* paragraph specifically — the exact sentence the old stale comment quoted (`...therefore runs on a schedule ... rather than through ci.yml`) — and ADR-0091's header carries the matching "Partially superseded by: ADR-0092" note. Both already show correct supersession metadata (`docs/adr/README.md`'s generated table agrees), and this PR touches neither frozen body. Not a substituted-but-wrong citation. 2. **The border assertion genuinely closes the gap.** Vendor's `.leaflet-bar a` sets `border-bottom: 1px solid #ccc`; neither `--colour-control-border` value (`#8c8e8c` light, `#66717e` dark) coincides with `#ccc` in either scheme, so it's a real second discriminator, not the same coincidence twice. Correctly targets `.leaflet-control-zoom-in` (first-child), not `-zoom-out`, avoiding the vendor's `:last-child { border-bottom: none }` rule that would have muddied that check. 3. **The log-archive workaround doesn't matter here.** Nothing I verified this round (ADR text, CSS, specificity, contrast) depended on a suite total from a log; matching by job status on the full head SHA is the right call given the documented quirk, and it's the same failure mode ADR-0092 itself already worked around. 4. **Flagging a genuine conflict, not a tooling glitch.** The PR shows `mergeable: false` against current `main`. Cause: #185 rewrote `PlaceMarkAppFixture`'s class-level `<remarks>` paragraph (new `StubOidcProvider`/OIDC sign-in content) on the same lines this PR's ADR-0092 correction touches — a real textual conflict. Needs a rebase/merge of `main` before the merge action will succeed. #185's other changes to that file (constants, `_oidcProvider` field, `InitializeAsync`) don't overlap anything this PR edits, so resolving it should be a manual splice of the two remarks edits, not a re-review of substance.
rob force-pushed feature/task-246-leaflet-theming from f733a913e0
All checks were successful
CI / build (pull_request) Successful in 3m24s
CI / container-images (pull_request) Successful in 2s
CI / e2e (pull_request) Successful in 2m57s
to 64cd25744b
All checks were successful
CI / build (pull_request) Successful in 3m35s
CI / container-images (pull_request) Successful in 3s
CI / e2e (pull_request) Successful in 3m42s
2026-08-17 06:10:30 +00:00
Compare
rob left a comment

Verdict: mergeable

Confirmed at 64cd257, rebase-only check:

  1. Splice reads correctly and unambiguously: "ADR-0091 originally accepted that on the strength of running this suite on a schedule rather than per PR; ADR-0092 partially superseded exactly that paragraph on Rob's own instruction, so the suite now runs on every pull request instead (ci.yml's own e2e job) — but the port-sharing constraint itself was never about the trigger, so it still stands unchanged." Past tense for ADR-0091's original position, present tense for what governs now — no reader ambiguity about which applies today.
  2. leaflet-theme.css and index.html are byte-identical to what I reviewed at f733a91/fd8529f5. LeafletControlThemingTests.cs carries the same 141 lines (border-colour assertion included). NewPageAsync(ColorScheme) sits untouched alongside hasTouch, clear of the OIDC wiring.
  3. Both earlier findings (ADR-0092 citation, border-assertion discriminator) remain addressed — nothing here reopens either.
Verdict: mergeable Confirmed at `64cd257`, rebase-only check: 1. Splice reads correctly and unambiguously: "ADR-0091 originally accepted that on the strength of running this suite on a schedule rather than per PR; ADR-0092 partially superseded exactly that paragraph on Rob's own instruction, so the suite now runs on every pull request instead (`ci.yml`'s own `e2e` job) — but the port-sharing constraint itself was never about the trigger, so it still stands unchanged." Past tense for ADR-0091's original position, present tense for what governs now — no reader ambiguity about which applies today. 2. `leaflet-theme.css` and `index.html` are byte-identical to what I reviewed at `f733a91`/`fd8529f5`. `LeafletControlThemingTests.cs` carries the same 141 lines (border-colour assertion included). `NewPageAsync(ColorScheme)` sits untouched alongside `hasTouch`, clear of the OIDC wiring. 3. Both earlier findings (ADR-0092 citation, border-assertion discriminator) remain addressed — nothing here reopens either.
rob merged commit 9d31c88517 into main 2026-08-17 06:20:05 +00:00
rob deleted branch feature/task-246-leaflet-theming 2026-08-17 06:20:05 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/PlaceMark!183
No description provided.