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(); + +let documentThemeClaim: DocumentThemeClaim | null = null; + +function readDocumentThemeInlineValues( + documentElement: HTMLElement, +): DocumentThemeInlineValues { + return { + backgroundColor: documentElement.style.getPropertyValue("background-color"), + colorScheme: documentElement.style.getPropertyValue("color-scheme"), + }; +} + +function setInlinePropertyOrRemove( + documentElement: HTMLElement, + property: string, + value: string, +): void { + if (value === "") { + documentElement.style.removeProperty(property); + } else { + documentElement.style.setProperty(property, value); + } +} + +function restoreDocumentThemeInlineValues( + documentElement: HTMLElement, + values: DocumentThemeInlineValues, +): void { + setInlinePropertyOrRemove( + documentElement, + "background-color", + values.backgroundColor, + ); + setInlinePropertyOrRemove( + documentElement, + "color-scheme", + values.colorScheme, + ); +} + +function releaseDocumentThemeClaim(documentElement: HTMLElement): void { + documentElement.removeAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE); + documentThemeClaim = null; +} + +/** + * Sync the resolved Studio theme to the document root so overscroll areas and + * the space behind Studio's rounded corners match the active theme. + * + * Studio may only paint the page canvas when no host styling claimed it. + * Embedded hosts (Console, arbitrary web apps) style ``/`` + * themselves, so Studio must leave their document untouched. Full-page + * shells that ship an unstyled document (the default-white overscroll and + * behind-border-radius areas from prisma/studio#1475) are safe to claim. + * Host ownership is re-evaluated on every sync (ignoring the inline values + * Studio applied itself), so a host that starts styling the document after + * Studio mounted takes over and Studio restores what it changed. + */ +export function syncDocumentTheme(args: { + isDarkMode: boolean; + owner: object; + studioRoot: HTMLElement | null; +}): void { + if (typeof document === "undefined") { + return; + } + + const { isDarkMode, owner, studioRoot } = args; + + documentThemeOwners.add(owner); + + const documentElement = document.documentElement; + + if ( + documentThemeClaim && + !documentElement.hasAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE) + ) { + // External code stripped Studio's marker; treat the claim as released. + documentThemeClaim = null; + } + + if (documentThemeClaim) { + const inlineValues = readDocumentThemeInlineValues(documentElement); + const backgroundHijacked = + inlineValues.backgroundColor !== + documentThemeClaim.applied.backgroundColor; + const colorSchemeHijacked = + inlineValues.colorScheme !== documentThemeClaim.applied.colorScheme; + + if (backgroundHijacked || colorSchemeHijacked) { + // The host took inline control after Studio's claim. Keep the host's + // values and only restore the properties Studio still controlled. + if (!backgroundHijacked) { + setInlinePropertyOrRemove( + documentElement, + "background-color", + documentThemeClaim.original.backgroundColor, + ); + } + + if (!colorSchemeHijacked) { + setInlinePropertyOrRemove( + documentElement, + "color-scheme", + documentThemeClaim.original.colorScheme, + ); + } + + releaseDocumentThemeClaim(documentElement); + return; + } + + // Judge host-authored backgrounds without Studio's own inline values. + restoreDocumentThemeInlineValues( + documentElement, + documentThemeClaim.original, + ); + } + + const body = document.body; + const hostOwnsBackground = + hasAuthoredBackground(documentElement) || + (body != null && hasAuthoredBackground(body)); + + if (hostOwnsBackground) { + if (documentThemeClaim) { + // Original inline values were restored above; drop the claim. + releaseDocumentThemeClaim(documentElement); + } + + return; + } + + const original = + documentThemeClaim?.original ?? + readDocumentThemeInlineValues(documentElement); + const resolvedTheme = isDarkMode ? "dark" : "light"; + + documentElement.setAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE, resolvedTheme); + documentElement.style.colorScheme = resolvedTheme; + + const studioBackground = + studioRoot != null && typeof window.getComputedStyle === "function" + ? window.getComputedStyle(studioRoot).getPropertyValue("--background") + : ""; + + if (studioBackground.trim() !== "") { + documentElement.style.backgroundColor = studioBackground.trim(); + } else { + documentElement.style.removeProperty("background-color"); + } + + documentThemeClaim = { + original, + applied: readDocumentThemeInlineValues(documentElement), + }; +} + +/** + * Release one `useTheme` instance's participation in document-level theming. + * The document theme is only removed (and the host's original inline values + * restored) when the last mounted instance releases. + */ +export function releaseDocumentTheme(owner: object): void { + documentThemeOwners.delete(owner); + + if (documentThemeOwners.size > 0 || typeof document === "undefined") { + return; + } + + const documentElement = document.documentElement; + const claim = documentThemeClaim; + + if (!claim) { + return; + } + + if (documentElement.hasAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE)) { + const inlineValues = readDocumentThemeInlineValues(documentElement); + + // Only restore properties Studio still controls; if the host overwrote + // one after Studio's claim, keep the host's value. + if (inlineValues.backgroundColor === claim.applied.backgroundColor) { + setInlinePropertyOrRemove( + documentElement, + "background-color", + claim.original.backgroundColor, + ); + } + + if (inlineValues.colorScheme === claim.applied.colorScheme) { + setInlinePropertyOrRemove( + documentElement, + "color-scheme", + claim.original.colorScheme, + ); + } + } + + releaseDocumentThemeClaim(documentElement); +} + /** * Apply dark mode class to every Studio root element. */ @@ -123,10 +374,16 @@ export function applyDarkModeClass(isDarkMode: boolean): void { function syncStudioRootTheme(args: { currentThemeVariables: ThemeVariables | null; + documentThemeOwner: object; isDarkMode: boolean; removedThemeVariableNames: string[]; }): void { - const { currentThemeVariables, isDarkMode, removedThemeVariableNames } = args; + const { + currentThemeVariables, + documentThemeOwner, + isDarkMode, + removedThemeVariableNames, + } = args; const roots = getStudioRoots(); for (const root of roots) { @@ -144,6 +401,12 @@ function syncStudioRootTheme(args: { root.style.setProperty(property, value); } } + + syncDocumentTheme({ + isDarkMode, + owner: documentThemeOwner, + studioRoot: roots[0] ?? null, + }); } const useIsomorphicLayoutEffect = @@ -157,6 +420,7 @@ export function useTheme( isDarkMode?: boolean, ) { const appliedThemeVariableNamesRef = useRef([]); + const documentThemeOwnerRef = useRef({}); const parsedTheme = useMemo(() => { if (!customTheme) return null; @@ -188,6 +452,7 @@ export function useTheme( syncStudioRootTheme({ currentThemeVariables, + documentThemeOwner: documentThemeOwnerRef.current, isDarkMode: isDarkMode ?? false, removedThemeVariableNames, }); @@ -211,6 +476,16 @@ export function useTheme( }; }, [currentThemeVariables, isDarkMode]); + // Participate in document-level theming for the lifetime of this instance; + // the document theme is only released when the last instance unmounts. + useIsomorphicLayoutEffect(() => { + const documentThemeOwner = documentThemeOwnerRef.current; + + return () => { + releaseDocumentTheme(documentThemeOwner); + }; + }, []); + return { parsedTheme, currentThemeVariables,