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:
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.
client/src/main.tsx — if there's any class manipulation on mount, ensure it doesn't strip dark from <html>.
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).
- 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.
- 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.
Summary
Toasts (Sonner), dialogs (
DialogContent), and popovers (PopoverContent) render with a white/light background even though the app is dark-only andindex.cssdefines 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
darkclass is set on an inner<div>inclient/src/App.tsx:565:…instead of on
<html>. Meanwhile:Toasteris mounted inclient/src/main.tsx:15, outside that wrapper, and Sonner portals todocument.body.DialogContent(client/src/components/ui/dialog.tsx:60) usesbg-popoverand is rendered throughDialogPortal, which also portals todocument.body.PopoverContent(client/src/components/ui/popover.tsx:33) has the same pattern:bg-popoverinsidePopoverPortal, escaping the.darkancestor.client/src/index.css:7defines the dark variant as:That selector only matches elements inside a
.darkancestor. Portals mounted todocument.bodyare siblings (not descendants) of the<div className="dark …">, sovar(--popover),var(--popover-foreground),var(--background), etc. all resolve to the:rootlight values (oklch(1 0 0)for--popover, etc.) — which is exactly the white surface the user sees.Note: the
Toasterinclient/src/components/ui/sonner.tsx:7does hardcodetheme="dark", but Sonner paints backgrounds via its--normal-bgCSS variable (wired tovar(--popover)), which is still subject to the cascade problem above. So thetheme="dark"prop is a no-op for the actual surface color.Proposal
Move the
darkclass from the inner wrapper to<html>so the entire document (including portals mounted todocument.body) resolves dark tokens. The app stays dark-only — no light-mode toggle is added in this issue.Concrete changes:
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.client/src/main.tsx— if there's any class manipulation on mount, ensure it doesn't stripdarkfrom<html>.client/src/App.tsx:565— dropdarkfrom the inner<div className="dark flex h-dvh w-full bg-background text-foreground">. Keepbg-background text-foreground(they're harmless and self-documenting).ImportSkillsDialog→ content + footer are dark.Popover(e.g. the skill picker's autocomplete chip inSessionView.tsx) → content is dark..darkwrapper (e.g. computedbg-*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
PopoverContentuses the samebg-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:565hardcodesdark; 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
client/src/components/ui/sonner.tsxto depend on anything other thantheme="dark"+var(--popover)(it's already correct for a dark-only app).bg-*/text-*token for contrast in dark mode. This issue fixes the leak; any contrast tuning is a follow-up.Acceptance criteria
<html>(or<body>) carriesclass="dark"in the rendered DOM. The inner<div>inApp.tsxno longer carriesdark.var(--popover)≈oklch(0.18 0.005 250)), matching the rest of the app, and not white.ImportSkillsDialog(and every otherDialogContent) renders dark — content area, footer (bg-muted/50), close button, and form controls.PopoverContent(e.g. the skill autocomplete in the chat input) renders dark.Notes
<html class="dark">is what shadcn, Radix, Base UI, and Sonner all expect.Toaster'stheme="dark"prop remains useful as a semantic signal even after the fix; leave it in place.getComputedStyle(document.body.querySelector('[data-sonner-toast]')).getPropertyValue('background-color')in DevTools before and after the change — before:rgb(255, 255, 255); after: a darkoklchvalue.