Skip to content

feat(gui): dark/light mode toggle - #162

Open
SHRIKEN117 wants to merge 1 commit into
jcast90:mainfrom
SHRIKEN117:feat/dark-mode-toggle
Open

feat(gui): dark/light mode toggle#162
SHRIKEN117 wants to merge 1 commit into
jcast90:mainfrom
SHRIKEN117:feat/dark-mode-toggle

Conversation

@SHRIKEN117

Copy link
Copy Markdown

Summary

Test plan

  • [ ]

Size check

Diff size: <N> lines added / <M> deleted.

Breaking changes?

AI-assisted?

@SHRIKEN117
SHRIKEN117 requested a review from jcast90 as a code owner April 23, 2026 21:42
@SHRIKEN117

Copy link
Copy Markdown
Author

I have made updates to add a toggle button on the sidebar of the app to toggle between dark mode and light mode.

@jcast90

jcast90 commented Apr 24, 2026

Copy link
Copy Markdown
Owner

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)

[data-theme=\"dark\"] in gui/src/styles/tokens.css overrides --color-paper-base/alt/line and a handful of text tokens, but not the hover/pressed/mention tokens that are referenced extensively across styles.css and styles/mentions.css. In dark mode you'll see tan hover states and light-blue mention outlines on a near-black surface.

To audit, grep tokens actually used against the ones you override:

rg -o 'var\(--color-[a-z-]+' gui/src/styles.css gui/src/styles/mentions.css | sort -u

At minimum, the dark block needs overrides for:

  • --color-paper-hover (currently #d8cfb0)
  • --color-paper-pressed (currently #bdb088)
  • --color-mention-primary-bg
  • --color-mention-primary-fg
  • --color-mention-attached-bg
  • --color-mention-attached-line (currently #b3c5e0)
  • --color-mention-attached-fg
  • --color-mention-human-fg

Any other --color-paper-* / --color-mention-* / --color-accent-*-soft tokens that the grep surfaces should get dark-mode values too. If a token is semantically light-only (e.g. a warm-paper accent), consider whether it should be remapped to a neutral in dark mode rather than kept.

2. FOUC on cold start (blocking)

applyTheme() in gui/src/lib/appearance.ts only runs inside a useEffect after React mounts, so a user on theme: \"dark\" (or system with a dark OS) sees a white flash on every cold start before the bundle hydrates. Standard fix is an inline script in gui/index.html that runs before the bundle loads:

<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 appearance.ts actually persists — double-check the storage key + JSON shape.)

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 theme: \"system\" mode — the matchMedia listener updates the DOM attribute but not React state, so the icon is stale until the next interaction. Small fix: have the listener also bump a state value so the component re-renders.

Happy to review again as soon as those two are addressed. Appreciate the PR!

@jcast90 jcast90 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Dark palette is incompletetokens.css line 127–148 below.
  2. FOUC on cold startappearance.ts line 84 below.
  3. CI redformat-check and ts-verify are both failing on this branch (run). pnpm format && pnpm typecheck locally 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.

Comment thread gui/src/styles/tokens.css
--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);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 -u

The 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 #1a2232 paper-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 (#e65a4f coral, #3a5fa0 slate-blue, #2a7a5a mint-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 #2a7a5a against 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.

Comment thread gui/src/lib/appearance.ts
}, []);

useEffect(() => {
applyTheme(state.theme);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ? "☀" : "☾"}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@jcast90

jcast90 commented Jul 11, 2026

Copy link
Copy Markdown
Owner

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 — tokens.css on main is a single light palette under :root { color-scheme: light }. The toggle mechanics, persistence, and a11y labels here are sound. The remaining work is ~1–2h and I want to be precise about what it is, because the CI signal is misleading.

The three blockers, diagnosed

The merge conflict is one file, not four. I replayed the merge against current main: tokens.css and Sidebar.tsx auto-merge cleanly. Only SettingsPage.tsx conflicts, because main's AppearanceSection moved since you branched. Trivial rebase.

ts-verify is not a typecheck failure — it is vitest, and the cause is not your logic. gui/src/components/Sidebar.test.tsx fails 4/4 because this PR makes Sidebar call useAppearance()resolveEffectiveTheme()window.matchMedia(...) at render time (gui/src/lib/appearance.ts:38). The GUI test env is jsdom, jsdom does not implement matchMedia, and gui/src/test-setup.ts has no polyfill — there are zero matchMedia references anywhere under gui/. Every Sidebar render throws. Sidebar is the only component you wire useAppearance() into that has a test, which is exactly why it is the only failing file.

Fix is a ~6-line stub in gui/src/test-setup.ts. Not your bug, but it lands on you.

format-check is Prettier on Sidebar.tsx only (the setAppearance({ ...appearance, theme: ... }) arrow wraps). pnpm format fixes it. Note CI now runs the lockfile-pinned Prettier (see #242), so pnpm format locally and CI will finally agree.

The real work: the dark palette is incomplete, and it is not cosmetic

This is the part worth your attention.

gui/src/styles.css:1577.composer-box { background: #fff; } is hardcoded, not a token. No [data-theme] override can reach it. The message composer stays pure white in dark mode, with dark-mode text-primary #e8ecf4 on it at 1.18:1 — effectively invisible. This one needs tokenizing before dark mode can ship at all.

tokens.css:127-148 — the dark block overrides chip backgrounds but not chip foregrounds, so every mention chip is dark-on-dark. Computed against your dark paper-base #1a2232, compositing the rgba fills:

chip ratio in dark mode
mention-repo-attached #3a5fa0 2.10:1
mention-human #2a7a5a 2.66:1
mention-repo-primary #e65a4f 3.81:1

Plus --color-paper-hover #d8cfb0 and --color-paper-pressed #bdb088 stay tan, so every hover flashes a beige block on a near-black surface (text-primary on it = 1.32:1). --color-mention-attached-line #b3c5e0 stays light-blue. Roughly ~15 more tokens need dark values.

Rebase onto current main first

#235 (contrast audit) and #244 (its hover-state follow-up) have both landed since you opened this. They remove text-dim from every text call site, add a prefers-contrast: more block, and add --color-text-muted-strong. Your dark block swaps --color-text-dim and --color-text-muted, so the two changes collide semantically — please re-derive the dark palette against current main rather than resolving textually.

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.

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.

3 participants