feat(ux): theming foundation + dark mode + visual polish#620
Conversation
UX-audit "visual consistency, responsive & theming" pass. Root-cause fixes at the primitive level (the audit noted ~4 edits fix most of the drift): - Card + Badge primitives → theme tokens. Card was `bg-white text-gray-900`, propagating a permanently-light surface to 50+ consumers; now `bg-card text-card-foreground border-border`. Badge keeps its semantic color families but gains dark: variants so status chips read on a dark surface. - New token-driven Alert primitive (info/warning/destructive) replaces the hand-rolled `border-*-200 bg-*-50` callouts (proposal error, chat secrets warning + stream error) — consistent padding/weight and dark-safe. - Dark mode is now REACHABLE and functional: new ThemeToggle (light/dark/ system) in the nav wires the already-configured next-themes. Verified live (Playwright): the nav flips white→near-black, cards/text/borders all theme correctly. - Top nav → tokens + mobile: `bg-background border-border`, active pill uses the primary token, and the item row is `overflow-x-auto` so the 9 items stay usable on narrow screens (was a fixed non-wrapping row). - empty-state, chat bubbles, metric-delta → tokens (+ tabular-nums on the metric delta for digit alignment; dark: green/red delta colors). - Chart a11y: the LLM-vs-UBI convergence overlay distinguished series by color alone — the UBI line is now dashed (deuteranopia-safe). Deferred to a tracked follow-up: the long-tail hardcoded link/callout color sweep (~49 `text-blue-600` links), full chart theme-color migration + axis labels/units, and a centralized date formatter. A human dark-mode QA pass across every surface is recommended before declaring dark mode fully done. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
…n mount guard Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
There was a problem hiding this comment.
Code Review
This pull request introduces a reusable Alert component, adds a ThemeToggle component for light/dark/system theme switching, and updates several components (such as Card, EmptyState, and TopNav) to support dark mode using Tailwind tokens. Feedback on these changes highlights a potential hydration mismatch in the chat detail page due to reading sessionStorage during initial render, a UX improvement for the theme toggle to prevent non-visual state transitions, and a bug in the metric delta calculation when handling negative baselines.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| {!warningDismissed && ( | ||
| <div | ||
| className="flex items-start justify-between gap-3 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900" | ||
| <Alert |
There was a problem hiding this comment.
The warningDismissed state is initialized by reading from sessionStorage directly during the initial render:
const [warningDismissed, setWarningDismissed] = useState(() => {
if (typeof window === 'undefined') return false;
return sessionStorage.getItem(SECRETS_WARNING_KEY) === '1';
});Because this component is rendered on the server (where typeof window === 'undefined' is true), warningDismissed will always initialize to false on the server. If a user has previously dismissed the warning, the client will initialize it to true on the initial render. This mismatch between the server-rendered HTML (which includes the Alert banner) and the client-rendered HTML (which does not) will cause a hydration mismatch error in Next.js.
Recommended Fix
Initialize the state to false and read from sessionStorage inside a useEffect hook after the component has mounted:
const [warningDismissed, setWarningDismissed] = useState(false);
useEffect(() => {
if (typeof window !== 'undefined') {
setWarningDismissed(sessionStorage.getItem(SECRETS_WARNING_KEY) === '1');
}
}, []);| const { theme, setTheme } = useTheme(); | ||
| const [mounted, setMounted] = useState(false); | ||
| // Canonical next-themes hydration guard: the resolved theme is only known | ||
| // client-side, so render a stable placeholder until mount. The synchronous | ||
| // set-on-mount is intentional (one-shot, empty deps). | ||
| // eslint-disable-next-line react-hooks/set-state-in-effect | ||
| useEffect(() => setMounted(true), []); | ||
|
|
||
| const order = ['light', 'dark', 'system'] as const; | ||
| const current = (theme ?? 'system') as (typeof order)[number]; | ||
| const next = order[(order.indexOf(current) + 1) % order.length]!; | ||
| const Icon = current === 'dark' ? Moon : current === 'light' ? Sun : Monitor; |
There was a problem hiding this comment.
Cycling blindly through ['light', 'dark', 'system'] can lead to a frustrating UX papercut where clicking the toggle results in no visual change. For example, if the user's system preference is light, the active theme is system (resolving to light). Clicking the toggle will switch the theme to light, which keeps the page light and makes the button feel broken on the first click.
To provide a seamless UX, we can toggle directly between light and dark based on the active resolvedTheme from next-themes. This guarantees a visual change on every click while still respecting the user's system preference initially.
| const { theme, setTheme } = useTheme(); | |
| const [mounted, setMounted] = useState(false); | |
| // Canonical next-themes hydration guard: the resolved theme is only known | |
| // client-side, so render a stable placeholder until mount. The synchronous | |
| // set-on-mount is intentional (one-shot, empty deps). | |
| // eslint-disable-next-line react-hooks/set-state-in-effect | |
| useEffect(() => setMounted(true), []); | |
| const order = ['light', 'dark', 'system'] as const; | |
| const current = (theme ?? 'system') as (typeof order)[number]; | |
| const next = order[(order.indexOf(current) + 1) % order.length]!; | |
| const Icon = current === 'dark' ? Moon : current === 'light' ? Sun : Monitor; | |
| const { theme, setTheme, resolvedTheme } = useTheme(); | |
| const [mounted, setMounted] = useState(false); | |
| // Canonical next-themes hydration guard: the resolved theme is only known | |
| // client-side, so render a stable placeholder until mount. The synchronous | |
| // set-on-mount is intentional (one-shot, empty deps). | |
| // eslint-disable-next-line react-hooks/set-state-in-effect | |
| useEffect(() => setMounted(true), []); | |
| const current = theme ?? 'system'; | |
| const next = resolvedTheme === 'dark' ? 'light' : 'dark'; | |
| const Icon = resolvedTheme === 'dark' ? Moon : Sun; |
| deltaPct > 0 | ||
| ? 'text-green-700 dark:text-green-400' | ||
| : deltaPct < 0 | ||
| ? 'text-red-700 dark:text-red-400' | ||
| : 'text-foreground'; |
There was a problem hiding this comment.
The deltaPct calculation (on line 32) does not handle negative baselines correctly:
const deltaPct = ((achieved - baseline) / baseline) * 100;If baseline is negative, a positive change (e.g., from -10 to -5) will result in a negative percentage, and a negative change (e.g., from -10 to -15) will result in a positive percentage. This will cause the wrong color class (text-green-700 vs text-red-700) to be applied here.
Recommended Fix
Update the deltaPct calculation to use Math.abs(baseline):
const deltaPct = ((achieved - baseline) / Math.abs(baseline)) * 100;- ThemeToggle: toggle off resolvedTheme (light↔dark) so every click is a visible change — a blind light→dark→system cycle no-ops on the first click when system already resolves to the landed theme. - chat secrets warning: initialize warningDismissed to false and read sessionStorage in a mount effect, avoiding the SSR/client hydration mismatch (server always rendered false; a client with the dismissed flag rendered true). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
Review adjudicationGemini — 2 findings, both accepted + fixed in
Only remaining red is the pre-existing |
…621) * docs(ux): capture deferred UX-audit polish as a tracked idea file Follow-up remainder of the 2026-07-11 UX audit whose high/medium findings shipped in PRs #616-#620: dark-mode long-tail color sweep + chart theming + human dark-mode QA, empty-state CTA parity, breadcrumb consistency, reverse "used by" links, wizard form-error association, and a centralized date formatter. All low-severity; split out to keep each fix PR reviewable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai> * docs(ux): clarify date vs number formatting in follow-up idea (Gemini review) Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai> --------- Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Fifth and final UX-audit fix PR. Scope: visual consistency, theming, and dark mode. The audit's key insight was that ~4 primitive edits fix most of the drift — this does those plus makes dark mode functional.
Changes
Cardwasbg-white text-gray-900(a permanently-light surface propagating to 50+ consumers); nowbg-card text-card-foreground border-border.Badgekeeps its semantic color families but gainsdark:variants so status chips read on a dark surface.empty-state, chat bubbles, andmetric-deltamigrated to tokens too.Alertprimitive (info/warning/destructive, token-driven + dark-safe) replaces the hand-rolledborder-*-200 bg-*-50callouts (proposal error, chat secrets warning, chat stream error) — consistent padding/weight, no longer light-only.ThemeToggle(light/dark/system) in the nav wires the already-configured next-themes. Verified live with Playwright: the nav flips white→near-black and cards/text/borders theme correctly (screenshot in the session).bg-background border-border, active pill uses the primary token, and the 9-item row isoverflow-x-autoso it stays usable on narrow screens (was a fixed non-wrapping row).metric-deltagainstabular-nums(digit alignment on the densest screens) +dark:green/red delta colors.Deferred (tracked follow-up — noted honestly)
The long-tail hardcoded-color sweep (~49
text-blue-600links + remainingbg-*-50boxes), full chart theme-color migration + axis labels/units, and a centralized date formatter. The primary surfaces are dark-correct (verified), but a human dark-mode QA pass across every surface is recommended before declaring dark mode 100% done — the token migration is correct-by-construction but the long-tail isn't swept yet.Testing
Alert+ThemeToggletests; the migration reuses the exact tokens the audit confirmed already work in dark mode on Input/Table/Select/Dialog, so existing tests (which assert substring/attribute) stay green. Full suite 1330;tsc+eslint+prettierclean;pnpm buildpasses; dark mode Playwright-verified.🤖 Generated with Claude Code