Fix invisible Today panel text and buttons in dark mode - #15
Open
Svyk wants to merge 9 commits into
Open
Conversation
Svyk
force-pushed
the
fix/today-panel-dark-theme
branch
from
August 5, 2026 17:22
ae02b79 to
ef3eb90
Compare
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
force-pushed
the
fix/today-panel-dark-theme
branch
from
August 5, 2026 17:24
ef3eb90 to
30823a4
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-titleusescolor: 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-colorstays at#202B33under.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:
disableAll()marks the action buttons disabled while a complete/snooze is in flight. Pinningcolorat author level beats the user-agentbutton:disabledgrey, so the first commit removed the only disabled feedback there was. They now get their own dimmer background/colour/border pluscursor: not-allowed— explicit values rather thanopacity, which would multiply against the already-alpha'd text and land back in a low-contrast state.:hoverrule for these buttons in any theme; dark now brightens background and border rather than leaving hover indistinguishable..bt-today-badge:hoverusedrgba(0,0,0,0.06), so it darkened against a dark panel, and.bt-today-badge__iconwas a fixed mid-tone. Both now have dark variants.:focus-visibleoutline is#2b6cb0, a dark navy against a dark panel. Overridden to#63b3edfor 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.getMatchedStylesForNodeon a live node:button, input, optgroup, select, textareainherit.bt-today-panel__row-titleinherit.bp3-dark buttonvar(--dark-bg)assets/css/less-compiled/site.cssRoam core paints every
<button>under.bp3-darkwith the theme's background colour..bt-today-panel__row-titleis a bare transparent<button>relying oncolor: 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: computedrgb(32,43,51)on argb(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
buttondirectly (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-darkalone. In current Roam buildsbp3-darksits on<html>, not<body>— a live DOM read showsbody.className === "rm-electron bt-theme-dark"— so everybody.bp3-dark …selector in this file matches nothing and only thebody.bt-theme-darkhalf is load-bearing. Hence the:is(html, body).bp3-darkform plus aprefers-color-schemefallback for auto mode, where.bp3-darkis absent entirely.Verified live on an encrypted graph, injecting the rule and reading computed styles:
rgb(32,43,51)rgba(247,249,251,0.92)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'srgba(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 insrc/index.js, which is beyond a CSS legibility fix.The
--due/--overduemodifier 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
.rm-block*rules stay scoped under.bt-today-panel-block/.bt-today-panel-inline-hiddenbp3-darkandbt-theme-darkfor enabled, hover, disabled and focus states — and, as of the third commit, for the row titles that the earlier verification missedNote on ordering
This branch is based directly on
mainand touches onlyextension.css, so it is independent of #14 and the two merge cleanly in either order (verified withgit 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),syncDashboardThemeVarsfell through toprefers-color-scheme, taggedbody.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 insrc/index.js: theme-toggle (light/dark) >bp3-darkmarker > measured luminance of Roam's rendered background > OSprefers-color-scheme(last-resort only, e.g. before Roam's chrome mounts). The decision is extracted as a pureresolvePanelIsDarkinsrc/core/theme-resolve.jswith unit tests covering the full matrix (258/258 pass). This makes the branch no longer CSS-only; the "touches onlyextension.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-mainand 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).