From a08b24fadf6280e9e8f3c35ca1e696486eede467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Fri, 17 Jul 2026 21:22:11 +0700 Subject: [PATCH 1/2] 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 (marked with data-prisma-studio-theme) so the page canvas matches the active theme. Studio only claims the document when neither nor 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 prisma/studio#1475 Co-Authored-By: Claude Fable 5 --- .changeset/dark-mode-page-background.md | 5 ++ Architecture/ui-state.md | 1 + FEATURES.md | 1 + demo/ppg-dev/DemoShell.tsx | 18 ++-- demo/ppg-dev/server.ts | 2 +- ui/hooks/use-theme.test.tsx | 102 ++++++++++++++++++++++ ui/hooks/use-theme.ts | 108 ++++++++++++++++++++++++ 7 files changed, 229 insertions(+), 8 deletions(-) create mode 100644 .changeset/dark-mode-page-background.md 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..fe60aaf8 100644 --- a/Architecture/ui-state.md +++ b/Architecture/ui-state.md @@ -150,6 +150,7 @@ 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, and Studio MUST remove its document-level theme when it 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..969837ac 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,103 @@ 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("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..39312a08 100644 --- a/ui/hooks/use-theme.ts +++ b/ui/hooks/use-theme.ts @@ -106,6 +106,111 @@ 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; +} + +/** + * 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. + */ +function canOwnDocumentBackground(): boolean { + const documentElement = document.documentElement; + + if (documentElement.hasAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE)) { + // Already claimed by Studio earlier; keep syncing. + return true; + } + + if (hasAuthoredBackground(documentElement)) { + return false; + } + + const body = document.body; + + return body == null || !hasAuthoredBackground(body); +} + +/** + * Sync the resolved Studio theme to the document root so overscroll areas and + * the space behind Studio's rounded corners match the active theme. This is + * a no-op whenever the host page authored its own document background. + */ +export function syncDocumentTheme(args: { + isDarkMode: boolean; + studioRoot: HTMLElement | null; +}): void { + if (typeof document === "undefined") { + return; + } + + if (!canOwnDocumentBackground()) { + return; + } + + const { isDarkMode, studioRoot } = args; + const documentElement = document.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"); + } +} + +/** + * Remove the document-level theme Studio applied, if any. + */ +export function clearDocumentTheme(): void { + if (typeof document === "undefined") { + return; + } + + const documentElement = document.documentElement; + + if (!documentElement.hasAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE)) { + return; + } + + documentElement.removeAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE); + documentElement.style.removeProperty("color-scheme"); + documentElement.style.removeProperty("background-color"); +} + /** * Apply dark mode class to every Studio root element. */ @@ -144,6 +249,8 @@ function syncStudioRootTheme(args: { root.style.setProperty(property, value); } } + + syncDocumentTheme({ isDarkMode, studioRoot: roots[0] ?? null }); } const useIsomorphicLayoutEffect = @@ -208,6 +315,7 @@ export function useTheme( return () => { observer.disconnect(); + clearDocumentTheme(); }; }, [currentThemeVariables, isDarkMode]); From 07900d40bf55b253ebc63abe2342c94bd91135b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Sat, 18 Jul 2026 16:24:25 +0700 Subject: [PATCH 2/2] 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 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 --- Architecture/ui-state.md | 4 +- ui/hooks/use-theme.test.tsx | 75 ++++++++++++ ui/hooks/use-theme.ts | 227 +++++++++++++++++++++++++++++++----- 3 files changed, 275 insertions(+), 31 deletions(-) diff --git a/Architecture/ui-state.md b/Architecture/ui-state.md index fe60aaf8..660bfbbf 100644 --- a/Architecture/ui-state.md +++ b/Architecture/ui-state.md @@ -150,7 +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, and Studio MUST remove its document-level theme when it unmounts. +- 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/ui/hooks/use-theme.test.tsx b/ui/hooks/use-theme.test.tsx index 969837ac..5b0cdc1f 100644 --- a/ui/hooks/use-theme.test.tsx +++ b/ui/hooks/use-theme.test.tsx @@ -299,6 +299,81 @@ describe("useTheme", () => { 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, diff --git a/ui/hooks/use-theme.ts b/ui/hooks/use-theme.ts index 39312a08..e027a682 100644 --- a/ui/hooks/use-theme.ts +++ b/ui/hooks/use-theme.ts @@ -132,49 +132,165 @@ function hasAuthoredBackground(element: Element): boolean { 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; +} + /** - * 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. + * `useTheme` instances that currently participate in document-level theming. + * The document theme is only released once the last instance unmounts. */ -function canOwnDocumentBackground(): boolean { - const documentElement = document.documentElement; +const documentThemeOwners = new Set(); - if (documentElement.hasAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE)) { - // Already claimed by Studio earlier; keep syncing. - return true; - } +let documentThemeClaim: DocumentThemeClaim | null = null; - if (hasAuthoredBackground(documentElement)) { - return false; +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); } +} - const body = document.body; +function restoreDocumentThemeInlineValues( + documentElement: HTMLElement, + values: DocumentThemeInlineValues, +): void { + setInlinePropertyOrRemove( + documentElement, + "background-color", + values.backgroundColor, + ); + setInlinePropertyOrRemove( + documentElement, + "color-scheme", + values.colorScheme, + ); +} - return body == null || !hasAuthoredBackground(body); +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. This is - * a no-op whenever the host page authored its own document background. + * 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; } - if (!canOwnDocumentBackground()) { + 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 { isDarkMode, studioRoot } = args; - const documentElement = document.documentElement; + const original = + documentThemeClaim?.original ?? + readDocumentThemeInlineValues(documentElement); const resolvedTheme = isDarkMode ? "dark" : "light"; documentElement.setAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE, resolvedTheme); @@ -190,25 +306,55 @@ export function syncDocumentTheme(args: { } else { documentElement.style.removeProperty("background-color"); } + + documentThemeClaim = { + original, + applied: readDocumentThemeInlineValues(documentElement), + }; } /** - * Remove the document-level theme Studio applied, if any. + * 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 clearDocumentTheme(): void { - if (typeof document === "undefined") { +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 (!documentElement.hasAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE)) { + if (!claim) { return; } - documentElement.removeAttribute(STUDIO_DOCUMENT_THEME_ATTRIBUTE); - documentElement.style.removeProperty("color-scheme"); - documentElement.style.removeProperty("background-color"); + 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); } /** @@ -228,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) { @@ -250,7 +402,11 @@ function syncStudioRootTheme(args: { } } - syncDocumentTheme({ isDarkMode, studioRoot: roots[0] ?? null }); + syncDocumentTheme({ + isDarkMode, + owner: documentThemeOwner, + studioRoot: roots[0] ?? null, + }); } const useIsomorphicLayoutEffect = @@ -264,6 +420,7 @@ export function useTheme( isDarkMode?: boolean, ) { const appliedThemeVariableNamesRef = useRef([]); + const documentThemeOwnerRef = useRef({}); const parsedTheme = useMemo(() => { if (!customTheme) return null; @@ -295,6 +452,7 @@ export function useTheme( syncStudioRootTheme({ currentThemeVariables, + documentThemeOwner: documentThemeOwnerRef.current, isDarkMode: isDarkMode ?? false, removedThemeVariableNames, }); @@ -315,10 +473,19 @@ export function useTheme( return () => { observer.disconnect(); - clearDocumentTheme(); }; }, [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,