Skip to content

Fix invisible Today panel text and buttons in dark mode - #15

Open
Svyk wants to merge 9 commits into
mlava:mainfrom
Svyk:fix/today-panel-dark-theme
Open

Fix invisible Today panel text and buttons in dark mode#15
Svyk wants to merge 9 commits into
mlava:mainfrom
Svyk:fix/today-panel-dark-theme

Conversation

@Svyk

@Svyk Svyk commented Aug 5, 2026

Copy link
Copy Markdown

In dark mode the Today panel looks empty: task titles render dark-on-dark and the icon buttons show up as ghost outlines.

.bt-today-panel__row-title uses color: inherit, and the icon buttons read --bt-panel-text. Both can resolve to a light-theme value under Roam's dark theme, because --bp3-text-color stays at #202B33 under .bp3-dark. The panel pins literal light values instead of routing through the sampled variable, matching what the task-row dark rules above it already do.

This PR is CSS only — extension.css, no JavaScript, no build output.

Covered

The first commit fixes the reported symptom: panel root colour and the icon buttons' background/colour/border.

The second commit closes the states the first one left behind, all of which are part of the same panel:

  • Disabled buttons. disableAll() marks the action buttons disabled while a complete/snooze is in flight. Pinning color at author level beats the user-agent button:disabled grey, so the first commit removed the only disabled feedback there was. They now get their own dimmer background/colour/border plus cursor: not-allowed — explicit values rather than opacity, which would multiply against the already-alpha'd text and land back in a low-contrast state.
  • Hover. There was no :hover rule for these buttons in any theme; dark now brightens background and border rather than leaving hover indistinguishable.
  • Sidebar badge. .bt-today-badge:hover used rgba(0,0,0,0.06), so it darkened against a dark panel, and .bt-today-badge__icon was a fixed mid-tone. Both now have dark variants.
  • Focus ring. The shared :focus-visible outline is #2b6cb0, a dark navy against a dark panel. Overridden to #63b3ed for this panel's buttons only — roughly 6.9:1 against the panel surface, versus the 3:1 WCAG threshold for non-text. The shared rule also covers task-menu and bulk-action buttons, which are outside this PR's scope, so those are untouched.

Third commit — the row titles were still invisible

Correcting an inaccurate claim in the two commits above. They described the panel-root colour as something "the row titles, section headers and empty state all inherit". That is true for the section headers and the empty state, but not for the row titles, and the panel remained broken in exactly the way this PR set out to fix. The earlier live-verification covered the icon buttons' enabled/hover/disabled/focus states and did not check a row title.

Root cause, from CSS.getMatchedStylesForNode on a live node:

rule specificity colour origin
button, input, optgroup, select, textarea 0,0,1 inherit user agent
.bt-today-panel__row-title 0,1,0 inherit this extension
.bp3-dark button 0,1,1 var(--dark-bg) Roam core, assets/css/less-compiled/site.css

Roam core paints every <button> under .bp3-dark with the theme's background colour. .bt-today-panel__row-title is a bare transparent <button> relying on color: inherit, so Roam's 0,1,1 beats the extension's 0,1,0 and the title is drawn in the panel background colour. Measured on a live graph: computed rgb(32,43,51) on a rgb(32,43,51) background — contrast ratio 1.00.

The icon buttons were never affected because their dark rule is 0,2,0, which is why the panel looked fixed while the titles stayed unreadable.

The fix scopes the rule to the panel root and matches button directly (0,2,2), so any button added to this panel later is covered rather than silently disappearing; the disabled-state rule remains 0,3,0 and still wins.

It also does not rely on body.bp3-dark alone. In current Roam builds bp3-dark sits on <html>, not <body> — a live DOM read shows body.className === "rm-electron bt-theme-dark" — so every body.bp3-dark … selector in this file matches nothing and only the body.bt-theme-dark half is load-bearing. Hence the :is(html, body).bp3-dark form plus a prefers-color-scheme fallback for auto mode, where .bp3-dark is absent entirely.

Verified live on an encrypted graph, injecting the rule and reading computed styles:

colour contrast vs panel
before rgb(32,43,51) 1.00
after rgba(247,249,251,0.92) 13.68

Icon buttons unchanged at 13.68; no bt-* node in the panel measures below 3:1 afterwards. The probe element was removed and the DOM restored before/after the check.

Deliberately not changed

Blocked and completed rows carry opacity: 0.55 / 0.6, which composites the panel's rgba(247,249,251,0.92) text down to roughly 0.51–0.55 effective alpha. Measured against both plausible dark panel surfaces that is 4.65:1 and 4.86:1 — above the 4.5:1 AA threshold for normal text, so dimmed but not illegible. Changing it would mean touching the row-opacity logic in src/index.js, which is beyond a CSS legibility fix.

The --due / --overdue modifier classes the panel adds have no CSS behind them in any theme. That is a pre-existing gap, not a dark-mode one, so it is left alone here.

The dead body.bp3-dark … selectors elsewhere in this file are left in place: removing them is a wider cleanup than this fix, and they are inert rather than harmful.

Checks

  • strict i18n parity passed
  • 250/250 tests pass
  • no selector added here matches a native Roam container; the existing .rm-block* rules stay scoped under .bt-today-panel-block / .bt-today-panel-inline-hidden
  • computed styles verified live in Roam under both bp3-dark and bt-theme-dark for enabled, hover, disabled and focus states — and, as of the third commit, for the row titles that the earlier verification missed

Note on ordering

This branch is based directly on main and touches only extension.css, so it is independent of #14 and the two merge cleanly in either order (verified with git merge-tree).

Update (2026-08-06): light-mode regression fixed in this branch

Live use surfaced the inverse bug: on a dark-mode OS with Roam in its default light theme (no theme extension, no bp3-dark), syncDashboardThemeVars fell through to prefers-color-scheme, tagged body.bt-theme-dark, and the panel's pinned near-white dark-mode text painted onto the white page — washed-out, near-invisible rows in light mode.

The fourth commit (10c2241) fixes the resolution order in src/index.js: theme-toggle (light/dark) > bp3-dark marker > measured luminance of Roam's rendered background > OS prefers-color-scheme (last-resort only, e.g. before Roam's chrome mounts). The decision is extracted as a pure resolvePanelIsDark in src/core/theme-resolve.js with unit tests covering the full matrix (258/258 pass). This makes the branch no longer CSS-only; the "touches only extension.css" note above predates this commit, though it remains independent of #14 and still merges cleanly in either order.

A follow-up commit (5cc9589) hardens the luminance sampling: Roam paints the page background on <body> while .roam-main and everything between it and the article is transparent, so the selector-level sample returned null and the decision still fell through to the OS hint. The sampler now walks each candidate's ancestor chain until it finds a painted element (verified live against the desktop client, where <body> carries the colour).

@Svyk
Svyk force-pushed the fix/today-panel-dark-theme branch from ae02b79 to ef3eb90 Compare August 5, 2026 17:22
Svyk added 2 commits August 5, 2026 10:23
The Today panel styles inherit their colour from the host block and read
--bt-panel-text for the icon buttons. Under Roam dark mode both can resolve
to a light-theme value (Roam leaves --bp3-text-color at #202B33 under
.bp3-dark), so row titles rendered dark-on-dark and the icon buttons became
ghosts.

Add paired body.bp3-dark / body.bt-theme-dark rules for .bt-today-panel-root
and .bt-today-panel__icon-btn, using the same literal light values the
task-row dark rules already use instead of the sampled variable.
…mode

- .bt-today-panel__icon-btn:disabled now has an explicit dark-scoped rule.
  disableAll() (src/index.js:19320) toggles .disabled while an action is
  in flight, and 07adfa2's pinned dark `color` on the base class beats the
  button:disabled UA grey, so disabled buttons showed zero feedback.
- Added .bt-today-panel__icon-btn:hover and :focus-visible dark overrides
  (neither existed in any theme before).
- .bt-today-badge:hover no longer darkens against a dark panel, and
  .bt-today-badge__icon no longer uses its fixed light-mode hsl() color.

All new rules pair body.bp3-dark / body.bt-theme-dark per the file's
existing convention. No selector touches an unscoped native Roam
container.
@Svyk
Svyk force-pushed the fix/today-panel-dark-theme branch from ef3eb90 to 30823a4 Compare August 5, 2026 17:24
Svyk and others added 7 commits August 6, 2026 18:58
Roam core ships `.bp3-dark button { color: var(--dark-bg) }` in
assets/css/less-compiled/site.css at specificity 0,1,1. The Today panel's
row title is a bare transparent <button> relying on `color: inherit`
(0,1,0), so Roam wins and paints the task title with the panel BACKGROUND
colour. Measured on a live encrypted graph via CSS.getMatchedStylesForNode:
computed colour rgb(32,43,51) on a rgb(32,43,51) background — contrast
ratio 1.00, i.e. the title is invisible.

The icon buttons escaped this only because their dark rule is 0,2,0, which
is why the previous commits appeared to fix the panel while the titles
stayed unreadable.

Scope the rule to the panel root and match `button` directly (0,2,2) so
any button added later is covered too; the disabled-state rule above is
0,3,0 and still wins. `body.bp3-dark` alone is insufficient: in current
Roam builds bp3-dark sits on <html>, and in Roam's auto mode it is absent
entirely, hence the :is(html, body) form plus a prefers-color-scheme
fallback.

Verified live: contrast 1.00 -> 13.68 with the rule applied, icon buttons
unchanged, no other bt-* node below 3:1.
With no theme-extension toggle mounted and no bp3-dark marker,
syncDashboardThemeVars fell through to prefers-color-scheme. On a dark-mode
OS with Roam in its default light theme this tagged body.bt-theme-dark and
poisoned the sampled panel vars, so the Today panel's pinned near-white
dark-mode text painted onto the white page (the inverse of the bug this
branch fixes).

Resolve the theme from the measured background luminance of Roam's own
chrome before consulting the OS hint: toggle (light/dark) > bp3-dark >
sampled luminance > prefers-color-scheme. Extracted as a pure
resolvePanelIsDark in src/core with unit tests.
Roam paints the page background on <body>; .roam-main and every container
between it and the article are transparent, so the selector-level sample
returned null and the theme decision fell through to the OS hint — the
exact failure the previous commit meant to remove. Walk each candidate's
ancestors until a painted element is found (lands on body / .rm-electron's
child in the desktop client).
syncDashboardThemeVars() samples the host theme and writes six custom
properties, but it wrote them on document.documentElement while
extension.css declares the same six names on `body.bt-theme-dark`.
Custom properties resolve per element, so <body> and every panel node
under it took the stylesheet's value and the inline declaration on <html>
was only ever visible to <html> itself. Everything the function computes
in dark mode — the clamped #202B33 Blueprint surface, the stronger
Blueprint text/border/pill fallbacks, the adjustColor() shades — was
discarded. Write on document.body, which outranks that class rule.

Three things had to be corrected before those values were safe to
activate:

- --bt-pill-bg was read as an input and written as an output. It could
  therefore never resolve to anything but the value the stylesheet had
  just declared, and once the write target is <body> the dark value would
  be read back on the next light pass and pin dark pills onto a light
  panel. Take the pinned fallback directly, and raise the non-Blueprint
  dark one to the stylesheet's own 0.12 so pills do not get fainter.

- Roam keeps --bp3-text-color at #202B33 under .bp3-dark, so the text and
  border chains can hand back a light-theme colour while we are painting
  a dark panel. Shadowing hid that; winning would have painted
  dark-on-dark. Reject a dark sample in dark mode, the same clamp the
  panel surface already had.

- adjustColor() mixes toward white for a positive delta, so the border
  signs were inverted: a "strong" border was moving toward the panel
  colour, and on the default white panel --bt-border-strong resolved to
  #ffffff. That half is live today, because only the dark writes were
  being shadowed.

The theme MutationObserver filters on ["class", "data-theme"], so writing
style on <body> cannot re-enter this sync.
.bt-pill had no dark rule at all and .bt-chip's kept `background:
transparent`, so on a dark panel both read as faint 1px outlines. Worse,
both are <button>s whose base rules are 0,1,0, and Roam ships
`.bp3-dark button { color: var(--dark-bg) }` at 0,1,1 — the same
invisible-label trap already documented for the Today panel, which is why
pill text disappeared entirely rather than merely dimming.

Give the inactive states a real surface and an explicit colour at 0,2,1,
and borrow --bt-border-strong (now that it actually carries the sampled
value) with literal fallbacks for the pre-sample paint. .bt-pill--muted's
`opacity: .6` multiplied against already-alpha'd text, the trap the
disabled Today-panel buttons hit, so dim it with an explicit colour
instead.

Selectors add the `:is(html, body).bp3-dark` form used elsewhere in this
file: bp3-dark lives on <html> in this Roam build, so the plain
`body.bp3-dark` form never fires. Same 0,2,1 specificity.
Call graph for syncDashboardThemeVars (the six --bt-* custom-property
writer fixed onto <body> by e09b296):
  - observeThemeChanges() -> one synchronous call at boot, before the
    MutationObserver is even wired up.
  - themeObserver (class/data-theme mutations on body/documentElement/head)
    -> triggerThemeResync(180), a >=250ms-debounced call.
  - window.matchMedia("(prefers-color-scheme: dark)") change listener ->
    triggerThemeResync(0), same >=250ms floor.
  - the toggle's click handler -> sets btPendingRoamStudioTheme=true (which
    already forced a resync unconditionally) then triggerThemeResync(650).
  - roamStudioToggleObserver (Roam Studio toggle's own class attribute) ->
    triggerThemeResync(120).

All five paths funnel through the same "did anything change" guard, and
that guard was keyed ONLY on the sampled panel-surface colour
(baseSurfaceCandidate === lastThemeSample.surface). That sample is built
from candidates that don't necessarily move in lockstep with the resolved
light/dark mode -- Roam/Blueprint custom properties that may not be set,
and a background-color read that can report the same string on two ticks
in a row. Concretely: a theme-observer resync can land with finalIsDark
now true while the surface sample still matches the last-cached (light)
value, and the guard returned before ever reaching
classList.toggle("bt-theme-dark", ...) or the six body.style writes below
it -- so bt-theme-dark could read true on <body> from an earlier write (or
from a second, older extension instance also targeting the class) while
the six custom properties stayed pinned at their last-written light
values.

Extract the skip decision into shouldSkipThemeSync (src/core/theme-
resolve.js, unit tested) and require the resolved mode to also match the
cached one before skipping. Any tick where finalIsDark differs from
lastThemeSample.dark now always proceeds and rewrites, regardless of
whether the surface heuristic happened to look unchanged on that pass --
closing the class of bug rather than just the one observed instance of it.

Also defer observeThemeChanges' first (boot-time) sync call by one
requestAnimationFrame. That first call already runs before Roam has
necessarily applied its own theme class/background, so it can sample the
white boot flash and cache a false "light" surface + mode as
lastThemeSample -- shouldSkipThemeSync now corrects that on the next real
mode change regardless, but skipping the flash sample avoids depending on
that correction for the common case. The observer/listener wiring later in
the function is untouched and still registers synchronously, so a genuine
theme mutation during that one deferred frame is never missed.

Separately (not fixed here, cannot be fixed in code): live verification
found a second, older extension.js instance still executing in the same
Roam window -- it writes the same six properties to
document.documentElement instead of document.body (the pre-e09b296
behaviour), which is why a correct dark sample was observed on
documentElement.style at the same time body.style still read light. That
implies a second Developer Extension entry (most likely the old pinned
raw.githubusercontent.com commit install) alongside the
https://svyk.github.io/better-tasks/ one. Fix is installation hygiene:
remove all Better Tasks entries under Settings -> Roam Depot -> Developer
Extensions in both windows and re-add exactly the Pages URL once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Qqb2nQDToAsPiA5PpYQg9
The period selector at the top of the Analytics panel (7 days / 30 days /
90 days / All time) rendered with invisible labels on every inactive chip.
Same trap as the Today panel row titles (f24f248) and the filter chips
(5a20cb7): Roam core ships

    .bp3-dark button { color: var(--dark-bg); }

at 0,1,1, and both `.bt-analytics-period-btn` and its `--active` modifier
are 0,1,0, so Roam wins and paints the label with the page BACKGROUND
colour. `background: none` on the inactive chip resolves to the dark panel
behind it, so its label disappeared outright — measured contrast 1.00. The
active chip only looked fine by accident: Roam's dark ink happens to land
on the light-blue --bt-chart-bar fill, which is also why pinning the base
rule's `color: #fff` at a winning specificity would have REGRESSED it to
white-on-#60a5fa (2.6:1, below WCAG AA). It keeps dark ink instead.

Audited the rest of the panel while in here. The stat tiles, section
titles, bar chart, distribution rows, stats rows, lists, heatmap, and
empty/loading state all resolve through --bt-panel-text / --bt-muted /
--bt-chart-bar, which the dark token block and the six body-level custom
property writes (e09b296) already carry correctly — no change needed.
Two other gaps did turn up:

  - The header's close ✕ is a bare `.bp3-button`. Blueprint's own
    `.bp3-dark .bp3-button` (0,2,0) covers it only while Roam actually
    carries bp3-dark; when the panel resolves dark from a sampled
    background or the appearance toggle (bt-theme-dark, bp3-dark nowhere)
    the light-theme Blueprint ink renders on a dark panel.
  - The overlay backdrop stayed at the light-mode rgba(0,0,0,.25) while
    the series and activity overlays both step to .45 in dark.

Selectors use the `body.bt-theme-dark` + `:is(html, body).bp3-dark` pair
already established on this branch — bp3-dark lives on <html> in this Roam
build, so the plain `body.bp3-dark` form never fires — at 0,2,1 (0,3,1 for
the close button, which also has to clear Blueprint's 0,2,0). Hover is
scoped with `:not(--active)` so it is disjoint from the active rule rather
than out-specifying it, and source order between the two stops mattering.

tests/dark-button-specificity.test.mjs parses extension.css, computes
selector specificity, and asserts that every Better Tasks surface that
renders as a <button> has a dark-scoped colour rule which (a) outranks
`.bp3-dark button` and (b) is reachable when bp3-dark sits on <html>.
Against the pre-fix stylesheet 6 of its 16 assertions fail — exactly the
four analytics ones plus the close button and backdrop — while the
already-fixed chips, pills and Today panel pass as controls. This is the
third surface in this family, so the check is now a standing guard rather
than a one-off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Qqb2nQDToAsPiA5PpYQg9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant