Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Fix dark mode leak: toasts, dialogs, and popovers render light because their portals escape the .dark wrapper #198

Description

@germanescobar

Summary

Toasts (Sonner), dialogs (DialogContent), and popovers (PopoverContent) render with a white/light background even though the app is dark-only and index.css defines proper dark theme tokens. The same root cause affects all three surfaces; fixing it once at the root resolves the class of bug for every current and future portal.

Root cause

The dark class is set on an inner <div> in client/src/App.tsx:565:

<div className="dark flex h-dvh w-full bg-background text-foreground">

…instead of on <html>. Meanwhile:

  • The Toaster is mounted in client/src/main.tsx:15, outside that wrapper, and Sonner portals to document.body.
  • DialogContent (client/src/components/ui/dialog.tsx:60) uses bg-popover and is rendered through DialogPortal, which also portals to document.body.
  • PopoverContent (client/src/components/ui/popover.tsx:33) has the same pattern: bg-popover inside PopoverPortal, escaping the .dark ancestor.

client/src/index.css:7 defines the dark variant as:

@custom-variant dark (&:is(.dark *));

That selector only matches elements inside a .dark ancestor. Portals mounted to document.body are siblings (not descendants) of the <div className="dark …">, so var(--popover), var(--popover-foreground), var(--background), etc. all resolve to the :root light values (oklch(1 0 0) for --popover, etc.) — which is exactly the white surface the user sees.

Note: the Toaster in client/src/components/ui/sonner.tsx:7 does hardcode theme="dark", but Sonner paints backgrounds via its --normal-bg CSS variable (wired to var(--popover)), which is still subject to the cascade problem above. So the theme="dark" prop is a no-op for the actual surface color.

Proposal

Move the dark class from the inner wrapper to <html> so the entire document (including portals mounted to document.body) resolves dark tokens. The app stays dark-only — no light-mode toggle is added in this issue.

Concrete changes:

  1. client/index.html — set <html class="dark"> so the dark theme applies before React mounts. This also avoids a flash of light content on initial load.
  2. client/src/main.tsx — if there's any class manipulation on mount, ensure it doesn't strip dark from <html>.
  3. client/src/App.tsx:565 — drop dark from the inner <div className="dark flex h-dvh w-full bg-background text-foreground">. Keep bg-background text-foreground (they're harmless and self-documenting).
  4. Verify the three reported surfaces pick up the dark tokens:
    • Trigger a Sonner toast (e.g. via an existing path) → background is dark.
    • Open ImportSkillsDialog → content + footer are dark.
    • Open any Popover (e.g. the skill picker's autocomplete chip in SessionView.tsx) → content is dark.
  5. Smoke check the rest of the app for any place that was previously getting dark only because of the inner .dark wrapper (e.g. computed bg-* classes that relied on the cascade). The wrapper's content area should look identical to before.

Why include popovers

The user reported toasts and dialogs, but PopoverContent uses the same bg-popover-in-a-body-portal pattern, so it almost certainly has the same bug even if not reported yet. Fixing only the two reported surfaces would leave the same trap active and would invite a duplicate bug report. The root-cause fix covers all three in one change.

Why not expand to "add light mode"

App.tsx:565 hardcodes dark; the app is dark-only by design. Adding a light-mode toggle is a separate, larger feature (theme persistence, system-pref detection, per-surface review, etc.). This issue keeps the app dark-only and just makes the existing dark theme reach every portal.

Out of scope

  • Adding a light-mode toggle or any system-preference detection.
  • Changing client/src/components/ui/sonner.tsx to depend on anything other than theme="dark" + var(--popover) (it's already correct for a dark-only app).
  • Auditing every bg-* / text-* token for contrast in dark mode. This issue fixes the leak; any contrast tuning is a follow-up.
  • Adding tests for the dark theme application itself. Visual verification is enough for a one-class move.

Acceptance criteria

  • <html> (or <body>) carries class="dark" in the rendered DOM. The inner <div> in App.tsx no longer carries dark.
  • A Sonner toast renders with a dark surface (var(--popover)oklch(0.18 0.005 250)), matching the rest of the app, and not white.
  • ImportSkillsDialog (and every other DialogContent) renders dark — content area, footer (bg-muted/50), close button, and form controls.
  • A PopoverContent (e.g. the skill autocomplete in the chat input) renders dark.
  • No light-flash on initial page load (because the dark class is present before React mounts).
  • No regressions: the rest of the app (sidebar, session list, terminal panel, settings) looks identical to before this change.

Notes

  • This is the kind of fix that should prevent the next person from filing "toasts are white again" after they add a new portal — <html class="dark"> is what shadcn, Radix, Base UI, and Sonner all expect.
  • The Toaster's theme="dark" prop remains useful as a semantic signal even after the fix; leave it in place.
  • The diagnosis can be re-verified by running getComputedStyle(document.body.querySelector('[data-sonner-toast]')).getPropertyValue('background-color') in DevTools before and after the change — before: rgb(255, 255, 255); after: a dark oklch value.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions