Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dark-mode-page-background.md
Original file line number Diff line number Diff line change
@@ -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 `<html>`/`<body>` background are left untouched.
3 changes: 3 additions & 0 deletions Architecture/ui-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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.
- 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 `<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.

## Why This Architecture Is Better

Expand Down
1 change: 1 addition & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html>`/`<body>` background are left untouched.
18 changes: 11 additions & 7 deletions demo/ppg-dev/DemoShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -118,7 +120,7 @@ export function DemoApp(props: {
<div
style={{
background:
"linear-gradient(165deg, #f8fafc 0%, #e2e8f0 40%, #dbeafe 100%)",
"linear-gradient(165deg, light-dark(#f8fafc, #0b1220) 0%, light-dark(#e2e8f0, #131c2e) 40%, light-dark(#dbeafe, #1a2440) 100%)",
display: "grid",
gridTemplateRows: "auto 1fr",
height: "100vh",
Expand All @@ -129,9 +131,11 @@ export function DemoApp(props: {
style={{
alignItems: "center",
backdropFilter: "blur(6px)",
background: "rgba(255, 255, 255, 0.86)",
borderBottom: "1px solid rgba(15, 23, 42, 0.1)",
color: "#0f172a",
background:
"light-dark(rgba(255, 255, 255, 0.86), rgba(15, 23, 42, 0.86))",
borderBottom:
"1px solid light-dark(rgba(15, 23, 42, 0.1), rgba(148, 163, 184, 0.2))",
color: "light-dark(#0f172a, #e2e8f0)",
display: "flex",
fontFamily:
"ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif",
Expand Down
2 changes: 1 addition & 1 deletion demo/ppg-dev/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -800,7 +800,7 @@ function getHtmlDocument(): string {
<title>Studio + ppg demo${isProduction ? "" : " (direct tcp)"}</title>
<link rel="stylesheet" href="/app.css${bustSuffix}" />
</head>
<body style="margin: 0; min-height: 100vh; background: #f3f4f6;">
<body style="margin: 0; min-height: 100vh;">
<div id="root" style="height: 100vh;"></div>${liveReloadScript}
<script type="module" src="/app.js${bustSuffix}"></script>
</body>
Expand Down
177 changes: 177 additions & 0 deletions ui/hooks/use-theme.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
applyDarkModeClass,
applyThemeVariables,
clearThemeVariables,
STUDIO_DOCUMENT_THEME_ATTRIBUTE,
useTheme,
} from "./use-theme";

Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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(<Harness isDarkMode />);
});

expect(document.documentElement.style.colorScheme).toBe("dark");

act(() => {
root.render(<Harness isDarkMode={false} />);
});

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("");
});
});
Loading