diff --git a/.changeset/dark-mode-page-background.md b/.changeset/dark-mode-page-background.md
new file mode 100644
index 00000000..bd02643b
--- /dev/null
+++ b/.changeset/dark-mode-page-background.md
@@ -0,0 +1,5 @@
+---
+"@prisma/studio-core": patch
+---
+
+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 ``/`
` background are left untouched.
diff --git a/Architecture/ui-state.md b/Architecture/ui-state.md
index d1234a26..660bfbbf 100644
--- a/Architecture/ui-state.md
+++ b/Architecture/ui-state.md
@@ -150,6 +150,9 @@ Command-palette action registrations are the one allowed React-context exception
- 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.
- Explicit `light` or `dark` choices MUST remain stable even if the embedding host mutates `document.documentElement.classList`.
- Legacy persisted rows that only contain `isDarkMode` MUST normalize into explicit `themeMode` values during load so existing installs keep their preference.
+- The resolved theme MUST also sync to the document root (`color-scheme` and Studio's `--background` color on ``, marked with `data-prisma-studio-theme`) so full-page shells get matching overscroll and behind-corner backgrounds, but ONLY when neither `` nor `` carries a host-authored background. Embedded hosts that style their own document MUST be left untouched.
+- 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.
+- The pre-claim inline `` 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.
## Why This Architecture Is Better
diff --git a/FEATURES.md b/FEATURES.md
index 2acaa70e..e7d6584b 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -332,3 +332,4 @@ Theme values are applied across Studio roots and portal surfaces at runtime, and
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.
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.
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.
+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 ``/`` background are left untouched.
diff --git a/demo/ppg-dev/DemoShell.tsx b/demo/ppg-dev/DemoShell.tsx
index e95a21f2..63bbd979 100644
--- a/demo/ppg-dev/DemoShell.tsx
+++ b/demo/ppg-dev/DemoShell.tsx
@@ -51,10 +51,12 @@ function DemoFullscreenButton() {
}}
style={{
alignItems: "center",
- background: "rgba(255, 255, 255, 0.86)",
- border: "1px solid rgba(15, 23, 42, 0.12)",
+ background:
+ "light-dark(rgba(255, 255, 255, 0.86), rgba(30, 41, 59, 0.86))",
+ border:
+ "1px solid light-dark(rgba(15, 23, 42, 0.12), rgba(148, 163, 184, 0.24))",
borderRadius: "10px",
- color: "#0f172a",
+ color: "light-dark(#0f172a, #e2e8f0)",
cursor: "pointer",
display: "inline-flex",
height: "36px",
@@ -118,7 +120,7 @@ export function DemoApp(props: {
Studio + ppg demo${isProduction ? "" : " (direct tcp)"}
-
+
${liveReloadScript}
diff --git a/ui/hooks/use-theme.test.tsx b/ui/hooks/use-theme.test.tsx
index e07e46dd..5b0cdc1f 100644
--- a/ui/hooks/use-theme.test.tsx
+++ b/ui/hooks/use-theme.test.tsx
@@ -6,6 +6,7 @@ import {
applyDarkModeClass,
applyThemeVariables,
clearThemeVariables,
+ STUDIO_DOCUMENT_THEME_ATTRIBUTE,
useTheme,
} from "./use-theme";
@@ -55,8 +56,10 @@ function renderThemeHarness(args: {
afterEach(() => {
document.body.innerHTML = "";
+ document.body.removeAttribute("style");
document.documentElement.className = "";
document.documentElement.removeAttribute("style");
+ document.documentElement.removeAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE);
});
describe("theme helpers", () => {
@@ -214,4 +217,178 @@ describe("useTheme", () => {
harness.cleanup();
portalRoot.remove();
});
+
+ it("syncs the resolved dark theme to the document root when the page background is unstyled", () => {
+ const harness = renderThemeHarness({
+ isDarkMode: true,
+ });
+
+ expect(
+ document.documentElement.getAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
+ ).toBe("dark");
+ expect(document.documentElement.style.colorScheme).toBe("dark");
+
+ harness.cleanup();
+ });
+
+ it("paints the document background with Studio's resolved background variable", () => {
+ const harness = renderThemeHarness({
+ customTheme: {
+ dark: {
+ "--background": "rgb(20, 20, 22)",
+ },
+ light: {
+ "--background": "rgb(250, 250, 250)",
+ },
+ },
+ isDarkMode: true,
+ });
+
+ expect(document.documentElement.style.backgroundColor).toBe(
+ "rgb(20, 20, 22)",
+ );
+
+ harness.cleanup();
+ });
+
+ it("keeps following theme changes after claiming the document", async () => {
+ const container = createStudioRoot();
+ const root = createRoot(container);
+
+ function Harness(props: { isDarkMode: boolean }) {
+ useTheme(undefined, props.isDarkMode);
+ return null;
+ }
+
+ act(() => {
+ root.render();
+ });
+
+ expect(document.documentElement.style.colorScheme).toBe("dark");
+
+ act(() => {
+ root.render();
+ });
+
+ await flush();
+
+ expect(
+ document.documentElement.getAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
+ ).toBe("light");
+ expect(document.documentElement.style.colorScheme).toBe("light");
+
+ act(() => {
+ root.unmount();
+ });
+ container.remove();
+ });
+
+ it("leaves the document untouched when the host authored a page background", () => {
+ document.body.style.background = "#ffffff";
+
+ const harness = renderThemeHarness({
+ isDarkMode: true,
+ });
+
+ expect(
+ document.documentElement.hasAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
+ ).toBe(false);
+ expect(document.documentElement.style.colorScheme).toBe("");
+ expect(document.documentElement.style.backgroundColor).toBe("");
+
+ harness.cleanup();
+ });
+
+ it("releases the document when the host authors a background after mount", async () => {
+ const harness = renderThemeHarness({
+ isDarkMode: true,
+ });
+
+ expect(
+ document.documentElement.getAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
+ ).toBe("dark");
+
+ // The host starts styling its page after Studio mounted.
+ document.body.style.background = "#ffffff";
+
+ // Trigger a re-sync through the body mutation observer.
+ const mutationProbe = document.createElement("div");
+ document.body.appendChild(mutationProbe);
+
+ await flush();
+
+ expect(
+ document.documentElement.hasAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
+ ).toBe(false);
+ expect(document.documentElement.style.colorScheme).toBe("");
+ expect(document.documentElement.style.backgroundColor).toBe("");
+
+ harness.cleanup();
+ mutationProbe.remove();
+ });
+
+ it("keeps the document theme until the last Studio instance unmounts", () => {
+ const firstHarness = renderThemeHarness({
+ isDarkMode: true,
+ });
+ const secondHarness = renderThemeHarness({
+ isDarkMode: true,
+ });
+
+ expect(
+ document.documentElement.getAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
+ ).toBe("dark");
+
+ firstHarness.cleanup();
+
+ expect(
+ document.documentElement.getAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
+ ).toBe("dark");
+ expect(document.documentElement.style.colorScheme).toBe("dark");
+
+ secondHarness.cleanup();
+
+ expect(
+ document.documentElement.hasAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
+ ).toBe(false);
+ expect(document.documentElement.style.colorScheme).toBe("");
+ });
+
+ it("restores a pre-existing host inline color-scheme on release", () => {
+ document.documentElement.style.colorScheme = "light";
+
+ const harness = renderThemeHarness({
+ isDarkMode: true,
+ });
+
+ expect(
+ document.documentElement.getAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
+ ).toBe("dark");
+ expect(document.documentElement.style.colorScheme).toBe("dark");
+
+ harness.cleanup();
+
+ expect(
+ document.documentElement.hasAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
+ ).toBe(false);
+ expect(document.documentElement.style.colorScheme).toBe("light");
+ });
+
+ it("clears the document-level theme when Studio unmounts", () => {
+ const harness = renderThemeHarness({
+ isDarkMode: true,
+ });
+
+ expect(
+ document.documentElement.hasAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
+ ).toBe(true);
+
+ harness.cleanup();
+
+ expect(
+ document.documentElement.hasAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE),
+ ).toBe(false);
+ expect(document.documentElement.style.colorScheme).toBe("");
+ expect(document.documentElement.style.backgroundColor).toBe("");
+ });
});
diff --git a/ui/hooks/use-theme.ts b/ui/hooks/use-theme.ts
index 0a8f114d..e027a682 100644
--- a/ui/hooks/use-theme.ts
+++ b/ui/hooks/use-theme.ts
@@ -106,6 +106,257 @@ export function clearThemeVariables(variableNames: Iterable): void {
}
}
+/**
+ * Marker attribute set on `` when Studio owns the document background.
+ */
+export const STUDIO_DOCUMENT_THEME_ATTRIBUTE = "data-prisma-studio-theme";
+
+function hasAuthoredBackground(element: Element): boolean {
+ if (
+ typeof window === "undefined" ||
+ typeof window.getComputedStyle !== "function"
+ ) {
+ return false;
+ }
+
+ const style = window.getComputedStyle(element);
+ const backgroundColor = style.backgroundColor;
+ const hasBackgroundColor =
+ backgroundColor !== "" &&
+ backgroundColor !== "transparent" &&
+ backgroundColor !== "rgba(0, 0, 0, 0)";
+ const backgroundImage = style.backgroundImage;
+ const hasBackgroundImage =
+ backgroundImage !== "" && backgroundImage !== "none";
+
+ return hasBackgroundColor || hasBackgroundImage;
+}
+
+interface DocumentThemeInlineValues {
+ backgroundColor: string;
+ colorScheme: string;
+}
+
+interface DocumentThemeClaim {
+ /**
+ * Inline `` values that were present before Studio claimed the
+ * document, restored when the claim is released.
+ */
+ original: DocumentThemeInlineValues;
+ /**
+ * Inline `` values Studio last applied. If the current inline values
+ * differ, the host mutated them after Studio's claim and wins.
+ */
+ applied: DocumentThemeInlineValues;
+}
+
+/**
+ * `useTheme` instances that currently participate in document-level theming.
+ * The document theme is only released once the last instance unmounts.
+ */
+const documentThemeOwners = new Set