Skip to content

Commit 1b72949

Browse files
sorenbsclaude
andauthored
fix: sync dark mode to the page background in full-page shells (#1549)
* fix: sync dark mode to the page background in full-page shells With dark mode enabled, the page around Studio (overscroll areas and the space behind Studio's rounded corners) stayed white because all Studio theming is scoped to the .ps root and never reached the document. The theme hook now syncs the resolved theme to the document root: it sets color-scheme and Studio's resolved --background color on <html> (marked with data-prisma-studio-theme) so the page canvas matches the active theme. Studio only claims the document when neither <html> nor <body> carries a host-authored background, so embedded hosts that style their own page are left untouched, and the document-level theme is removed again when Studio unmounts. The ppg demo shell drops its hardcoded light body background and uses light-dark() for its chrome so the demo follows the synced color-scheme. Fixes #1475 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: re-evaluate host ownership and refcount document theme claims Address review feedback on the document-level theme sync: - Host ownership is now re-evaluated on every sync instead of being sticky once the data-prisma-studio-theme marker exists. Studio's own inline values are ignored when judging "host-authored": they are temporarily swapped for the pre-claim originals before checking, and if the host authored a document background (or overwrote Studio's inline properties) after mount, Studio releases the claim and keeps the host's values. - Document theme claims are now owned by a module-level registry of mounted useTheme instances. The pre-claim inline <html> values (including any pre-existing host color-scheme) are snapshotted when the first owner claims the document and restored only when the last owner unmounts, so unmounting one of several Studio instances no longer clears the theme for the rest, and a host's inline color-scheme survives release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 099b7da commit 1b72949

7 files changed

Lines changed: 474 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@prisma/studio-core": patch
3+
---
4+
5+
Fix white page background around Studio in dark mode. When Studio runs in a full-page shell whose document has no host-authored background, the resolved theme now syncs to the document root (`color-scheme` plus Studio's background color), so overscroll areas and the space behind Studio's rounded corners match the active theme. Host pages that style their own `<html>`/`<body>` background are left untouched.

Architecture/ui-state.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,9 @@ Command-palette action registrations are the one allowed React-context exception
157157
- Explicit user-triggered theme changes SHOULD use `document.startViewTransition` when available, with direct synchronous updates as the fallback, so Studio does not flash partially updated theme tokens during appearance switches.
158158
- Explicit `light` or `dark` choices MUST remain stable even if the embedding host mutates `document.documentElement.classList`.
159159
- Legacy persisted rows that only contain `isDarkMode` MUST normalize into explicit `themeMode` values during load so existing installs keep their preference.
160+
- The resolved theme MUST also sync to the document root (`color-scheme` and Studio's `--background` color on `<html>`, marked with `data-prisma-studio-theme`) so full-page shells get matching overscroll and behind-corner backgrounds, but ONLY when neither `<html>` nor `<body>` carries a host-authored background. Embedded hosts that style their own document MUST be left untouched.
161+
- Host ownership MUST be re-evaluated on every document-theme sync, ignoring the inline values Studio applied itself: if the host authors a document background (or overwrites Studio's inline properties) after Studio mounted, Studio MUST release the document theme and keep the host's values.
162+
- The pre-claim inline `<html>` values (including any host `color-scheme`) MUST be snapshotted when Studio first claims the document and restored when the claim is released. With multiple mounted Studio instances, the document theme MUST only be released when the last instance unmounts.
160163

161164
## Why This Architecture Is Better
162165

FEATURES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,3 +356,4 @@ Theme values are applied across Studio roots and portal surfaces at runtime, and
356356
Theme root classes and variables are synchronized before paint, and supported browsers wrap explicit theme changes in a view transition, so switching appearance modes does not flash a partially updated mix of old and new tokens.
357357
Palette theme toggles stay interactive in browsers that expose the View Transition API, so `Match system theme` can be turned both on and off in place without closing the palette or getting stuck on the system setting.
358358
Shared buttons, inputs, filter pills, visualizer nodes, confirmation dialogs, staged-cell overlays, grid cells, compact pagination controls, and the Prisma navigation mark resolve readable dark-mode treatment from those theme tokens and assets, so toolbar controls, page pickers, inline filters, schema cards, prompts, staged edits, table values, and the Studio brand chrome stay visible on dark host surfaces.
359+
When Studio runs in a full-page shell whose document has no host-authored background, it also syncs the resolved theme to the document root (`color-scheme` plus Studio's background color), so overscroll areas and the space behind Studio's rounded corners match the active theme instead of staying white. Host pages that style their own `<html>`/`<body>` background are left untouched.

demo/ppg-dev/DemoShell.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,12 @@ function DemoFullscreenButton() {
5151
}}
5252
style={{
5353
alignItems: "center",
54-
background: "rgba(255, 255, 255, 0.86)",
55-
border: "1px solid rgba(15, 23, 42, 0.12)",
54+
background:
55+
"light-dark(rgba(255, 255, 255, 0.86), rgba(30, 41, 59, 0.86))",
56+
border:
57+
"1px solid light-dark(rgba(15, 23, 42, 0.12), rgba(148, 163, 184, 0.24))",
5658
borderRadius: "10px",
57-
color: "#0f172a",
59+
color: "light-dark(#0f172a, #e2e8f0)",
5860
cursor: "pointer",
5961
display: "inline-flex",
6062
height: "36px",
@@ -118,7 +120,7 @@ export function DemoApp(props: {
118120
<div
119121
style={{
120122
background:
121-
"linear-gradient(165deg, #f8fafc 0%, #e2e8f0 40%, #dbeafe 100%)",
123+
"linear-gradient(165deg, light-dark(#f8fafc, #0b1220) 0%, light-dark(#e2e8f0, #131c2e) 40%, light-dark(#dbeafe, #1a2440) 100%)",
122124
display: "grid",
123125
gridTemplateRows: "auto 1fr",
124126
height: "100vh",
@@ -129,9 +131,11 @@ export function DemoApp(props: {
129131
style={{
130132
alignItems: "center",
131133
backdropFilter: "blur(6px)",
132-
background: "rgba(255, 255, 255, 0.86)",
133-
borderBottom: "1px solid rgba(15, 23, 42, 0.1)",
134-
color: "#0f172a",
134+
background:
135+
"light-dark(rgba(255, 255, 255, 0.86), rgba(15, 23, 42, 0.86))",
136+
borderBottom:
137+
"1px solid light-dark(rgba(15, 23, 42, 0.1), rgba(148, 163, 184, 0.2))",
138+
color: "light-dark(#0f172a, #e2e8f0)",
135139
display: "flex",
136140
fontFamily:
137141
"ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif",

demo/ppg-dev/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -800,7 +800,7 @@ function getHtmlDocument(): string {
800800
<title>Studio + ppg demo${isProduction ? "" : " (direct tcp)"}</title>
801801
<link rel="stylesheet" href="/app.css${bustSuffix}" />
802802
</head>
803-
<body style="margin: 0; min-height: 100vh; background: #f3f4f6;">
803+
<body style="margin: 0; min-height: 100vh;">
804804
<div id="root" style="height: 100vh;"></div>${liveReloadScript}
805805
<script type="module" src="/app.js${bustSuffix}"></script>
806806
</body>

ui/hooks/use-theme.test.tsx

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
applyDarkModeClass,
77
applyThemeVariables,
88
clearThemeVariables,
9+
STUDIO_DOCUMENT_THEME_ATTRIBUTE,
910
useTheme,
1011
} from "./use-theme";
1112

@@ -55,8 +56,10 @@ function renderThemeHarness(args: {
5556

5657
afterEach(() => {
5758
document.body.innerHTML = "";
59+
document.body.removeAttribute("style");
5860
document.documentElement.className = "";
5961
document.documentElement.removeAttribute("style");
62+
document.documentElement.removeAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE);
6063
});
6164

6265
describe("theme helpers", () => {
@@ -214,4 +217,178 @@ describe("useTheme", () => {
214217
harness.cleanup();
215218
portalRoot.remove();
216219
});
220+
221+
it("syncs the resolved dark theme to the document root when the page background is unstyled", () => {
222+
const harness = renderThemeHarness({
223+
isDarkMode: true,
224+
});
225+
226+
expect(
227+
document.documentElement.getAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
228+
).toBe("dark");
229+
expect(document.documentElement.style.colorScheme).toBe("dark");
230+
231+
harness.cleanup();
232+
});
233+
234+
it("paints the document background with Studio's resolved background variable", () => {
235+
const harness = renderThemeHarness({
236+
customTheme: {
237+
dark: {
238+
"--background": "rgb(20, 20, 22)",
239+
},
240+
light: {
241+
"--background": "rgb(250, 250, 250)",
242+
},
243+
},
244+
isDarkMode: true,
245+
});
246+
247+
expect(document.documentElement.style.backgroundColor).toBe(
248+
"rgb(20, 20, 22)",
249+
);
250+
251+
harness.cleanup();
252+
});
253+
254+
it("keeps following theme changes after claiming the document", async () => {
255+
const container = createStudioRoot();
256+
const root = createRoot(container);
257+
258+
function Harness(props: { isDarkMode: boolean }) {
259+
useTheme(undefined, props.isDarkMode);
260+
return null;
261+
}
262+
263+
act(() => {
264+
root.render(<Harness isDarkMode />);
265+
});
266+
267+
expect(document.documentElement.style.colorScheme).toBe("dark");
268+
269+
act(() => {
270+
root.render(<Harness isDarkMode={false} />);
271+
});
272+
273+
await flush();
274+
275+
expect(
276+
document.documentElement.getAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
277+
).toBe("light");
278+
expect(document.documentElement.style.colorScheme).toBe("light");
279+
280+
act(() => {
281+
root.unmount();
282+
});
283+
container.remove();
284+
});
285+
286+
it("leaves the document untouched when the host authored a page background", () => {
287+
document.body.style.background = "#ffffff";
288+
289+
const harness = renderThemeHarness({
290+
isDarkMode: true,
291+
});
292+
293+
expect(
294+
document.documentElement.hasAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
295+
).toBe(false);
296+
expect(document.documentElement.style.colorScheme).toBe("");
297+
expect(document.documentElement.style.backgroundColor).toBe("");
298+
299+
harness.cleanup();
300+
});
301+
302+
it("releases the document when the host authors a background after mount", async () => {
303+
const harness = renderThemeHarness({
304+
isDarkMode: true,
305+
});
306+
307+
expect(
308+
document.documentElement.getAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
309+
).toBe("dark");
310+
311+
// The host starts styling its page after Studio mounted.
312+
document.body.style.background = "#ffffff";
313+
314+
// Trigger a re-sync through the body mutation observer.
315+
const mutationProbe = document.createElement("div");
316+
document.body.appendChild(mutationProbe);
317+
318+
await flush();
319+
320+
expect(
321+
document.documentElement.hasAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
322+
).toBe(false);
323+
expect(document.documentElement.style.colorScheme).toBe("");
324+
expect(document.documentElement.style.backgroundColor).toBe("");
325+
326+
harness.cleanup();
327+
mutationProbe.remove();
328+
});
329+
330+
it("keeps the document theme until the last Studio instance unmounts", () => {
331+
const firstHarness = renderThemeHarness({
332+
isDarkMode: true,
333+
});
334+
const secondHarness = renderThemeHarness({
335+
isDarkMode: true,
336+
});
337+
338+
expect(
339+
document.documentElement.getAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
340+
).toBe("dark");
341+
342+
firstHarness.cleanup();
343+
344+
expect(
345+
document.documentElement.getAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
346+
).toBe("dark");
347+
expect(document.documentElement.style.colorScheme).toBe("dark");
348+
349+
secondHarness.cleanup();
350+
351+
expect(
352+
document.documentElement.hasAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
353+
).toBe(false);
354+
expect(document.documentElement.style.colorScheme).toBe("");
355+
});
356+
357+
it("restores a pre-existing host inline color-scheme on release", () => {
358+
document.documentElement.style.colorScheme = "light";
359+
360+
const harness = renderThemeHarness({
361+
isDarkMode: true,
362+
});
363+
364+
expect(
365+
document.documentElement.getAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
366+
).toBe("dark");
367+
expect(document.documentElement.style.colorScheme).toBe("dark");
368+
369+
harness.cleanup();
370+
371+
expect(
372+
document.documentElement.hasAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
373+
).toBe(false);
374+
expect(document.documentElement.style.colorScheme).toBe("light");
375+
});
376+
377+
it("clears the document-level theme when Studio unmounts", () => {
378+
const harness = renderThemeHarness({
379+
isDarkMode: true,
380+
});
381+
382+
expect(
383+
document.documentElement.hasAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
384+
).toBe(true);
385+
386+
harness.cleanup();
387+
388+
expect(
389+
document.documentElement.hasAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
390+
).toBe(false);
391+
expect(document.documentElement.style.colorScheme).toBe("");
392+
expect(document.documentElement.style.backgroundColor).toBe("");
393+
});
217394
});

0 commit comments

Comments
 (0)