@marimo-team/islands 0.25.3-dev273 → 0.25.3-dev275

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/dist/{ConnectedDataExplorerComponent-eLHPh6uu.js → ConnectedDataExplorerComponent-CErm132k.js} +25 -26
  2. package/dist/{ErrorBoundary-DBh-wUT9.js → ErrorBoundary-BuIXiuc5.js} +43 -44
  3. package/dist/{abnfDiagram-VCTEODGH-hqd0IBtX.js → abnfDiagram-VCTEODGH-d-Qn2fwb.js} +2 -2
  4. package/dist/{any-language-editor-DnG26QXh.js → any-language-editor-6k6F12Z3.js} +3 -3
  5. package/dist/{architectureDiagram-5GKGNRK7-BzLAFcc0.js → architectureDiagram-5GKGNRK7-DxragHYv.js} +1 -1
  6. package/dist/{assertNever-DIU3JJbt.js → assertNever-B-YzWzbL.js} +14 -14
  7. package/dist/{button-0-RqY1DW.js → button-DZuNnIZO.js} +220 -223
  8. package/dist/{capabilities-B6yy_CBN.js → capabilities-D9FskwsK.js} +1 -1
  9. package/dist/{chat-ui-BWIkve2R.js → chat-ui-SZ6lSH52.js} +144 -145
  10. package/dist/{check-gUSZbRsl.js → check-XFzZeH-y.js} +1 -1
  11. package/dist/{chunk-IMKFNOWR-DOeVvuyE.js → chunk-IMKFNOWR-C0x0feFg.js} +1 -1
  12. package/dist/{chunk-SHT3W25Y-DZoFI8wR.js → chunk-SHT3W25Y-D0ZdASqB.js} +1 -1
  13. package/dist/{chunk-SVP7TREG-CWu86PSW.js → chunk-SVP7TREG-BHjL96zx.js} +1 -1
  14. package/dist/{chunk-TICWLB2K-BrM0GoP3.js → chunk-TICWLB2K-CzcrwGfp.js} +1 -1
  15. package/dist/{classDiagram-ZZMXUADV-DYi4V2bB.js → classDiagram-ZZMXUADV-BTqFAZTP.js} +2 -2
  16. package/dist/{classDiagram-v2-VYDZK3BY-tN7FIk-m.js → classDiagram-v2-VYDZK3BY-EPXb0Elt.js} +2 -2
  17. package/dist/{code-visibility-M_dXDGp6.js → code-visibility-BEAPMrC-.js} +20 -20
  18. package/dist/{common-DV0fvw_I.js → common-CEmzaoJ8.js} +799 -801
  19. package/dist/{config-BsPruWMR.js → config-C8MFHQ2E.js} +23 -22
  20. package/dist/{copy-icon-C2I8sw4h.js → copy-icon-CtllOdmg.js} +3 -3
  21. package/dist/{createReducer-DdtT_7ji.js → createReducer-DRqwmaTc.js} +2 -2
  22. package/dist/{cynefinDiagram-5FMLGOSQ-D5nCnDc-.js → cynefinDiagram-5FMLGOSQ-CBSD344q.js} +1 -1
  23. package/dist/{dagre-GXQ25YYZ-BHkd7f_i.js → dagre-GXQ25YYZ-JItjh3Ge.js} +1 -1
  24. package/dist/{data-grid-overlay-editor-Cd1zGKBt.js → data-grid-overlay-editor-CUo25_W8.js} +1 -1
  25. package/dist/{diagram-S7CK7UJ4-D1ALppT2.js → diagram-S7CK7UJ4-CTD9xTVb.js} +1 -1
  26. package/dist/{diagram-UQ7AKVKN-DLv0b83L.js → diagram-UQ7AKVKN-DTfnFai8.js} +1 -1
  27. package/dist/{diagram-VX7I27RA-7XJ7J6qg.js → diagram-VX7I27RA-BcSnucV-.js} +1 -1
  28. package/dist/{diagram-Z3DM3KII-7M7zbHp_.js → diagram-Z3DM3KII-C2ATIE8A.js} +1 -1
  29. package/dist/{dialog-DzCj1qQv.js → dialog-CaYm5imE.js} +33 -33
  30. package/dist/{dropdown-menu-CdXE15an.js → dropdown-menu-Bb0bFOIk.js} +149 -149
  31. package/dist/{ebnfDiagram-PWID7BFC-DHH1qlmV.js → ebnfDiagram-PWID7BFC-B7B9JPxc.js} +2 -2
  32. package/dist/{erDiagram-RLTQ6QDP-fI_0dGef.js → erDiagram-RLTQ6QDP-v6hDno1O.js} +1 -1
  33. package/dist/{error-banner-DPc9eIw5.js → error-banner-CHCdHVFB.js} +44 -45
  34. package/dist/{error-entries-CxN-Nq3V.js → error-entries-B-1yUdcH.js} +406 -407
  35. package/dist/{feedback-button-CE2gPi4-.js → feedback-button-BqiR7zMO.js} +11 -11
  36. package/dist/{flowDiagram-HODETNUW-Brg9MA6i.js → flowDiagram-HODETNUW-aIFlAtOy.js} +2 -2
  37. package/dist/{formats-DbSvAPY-.js → formats-PzBQMUnr.js} +3 -3
  38. package/dist/{glide-data-editor-BycATbgF.js → glide-data-editor-B7dguvqk.js} +14 -16
  39. package/dist/{glide-utils-BSw3SSHF.js → glide-utils-BSQr4d90.js} +51 -59
  40. package/dist/{globals-CW6OvnI5.js → globals-CYykXfGI.js} +3 -3
  41. package/dist/{html-to-image-Bc2AvXHG.js → html-to-image-DC_pmZLs.js} +546 -548
  42. package/dist/{infoDiagram-27XIBGKW-E9e403YT.js → infoDiagram-27XIBGKW-D6YHLeCP.js} +1 -1
  43. package/dist/{ishikawaDiagram-5VMMS53U-C-RlYDRo.js → ishikawaDiagram-5VMMS53U-D1ZvprOX.js} +1 -1
  44. package/dist/{kanban-definition-UXKFOSKX-CLFl_uqK.js → kanban-definition-UXKFOSKX-Bna_3BMr.js} +1 -1
  45. package/dist/{loader-BVEtQ4F3.js → loader-DZvtzQoD.js} +1 -1
  46. package/dist/main.js +6772 -6771
  47. package/dist/{mermaid-rd09e1JL.js → mermaid-A36unKXJ.js} +50 -50
  48. package/dist/{mindmap-definition-YA3MSWOX-BQ8TWtyB.js → mindmap-definition-YA3MSWOX-CyVCs__G.js} +1 -1
  49. package/dist/{number-overlay-editor-B_rR-LdM.js → number-overlay-editor-C4u7SJAJ.js} +1 -1
  50. package/dist/{pegDiagram-XKGWAZYB-CN8UHFPi.js → pegDiagram-XKGWAZYB-9hNBD2wZ.js} +2 -2
  51. package/dist/{pencil-DcT8AE0h.js → pencil-DYUlSKxZ.js} +1 -1
  52. package/dist/{pieDiagram-E7YTZNPT-DGuJ0EBd.js → pieDiagram-E7YTZNPT-8tr-NYo7.js} +1 -1
  53. package/dist/{process-output-qecD7Dk8.js → process-output-DYu84VfZ.js} +4 -4
  54. package/dist/{railroadDiagram-O6MQD6OU-BPDA8wy8.js → railroadDiagram-O6MQD6OU-Dzg1dyyp.js} +2 -2
  55. package/dist/{requirementDiagram-BXWQKSXE-ChzLGbGW.js → requirementDiagram-BXWQKSXE-B7sNqwGG.js} +1 -1
  56. package/dist/{reveal-component-CngVwOSy.js → reveal-component-DGi-irHV.js} +589 -589
  57. package/dist/{select-OxgozOC8.js → select-DsoP1qe8.js} +290 -290
  58. package/dist/{sequenceDiagram-WJ2MYXX4-DFX-bE6O.js → sequenceDiagram-WJ2MYXX4-oP9shbaG.js} +1 -1
  59. package/dist/{spec-oJn0RhpQ.js → spec-BZMDxAu7.js} +4 -4
  60. package/dist/{stateDiagram-D77RDMKH-BR7cI3wK.js → stateDiagram-D77RDMKH-CjCv4ogv.js} +2 -2
  61. package/dist/{stateDiagram-v2-MP3YSRHH-oGqB5UDI.js → stateDiagram-v2-MP3YSRHH-BW9X162a.js} +2 -2
  62. package/dist/{static-state-CuV6Pc3a.js → static-state-D-Q-Ajaa.js} +8 -8
  63. package/dist/style.css +1 -1
  64. package/dist/{swimlanes-42K2YHIH-DwSZl1yC.js → swimlanes-42K2YHIH-Ckfs9Wrn.js} +1 -1
  65. package/dist/{swimlanesDiagram-VR7AAH4N-BHlcd-Ri.js → swimlanesDiagram-VR7AAH4N-DpTNG9YP.js} +2 -2
  66. package/dist/{swiper-component-Dvj-goaD.js → swiper-component-DQbK_ZWm.js} +7 -7
  67. package/dist/{timeline-definition-24CTP7MA-DMmP-G2O.js → timeline-definition-24CTP7MA-BXaBlV6U.js} +1 -1
  68. package/dist/{toast-DbBHmxmB.js → toast-BhjIy3Tq.js} +158 -158
  69. package/dist/{tooltip-BfcDQd2j.js → tooltip-DMMiOn2f.js} +64 -64
  70. package/dist/use-restore-focus-CosX7-gx.js +7568 -0
  71. package/dist/{useAsyncData-BYIAoMqn.js → useAsyncData-Ci4AvJff.js} +4 -4
  72. package/dist/{useDateFormatter-ButWkdcK.js → useDateFormatter-CPlava52.js} +2 -2
  73. package/dist/{useDeepCompareMemoize-Bz0c5GN6.js → useDeepCompareMemoize-NaaWoFlX.js} +1 -1
  74. package/dist/useEvent-BRH-aCqi.js +19 -0
  75. package/dist/{useIframeCapabilities-BIolGsF1.js → useIframeCapabilities-BXhDDoCG.js} +1 -1
  76. package/dist/{useLifecycle-JjxCC7Vu.js → useLifecycle-SniivYmC.js} +2 -2
  77. package/dist/{vega-component-rxIxAAh9.js → vega-component-DIxaDQNs.js} +40 -40
  78. package/dist/{vennDiagram-4TSXK5OY-Db8GtH1G.js → vennDiagram-4TSXK5OY-C-hQ4iNJ.js} +1 -1
  79. package/dist/{wardleyDiagram-VM6X3IG4-DmIiC3FU.js → wardleyDiagram-VM6X3IG4-C2MQ_Umv.js} +1 -1
  80. package/dist/{xychartDiagram-S5SC5T6Z-DS9OPrNq.js → xychartDiagram-S5SC5T6Z-BtTTBn7Q.js} +1 -1
  81. package/package.json +1 -1
  82. package/src/core/islands/components/__tests__/web-components.test.tsx +63 -0
  83. package/src/core/islands/components/web-components.tsx +11 -0
  84. package/src/css/globals.css +6 -2
  85. package/src/plugins/impl/DataEditorPlugin.tsx +0 -4
  86. package/src/plugins/impl/data-editor/__tests__/column-types.test.ts +5 -0
  87. package/src/plugins/impl/data-editor/__tests__/glide-data-editor.test.tsx +9 -5
  88. package/src/plugins/impl/data-editor/column-types.ts +3 -0
  89. package/src/plugins/impl/data-editor/glide-data-editor.tsx +6 -5
  90. package/src/plugins/impl/data-editor/glide-utils.ts +10 -32
  91. package/src/plugins/impl/data-editor/types.ts +0 -2
  92. package/src/theme/__tests__/islands-theme.test.tsx +276 -0
  93. package/src/theme/namespace.tsx +4 -3
  94. package/src/theme/useTheme.ts +71 -41
  95. package/dist/schemas-CdscWNy_.js +0 -4747
  96. package/dist/use-restore-focus-CmflVLWJ.js +0 -1672
  97. package/dist/useTheme-DPLobSFs.js +0 -339
  98. package/dist/utils-EBklsC5R.js +0 -813
@@ -0,0 +1,276 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { render, waitFor } from "@testing-library/react";
4
+ import { createStore } from "jotai";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { JsonOutput } from "@/components/editor/output/JsonOutput";
7
+ import {
8
+ marimoDarkTheme,
9
+ marimoLightTheme,
10
+ } from "@/components/editor/output/json-output/themes";
11
+ import { userConfigAtom } from "@/core/config/config";
12
+ import { defaultUserConfig } from "@/core/config/config-schema";
13
+ import { StyleNamespace } from "../namespace";
14
+ import { resolvedThemeAtom } from "../useTheme";
15
+
16
+ let store: ReturnType<typeof createStore>;
17
+ let unsubscribe: (() => void) | undefined;
18
+ let media: EventTarget & { matches: boolean };
19
+
20
+ beforeEach(() => {
21
+ vi.stubEnv("VITE_MARIMO_ISLANDS", "true");
22
+ media = Object.assign(new EventTarget(), { matches: false });
23
+ vi.stubGlobal(
24
+ "matchMedia",
25
+ vi.fn(() => media),
26
+ );
27
+ store = createStore();
28
+ });
29
+
30
+ afterEach(() => {
31
+ stopObserving();
32
+ for (const element of [document.body, document.documentElement]) {
33
+ for (const attribute of [
34
+ "class",
35
+ "style",
36
+ "data-theme",
37
+ "data-mode",
38
+ "data-vscode-theme-kind",
39
+ ]) {
40
+ element.removeAttribute(attribute);
41
+ }
42
+ }
43
+ vi.unstubAllEnvs();
44
+ vi.unstubAllGlobals();
45
+ vi.restoreAllMocks();
46
+ });
47
+
48
+ function subscribe() {
49
+ unsubscribe = store.sub(resolvedThemeAtom, vi.fn());
50
+ }
51
+
52
+ function stopObserving() {
53
+ unsubscribe?.();
54
+ unsubscribe = undefined;
55
+ }
56
+
57
+ function getTheme() {
58
+ return store.get(resolvedThemeAtom);
59
+ }
60
+
61
+ function setSystemTheme(matches: boolean) {
62
+ media.matches = matches;
63
+ media.dispatchEvent(new Event("change"));
64
+ }
65
+
66
+ describe("islands host theme", () => {
67
+ it("defaults to light on a transparent page even if the system is dark", () => {
68
+ media.matches = true;
69
+ subscribe();
70
+ expect(getTheme()).toBe("light");
71
+ });
72
+
73
+ it.each(["body", "html"])(
74
+ "follows %s theme attributes in both directions",
75
+ async (tag) => {
76
+ const host = document.querySelector(tag)!;
77
+ subscribe();
78
+ for (const [attribute, dark, light] of [
79
+ ["class", "dark", "light"],
80
+ ["class", "dark-mode", "light-mode"],
81
+ ["class", "dark-theme", "light-theme"],
82
+ ["data-theme", "dark", "light"],
83
+ ["data-mode", "dark", "light"],
84
+ ]) {
85
+ host.setAttribute(attribute, dark);
86
+ await expect.poll(getTheme).toBe("dark");
87
+ host.setAttribute(attribute, light);
88
+ await expect.poll(getTheme).toBe("light");
89
+ host.removeAttribute(attribute);
90
+ }
91
+ },
92
+ );
93
+
94
+ it("reads the initial host theme and follows color-scheme changes", async () => {
95
+ document.body.style.colorScheme = "dark";
96
+ subscribe();
97
+ expect(getTheme()).toBe("dark");
98
+ document.body.style.colorScheme = "light";
99
+ await expect.poll(getTheme).toBe("light");
100
+ document.body.style.colorScheme = "dark";
101
+ await expect.poll(getTheme).toBe("dark");
102
+ });
103
+
104
+ it("uses the background when color-scheme is normal", async () => {
105
+ document.body.style.colorScheme = "normal";
106
+ document.body.style.backgroundColor = "rgb(30, 30, 30)";
107
+ subscribe();
108
+ expect(getTheme()).toBe("dark");
109
+ document.body.style.backgroundColor = "white";
110
+ await expect.poll(getTheme).toBe("light");
111
+ });
112
+
113
+ it("resolves a host supporting both color schemes when the system changes", async () => {
114
+ document.body.style.colorScheme = "light dark";
115
+ subscribe();
116
+ expect(getTheme()).toBe("light");
117
+ for (const matches of [true, false]) {
118
+ setSystemTheme(matches);
119
+ await expect.poll(getTheme).toBe(matches ? "dark" : "light");
120
+ }
121
+ });
122
+
123
+ it("shares a subscription, skips unchanged themes, and cleans up on unmount", async () => {
124
+ const disconnect = vi.spyOn(MutationObserver.prototype, "disconnect");
125
+ const removeListener = vi.spyOn(media, "removeEventListener");
126
+ const listener = vi.fn();
127
+ subscribe();
128
+ const unsubscribeSecond = store.sub(resolvedThemeAtom, listener);
129
+ document.body.classList.add("unrelated");
130
+ await new Promise((resolve) => setTimeout(resolve, 0));
131
+ expect(listener).not.toHaveBeenCalled();
132
+ unsubscribeSecond();
133
+ expect(disconnect).not.toHaveBeenCalled();
134
+ stopObserving();
135
+ expect(disconnect).toHaveBeenCalledOnce();
136
+ expect(removeListener).toHaveBeenCalledWith("change", expect.any(Function));
137
+ document.body.classList.add("dark");
138
+ subscribe();
139
+ expect(getTheme()).toBe("dark");
140
+ });
141
+
142
+ it("ignores unrecognized theme attributes and transparent body backgrounds", async () => {
143
+ document.body.dataset.theme = "custom-theme";
144
+ document.body.dataset.mode = "auto";
145
+ document.body.style.backgroundColor = "rgba(0, 0, 0, 0)";
146
+ document.documentElement.style.backgroundColor = "white";
147
+ subscribe();
148
+ expect(getTheme()).toBe("light");
149
+ document.documentElement.style.backgroundColor = "black";
150
+ await expect.poll(getTheme).toBe("dark");
151
+ document.documentElement.style.backgroundColor = "transparent";
152
+ await expect.poll(getTheme).toBe("light");
153
+ });
154
+
155
+ it("falls back after a theme attribute is removed", async () => {
156
+ document.body.dataset.theme = "dark";
157
+ document.body.style.colorScheme = "light";
158
+ subscribe();
159
+ expect(getTheme()).toBe("dark");
160
+ delete document.body.dataset.theme;
161
+ await expect.poll(getTheme).toBe("light");
162
+ });
163
+
164
+ it("continues to observe the host without matchMedia", async () => {
165
+ vi.stubGlobal("matchMedia", undefined);
166
+ document.body.style.colorScheme = "light dark";
167
+ subscribe();
168
+ expect(getTheme()).toBe("light");
169
+ document.body.style.colorScheme = "only dark";
170
+ await expect.poll(getTheme).toBe("dark");
171
+ });
172
+
173
+ it.each(["light", "dark"] as const)(
174
+ "does not override an explicit %s host when system preferences change",
175
+ (theme) => {
176
+ document.body.style.colorScheme = theme;
177
+ subscribe();
178
+ for (const matches of [true, false]) {
179
+ setSystemTheme(matches);
180
+ expect(getTheme()).toBe(theme);
181
+ }
182
+ },
183
+ );
184
+
185
+ it("re-reads host CSS when a media query changes its color-scheme", () => {
186
+ const computedStyle = window.getComputedStyle(document.body);
187
+ const scheme = vi.spyOn(computedStyle, "colorScheme", "get");
188
+ vi.spyOn(window, "getComputedStyle").mockReturnValue(computedStyle);
189
+ scheme.mockReturnValue("light");
190
+ subscribe();
191
+ expect(getTheme()).toBe("light");
192
+ scheme.mockReturnValue("dark");
193
+ media.dispatchEvent(new Event("change"));
194
+ expect(getTheme()).toBe("dark");
195
+ });
196
+
197
+ it("discards queued mutations and media events after the last unsubscribe", async () => {
198
+ const readStyle = vi.spyOn(window, "getComputedStyle");
199
+ subscribe();
200
+ document.body.style.colorScheme = "dark";
201
+ stopObserving();
202
+ readStyle.mockClear();
203
+ media.dispatchEvent(new Event("change"));
204
+ await new Promise((resolve) => setTimeout(resolve, 0));
205
+ expect(readStyle).not.toHaveBeenCalled();
206
+ subscribe();
207
+ expect(getTheme()).toBe("dark");
208
+ });
209
+
210
+ it("keeps the VS Code override and returns to the host after it is removed", async () => {
211
+ document.body.classList.add("dark");
212
+ const { container, unmount } = render(
213
+ <StyleNamespace>Widget</StyleNamespace>,
214
+ );
215
+ for (const [kind, theme] of [
216
+ ["vscode-light", "light"],
217
+ ["vscode-high-contrast", "dark"],
218
+ ["vscode-light", "light"],
219
+ ]) {
220
+ document.body.dataset.vscodeThemeKind = kind;
221
+ await waitFor(() => expect(container.firstChild).toHaveClass(theme));
222
+ }
223
+ delete document.body.dataset.vscodeThemeKind;
224
+ await waitFor(() => expect(container.firstChild).toHaveClass("dark"));
225
+ unmount();
226
+ });
227
+
228
+ it("does not subscribe to host themes outside Islands", async () => {
229
+ vi.stubEnv("VITE_MARIMO_ISLANDS", "false");
230
+ const config = defaultUserConfig();
231
+ store.set(userConfigAtom, {
232
+ ...config,
233
+ display: { ...config.display, theme: "light" },
234
+ });
235
+ const observe = vi.spyOn(MutationObserver.prototype, "observe");
236
+ const addListener = vi.spyOn(media, "addEventListener");
237
+ subscribe();
238
+ document.body.classList.add("dark");
239
+ await new Promise((resolve) => setTimeout(resolve, 0));
240
+ expect(getTheme()).toBe("light");
241
+ expect(observe).not.toHaveBeenCalled();
242
+ expect(addListener).not.toHaveBeenCalled();
243
+ });
244
+
245
+ it("updates the namespace root used by shadow DOM widgets", async () => {
246
+ const { container } = render(<StyleNamespace>Widget</StyleNamespace>);
247
+ expect(container.firstChild).toHaveClass("marimo", "light");
248
+ document.body.classList.add("dark");
249
+ await waitFor(() =>
250
+ expect(container.firstChild).toHaveClass("marimo", "dark"),
251
+ );
252
+ document.body.classList.remove("dark");
253
+ await waitFor(() =>
254
+ expect(container.firstChild).toHaveClass("marimo", "light"),
255
+ );
256
+ });
257
+
258
+ it("updates the dictionary viewer palette without remounting", async () => {
259
+ const { container } = render(
260
+ <JsonOutput data={{ relu: 0, note: "text" }} />,
261
+ );
262
+ const viewer = container.querySelector<HTMLElement>(
263
+ ".w-json-view-container",
264
+ );
265
+ expect(viewer).not.toBeNull();
266
+ for (const theme of ["dark", "light"] as const) {
267
+ document.body.dataset.theme = theme;
268
+ const palette = theme === "dark" ? marimoDarkTheme : marimoLightTheme;
269
+ await waitFor(() =>
270
+ expect(viewer?.style.getPropertyValue("--w-rjv-color")).toBe(
271
+ palette["--w-rjv-color"],
272
+ ),
273
+ );
274
+ }
275
+ });
276
+ });
@@ -1,20 +1,21 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  import React, { type PropsWithChildren } from "react";
3
+ import { useTheme } from "./useTheme";
3
4
 
4
5
  const style = {
5
6
  display: "contents",
6
7
  };
7
8
 
8
9
  /**
9
- * Adds the 'marimo' className which serves as a namespace to its children,
10
- * so that the styles are scoped to the DOM subtree.
10
+ * Scopes styles and applies the theme before descendants inherit text colors.
11
11
  */
12
12
  export const StyleNamespace = React.forwardRef<
13
13
  HTMLDivElement,
14
14
  PropsWithChildren<{}>
15
15
  >(({ children }, ref) => {
16
+ const { theme } = useTheme();
16
17
  return (
17
- <div ref={ref} className="marimo" style={style}>
18
+ <div ref={ref} className={`marimo ${theme}`} style={style}>
18
19
  {children}
19
20
  </div>
20
21
  );
@@ -21,61 +21,92 @@ function themeFromQueryParam(): Theme | undefined {
21
21
  return THEMES.includes(value as Theme) ? (value as Theme) : undefined;
22
22
  }
23
23
 
24
- const themeAtom = atom((get) => {
25
- // If it is islands, try a few ways to infer if it is dark mode.
26
- if (isIslands()) {
27
- // If it has a dark mode class on the body, use dark mode.
28
- if (
29
- document.body.classList.contains("dark-mode") ||
30
- document.body.classList.contains("dark")
31
- ) {
32
- return "dark";
33
- }
24
+ function getHostElements() {
25
+ return [document.body, document.documentElement];
26
+ }
34
27
 
35
- // If it has data-theme=dark or data-mode=dark on the body, use dark mode.
36
- if (
37
- document.body.dataset.theme === "dark" ||
38
- document.body.dataset.mode === "dark" ||
39
- getVsCodeTheme() === "dark"
40
- ) {
41
- return "dark";
42
- }
28
+ function getColorSchemeMedia() {
29
+ return typeof window === "undefined"
30
+ ? undefined
31
+ : window.matchMedia?.("(prefers-color-scheme: dark)");
32
+ }
43
33
 
44
- // Check the computed style for color-scheme
45
- const computedStyle = window.getComputedStyle(document.body);
46
- const colorScheme = computedStyle.getPropertyValue("color-scheme").trim();
47
- if (colorScheme) {
48
- return colorScheme.includes("dark") ? "dark" : "light";
34
+ /** Body markers override HTML markers. CSS provides the fallback. */
35
+ function getHostTheme(
36
+ prefersDark = getColorSchemeMedia()?.matches,
37
+ ): ResolvedTheme {
38
+ const elements = getHostElements();
39
+ for (const element of elements) {
40
+ for (const theme of ["dark", "light"] as const) {
41
+ if (
42
+ element.classList.contains(theme) ||
43
+ element.classList.contains(`${theme}-mode`) ||
44
+ element.classList.contains(`${theme}-theme`) ||
45
+ element.dataset.theme === theme ||
46
+ element.dataset.mode === theme
47
+ ) {
48
+ return theme;
49
+ }
49
50
  }
51
+ }
52
+
53
+ const styles = elements.map((element) => window.getComputedStyle(element));
54
+ const colorScheme = styles[0].colorScheme.split(/\s+/);
55
+ if (colorScheme.includes("dark")) {
56
+ return !colorScheme.includes("light") || prefersDark ? "dark" : "light";
57
+ }
58
+ if (colorScheme.includes("light")) {
59
+ return "light";
60
+ }
50
61
 
51
- // Fallback: check for dark background color
52
- const bgColor = computedStyle.getPropertyValue("background-color");
53
- const rgb = bgColor.match(/\d+/g);
54
- if (rgb) {
55
- const [r, g, b] = rgb.map(Number);
56
- const brightness = (r * 299 + g * 587 + b * 114) / 1000;
57
- return brightness < 128 ? "dark" : "light";
62
+ // Transparent backgrounds do not indicate a dark host page.
63
+ for (const style of styles) {
64
+ const rgb = style.backgroundColor.match(/[\d.]+/g)?.map(Number);
65
+ if (rgb && (rgb.length === 3 || rgb[3] > 0)) {
66
+ const [r, g, b] = rgb;
67
+ return (r * 299 + g * 587 + b * 114) / 1000 < 128 ? "dark" : "light";
58
68
  }
69
+ }
59
70
 
60
- // We don't want to infer from the system theme,
61
- // since the island consumer may not support dark mode.
62
- return "light";
71
+ // A dark system preference does not imply a dark host.
72
+ return "light";
73
+ }
74
+
75
+ const hostThemeAtom = atom<ResolvedTheme | undefined>(undefined);
76
+ hostThemeAtom.onMount = (setTheme) => {
77
+ const media = getColorSchemeMedia();
78
+ const updateTheme = () => setTheme(getHostTheme(media?.matches));
79
+ const observer = new MutationObserver(updateTheme);
80
+ for (const element of getHostElements()) {
81
+ observer.observe(element, {
82
+ attributes: true,
83
+ attributeFilter: ["class", "data-theme", "data-mode", "style"],
84
+ });
63
85
  }
86
+ media?.addEventListener("change", updateTheme);
87
+ updateTheme();
88
+ return () => {
89
+ observer.disconnect();
90
+ media?.removeEventListener("change", updateTheme);
91
+ // A later mount must read the current host theme.
92
+ setTheme(undefined);
93
+ };
94
+ };
64
95
 
96
+ const themeAtom = atom((get) => {
97
+ if (isIslands()) {
98
+ return get(hostThemeAtom) ?? getHostTheme();
99
+ }
65
100
  return themeFromQueryParam() ?? get(resolvedMarimoConfigAtom).display.theme;
66
101
  });
67
102
 
68
103
  const prefersDarkModeAtom = atom(false);
69
104
 
70
105
  function setupThemeListener(): void {
71
- if (typeof window === "undefined") {
106
+ const media = getColorSchemeMedia();
107
+ if (!media) {
72
108
  return;
73
109
  }
74
- if (!window.matchMedia) {
75
- return;
76
- }
77
-
78
- const media = window.matchMedia("(prefers-color-scheme: dark)");
79
110
  store.set(prefersDarkModeAtom, media.matches);
80
111
  media.addEventListener("change", (e) => {
81
112
  store.set(prefersDarkModeAtom, e.matches);
@@ -126,8 +157,7 @@ export const resolvedThemeAtom = atom((get) => {
126
157
  });
127
158
 
128
159
  /**
129
- * React hook to get the theme of the app.
130
- * This is stored in the user config.
160
+ * Returns the resolved theme from VS Code, the Islands host, or notebook settings.
131
161
  */
132
162
  export function useTheme(): { theme: ResolvedTheme } {
133
163
  const theme = useAtomValue(resolvedThemeAtom, { store });