feat(gui): dark/light mode toggle - #162
Conversation
|
I have made updates to add a toggle button on the sidebar of the app to toggle between dark mode and light mode. |
|
Thanks for the contribution! The toggle mechanics, persistence, and accessibility are all solid. There are two things that need to land before this is mergeable, both in the dark-mode rendering path rather than the toggle logic. 1. Dark palette is incomplete (blocking)
To audit, grep tokens actually used against the ones you override: At minimum, the dark block needs overrides for:
Any other 2. FOUC on cold start (blocking)
<script>
(function () {
try {
var stored = localStorage.getItem('relay.appearance');
var theme = stored ? JSON.parse(stored).theme : 'system';
var effective = theme === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
document.documentElement.setAttribute('data-theme', effective);
} catch (_) {}
})();
</script>(Key/shape should match whatever Nice-to-have (non-blocking, can be a follow-up)The sidebar sun/moon glyph doesn't re-render when the OS theme flips in Happy to review again as soon as those two are addressed. Appreciate the PR! |
jcast90
left a comment
There was a problem hiding this comment.
Re-reviewing after the dark-block update. Toggle mechanics, persistence, and the radio group in Settings are all solid. The two blockers I called out in the earlier comment are still open, plus CI is red on this branch — pinning them inline now so they're easier to address line-by-line:
- Dark palette is incomplete —
tokens.cssline 127–148 below. - FOUC on cold start —
appearance.tsline 84 below. - CI red —
format-checkandts-verifyare both failing on this branch (run).pnpm format && pnpm typechecklocally should sort both. Please push the formatted version before the next review pass.
Nothing fundamental is wrong — happy to re-review as soon as those three land.
| --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.30); | ||
| --shadow-popover: 0 6px 18px rgba(0, 0, 0, 0.45); | ||
| --shadow-modal: 0 24px 80px rgba(0, 0, 0, 0.65); | ||
| --shadow-drawer: -16px 0 48px rgba(0, 0, 0, 0.40); |
There was a problem hiding this comment.
This block overrides paper-base/alt/line, the text family, accent-softs, and mention-bgs — but the GUI also references several other --color-* tokens that stay light-mode-tuned and will look wrong on a dark surface. To audit programmatically:
rg -o 'var\(--color-[a-z-]+' gui/src/styles.css gui/src/styles/mentions.css | sort -uThe palette gaps I'm fairly sure of:
--color-paper-hover(resting#d8cfb0, tan) — every hover state across rows, list items, and buttons inherits this. On the new#1a2232paper-base, the hover lands as a tan flash on near-black.--color-paper-pressed(resting#bdb088) — same story for pressed states.--color-mention-primary-fg/--color-mention-attached-fg/--color-mention-human-fg— you re-tinted the bgs to translucent washes (good) but left the fgs at their light-mode values (#e65a4fcoral,#3a5fa0slate-blue,#2a7a5amint-green). On the new 15%-alpha bgs sitting over the dark paper surface, those fg hues read very differently from their light-mode intent — especially#2a7a5aagainst the dark mint wash, which loses almost all contrast.--color-mention-attached-line(#b3c5e0) — light-blue line over the now-dark attached chip; the border becomes the loudest part of the chip.
Generally — anything --color-paper-*, --color-mention-*, and any --color-accent-*-soft that the grep above surfaces should get a dark-mode value. If a token is semantically light-only (e.g. a warm-paper accent that has no dark equivalent), prefer remapping it to a neutral in dark rather than leaving it.
FYI — there's an open PR #235 that has bumped some of the light-mode mention-fg tokens to darker WCAG-AA-compliant values (#a43c32, #1f6e54). When that merges, this dark block will need to additionally override those tokens so the darkened-for-light hues don't end up applied to a dark surface.
| }, []); | ||
|
|
||
| useEffect(() => { | ||
| applyTheme(state.theme); |
There was a problem hiding this comment.
applyTheme(state.theme) runs inside a useEffect, so it fires after React mounts and hydrates the bundle. Any user persisted on theme: "dark" (or theme: "system" on a dark-OS machine) will see a white flash on every cold start — the dark data-theme attribute isn't set until JS executes.
Standard fix is a tiny inline script in gui/index.html that runs before the bundle loads. The key/shape must match what write() persists here exactly — otherwise the inline reader and the React reader disagree and you flash twice:
<!-- in gui/index.html, before `<script type="module" src="/src/main.tsx"></script>` -->
<script>
(function () {
try {
var raw = localStorage.getItem('relay.appearance');
var theme = 'system';
if (raw) {
var parsed = JSON.parse(raw);
if (parsed.theme === 'dark' || parsed.theme === 'light') theme = parsed.theme;
}
var effective = theme === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
document.documentElement.setAttribute('data-theme', effective);
} catch (_) {}
})();
</script>The normalisation logic mirrors read() above so the two stay in sync. If you later add more validated fields to Appearance, this inline reader needs the same treatment — worth a brief comment here pointing at index.html as a sibling reader.
| title={isDark ? "Switch to light mode" : "Switch to dark mode"} | ||
| aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"} | ||
| > | ||
| {isDark ? "☀" : "☾"} |
There was a problem hiding this comment.
Nice — toggle is in the right spot and the title/aria-label pair reads correctly.
One stale-state bug worth fixing before merge: when theme === "system" and the OS theme flips, the matchMedia listener in appearance.ts updates document.documentElement's data-theme attribute, but the isDark value here is computed from resolveEffectiveTheme(appearance.theme) and never re-evaluates because appearance (state) hasn't changed. Result: the sidebar icon shows the stale glyph (☾ when the app is actually dark) until the user clicks something that triggers a re-render.
Cheapest fix is to have useAppearance's matchMedia listener bump a state value (a render counter, or just re-call setState with the same state) when system mode is active, so the consumer re-evaluates resolveEffectiveTheme. Alternative: expose effectiveTheme from useAppearance directly, computed via a useSyncExternalStore against matchMedia, so consumers don't have to know about the re-render dance.
|
Picking this up in a PR sweep. Good news first: this is not obsolete and it is worth finishing. Relay still has no theming at all — The three blockers, diagnosedThe merge conflict is one file, not four. I replayed the merge against current
Fix is a ~6-line stub in
The real work: the dark palette is incomplete, and it is not cosmeticThis is the part worth your attention.
Plus Rebase onto current main first#235 (contrast audit) and #244 (its hover-state follow-up) have both landed since you opened this. They remove Checklist
Happy to help with any of these — I just didn't want to push to your fork uninvited. Ping if you'd rather I take it over. |
Summary
Test plan
Size check
Diff size:
<N>lines added /<M>deleted.Breaking changes?
AI-assisted?