Skip to content

feat(ux): theming foundation + dark mode + visual polish#620

Merged
SoundMindsAI merged 3 commits into
mainfrom
feat_ux_theming_dark_mode
Jul 11, 2026
Merged

feat(ux): theming foundation + dark mode + visual polish#620
SoundMindsAI merged 3 commits into
mainfrom
feat_ux_theming_dark_mode

Conversation

@SoundMindsAI

Copy link
Copy Markdown
Owner

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

  • Root-cause primitive migrationCard was bg-white text-gray-900 (a permanently-light surface propagating 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. empty-state, chat bubbles, and metric-delta migrated to tokens too.
  • New Alert primitive (info/warning/destructive, token-driven + dark-safe) replaces the hand-rolled border-*-200 bg-*-50 callouts (proposal error, chat secrets warning, chat stream error) — consistent padding/weight, no longer light-only.
  • Dark mode is now reachable and functional — new 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).
  • Top nav → tokens + mobile: bg-background border-border, active pill uses the primary token, and the 9-item row is overflow-x-auto so it stays usable on narrow screens (was a fixed non-wrapping row).
  • metric-delta gains tabular-nums (digit alignment on the densest screens) + 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 (tracked follow-up — noted honestly)

The long-tail hardcoded-color sweep (~49 text-blue-600 links + remaining bg-*-50 boxes), 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 + ThemeToggle tests; 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 + prettier clean; pnpm build passes; dark mode Playwright-verified.

🤖 Generated with Claude Code

SoundMindsAI and others added 2 commits July 11, 2026 09:23
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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

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');
  }
}, []);

Comment on lines +20 to +31
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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.

Suggested change
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;

Comment on lines +35 to +39
deltaPct > 0
? 'text-green-700 dark:text-green-400'
: deltaPct < 0
? 'text-red-700 dark:text-red-400'
: 'text-foreground';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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>
@SoundMindsAI

Copy link
Copy Markdown
Owner Author

Review adjudication

Gemini — 2 findings, both accepted + fixed in dc57f3b5:

Finding Verdict Action
theme-toggle.tsx — blind light→dark→system cycle can no-op on the first click (system already resolves to the landed theme) Accept Toggle off resolvedTheme (light↔dark) so every click is a visible change.
chat/[id]/page.tsxwarningDismissed read sessionStorage in the initial state → SSR (false) vs client (true) hydration mismatch Accept Initialize to false; read sessionStorage in a mount effect. (Pre-existing latent bug adjacent to the Alert change; fixed inline.)

Only remaining red is the pre-existing backend (contract+integration) UBI test (#610). Dark mode was Playwright-verified before shipping the toggle.

@SoundMindsAI
SoundMindsAI merged commit 9e32ac8 into main Jul 11, 2026
17 of 20 checks passed
@SoundMindsAI
SoundMindsAI deleted the feat_ux_theming_dark_mode branch July 11, 2026 13:48
SoundMindsAI added a commit that referenced this pull request Jul 11, 2026
…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>
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