@marimo-team/islands 0.23.15-dev5 → 0.23.15-dev50
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.
- package/dist/{ConnectedDataExplorerComponent-CU4fZJzG.js → ConnectedDataExplorerComponent-Cmq_9LVS.js} +4 -4
- package/dist/{ErrorBoundary-DE6tzZf-.js → ErrorBoundary-CnMJ8jR9.js} +1 -1
- package/dist/{ImageComparisonComponent-CHrI72em.js → ImageComparisonComponent-1i5IBhon.js} +173 -129
- package/dist/assets/__vite-browser-external-CjNAy01L.js +1 -0
- package/dist/assets/worker-B3XPCb6y.js +98 -0
- package/dist/{chat-ui-DinOvbWu.js → chat-ui-Dy0arY8E.js} +3111 -3111
- package/dist/{code-visibility-DBuB5MVY.js → code-visibility-B5xe1Xp2.js} +946 -864
- package/dist/{constants-T20xxyNf.js → debounce-BOD3DbfP.js} +1 -24
- package/dist/{formats-Dzx4J_z1.js → formats-CdvkxTS6.js} +1 -1
- package/dist/{glide-data-editor-CjTu7ukN.js → glide-data-editor-D5w23DvC.js} +3 -3
- package/dist/{html-to-image-_wGfk8V-.js → html-to-image-f-kPEnn0.js} +2291 -2250
- package/dist/{input-DtsN7xm-.js → input-CF5Sgjba.js} +2 -2
- package/dist/main.js +1302 -1202
- package/dist/{mermaid-BYqXy_NE.js → mermaid-BaZJ2mds.js} +2 -2
- package/dist/{process-output-Mh4UrjwM.js → process-output-O9FeFAVM.js} +1 -1
- package/dist/{reveal-component-DfOZrkd6.js → reveal-component-BkZp_qsV.js} +618 -605
- package/dist/{spec-Cz-Bj1JI.js → spec-DxIove3q.js} +1 -1
- package/dist/style.css +1 -1
- package/dist/{toDate-CWNNlFEX.js → toDate-CEOdz9nC.js} +14 -5
- package/dist/{useAsyncData-KfHB8wQR.js → useAsyncData-Cs0mpJTy.js} +1 -1
- package/dist/{useDeepCompareMemoize-nJMtxhm4.js → useDeepCompareMemoize-DoOZicGb.js} +1 -1
- package/dist/{useLifecycle-DegSo0lV.js → useLifecycle-DR_F1d7F.js} +1 -1
- package/dist/{useTheme-6eZ3GOTS.js → useTheme-BA-8MM7N.js} +63 -27
- package/dist/{vega-component-DzyyM9fc.js → vega-component-BN0fz-QA.js} +6 -6
- package/package.json +1 -1
- package/src/__mocks__/requests.ts +3 -0
- package/src/components/chat/__tests__/chat-utils.test.ts +244 -1
- package/src/components/chat/__tests__/message-queue.test.tsx +121 -0
- package/src/components/chat/chat-panel.tsx +196 -67
- package/src/components/chat/chat-utils.ts +111 -2
- package/src/components/editor/SortableCell.tsx +6 -2
- package/src/components/editor/__tests__/output-persistence.test.tsx +241 -0
- package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +153 -0
- package/src/components/editor/actions/pair-with-agent-commands.ts +109 -0
- package/src/components/editor/actions/pair-with-agent-modal.tsx +20 -64
- package/src/components/editor/actions/useNotebookActions.tsx +8 -0
- package/src/components/editor/ai/add-cell-with-ai.tsx +14 -5
- package/src/components/editor/ai/ai-completion-editor.tsx +4 -1
- package/src/components/editor/cell/cell-context-menu.tsx +7 -0
- package/src/components/editor/chrome/components/__tests__/feedback-button.test.tsx +221 -0
- package/src/components/editor/chrome/components/feedback-button.tsx +308 -77
- package/src/components/editor/chrome/panels/packages-panel.tsx +11 -1
- package/src/components/editor/columns/__tests__/cell-column.test.tsx +105 -0
- package/src/components/editor/columns/cell-column.tsx +34 -4
- package/src/components/editor/columns/sortable-column.tsx +24 -4
- package/src/components/editor/file-tree/download.ts +46 -0
- package/src/components/editor/file-tree/file-explorer.tsx +3 -21
- package/src/components/editor/file-tree/file-viewer.tsx +4 -27
- package/src/components/editor/navigation/__tests__/navigation.test.ts +33 -0
- package/src/components/editor/navigation/navigation.ts +8 -1
- package/src/components/editor/notebook-cell.tsx +203 -106
- package/src/components/editor/renderers/__tests__/cells-renderer.test.tsx +66 -0
- package/src/components/editor/renderers/cell-array.tsx +26 -13
- package/src/components/editor/renderers/cells-renderer.tsx +8 -2
- package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +29 -0
- package/src/components/editor/renderers/slides-layout/types.ts +4 -0
- package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +19 -19
- package/src/components/slides/reveal-component.tsx +37 -4
- package/src/components/slides/slide-form.tsx +72 -0
- package/src/components/ui/toast.tsx +11 -3
- package/src/components/ui/toaster.tsx +65 -9
- package/src/core/ai/context/providers/error.ts +6 -158
- package/src/core/cells/__tests__/utils.test.ts +59 -0
- package/src/core/cells/utils.ts +51 -0
- package/src/core/codemirror/completion/__tests__/signature-hint.test.ts +97 -3
- package/src/core/codemirror/completion/signature-hint.ts +78 -11
- package/src/core/constants.ts +8 -0
- package/src/core/diagnostics/__tests__/issue-details.test.ts +173 -0
- package/src/core/diagnostics/issue-details.ts +139 -0
- package/src/core/errors/__tests__/error-entries.test.ts +25 -0
- package/src/core/errors/error-entries.ts +176 -0
- package/src/core/islands/__tests__/bridge.test.ts +341 -46
- package/src/core/islands/__tests__/main.test.ts +176 -0
- package/src/core/islands/__tests__/parse.test.ts +105 -0
- package/src/core/islands/bootstrap.ts +8 -3
- package/src/core/islands/bridge.ts +117 -23
- package/src/core/islands/components/__tests__/web-components.test.tsx +214 -0
- package/src/core/islands/components/web-components.tsx +76 -15
- package/src/core/islands/constants.ts +1 -0
- package/src/core/islands/main.ts +69 -3
- package/src/core/islands/parse.ts +70 -23
- package/src/core/islands/worker/__tests__/controller.test.ts +173 -0
- package/src/core/islands/worker/worker.tsx +145 -57
- package/src/core/network/__tests__/requests-lazy.test.ts +18 -0
- package/src/core/network/__tests__/requests-network.test.ts +7 -0
- package/src/core/network/requests-lazy.ts +13 -1
- package/src/core/network/requests-network.ts +3 -0
- package/src/core/network/requests-static.ts +1 -0
- package/src/core/network/requests-toasting.tsx +1 -0
- package/src/core/network/types.ts +3 -0
- package/src/core/runtime/__tests__/runtime.test.ts +64 -0
- package/src/core/runtime/runtime.ts +30 -10
- package/src/core/wasm/bridge.ts +8 -0
- package/src/core/wasm/worker/bootstrap.ts +113 -20
- package/src/core/wasm/worker/types.ts +2 -0
- package/src/css/app/Cell.css +10 -0
- package/src/hooks/__tests__/useHotkey.test.tsx +88 -0
- package/src/hooks/useHotkey.ts +29 -4
- package/src/plugins/impl/anywidget/__tests__/host.test.ts +6 -9
- package/src/plugins/impl/anywidget/__tests__/registry.test.ts +3 -1
- package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +32 -61
- package/src/plugins/impl/anywidget/model-proxy.ts +0 -13
- package/src/plugins/impl/anywidget/runtime.ts +1 -0
- package/src/plugins/impl/anywidget/widget-binding.ts +8 -34
- package/src/plugins/impl/image-comparison/ImageComparisonComponent.tsx +53 -2
- package/src/plugins/impl/image-comparison/__tests__/ImageComparisonComponent.test.tsx +71 -0
- package/src/plugins/impl/matplotlib/__tests__/matplotlib-renderer.test.ts +71 -3
- package/src/plugins/impl/matplotlib/matplotlib-renderer.ts +1 -0
- package/src/theme/__tests__/useTheme.test.ts +68 -0
- package/src/theme/useTheme.ts +16 -1
- package/dist/assets/__vite-browser-external-BQCLNwri.js +0 -1
- package/dist/assets/worker-DAWRHcPq.js +0 -73
|
@@ -12,7 +12,7 @@ import { isTrustedVirtualFileUrl } from "@/plugins/core/trusted-url";
|
|
|
12
12
|
import { Logger } from "@/utils/Logger";
|
|
13
13
|
import type { Host } from "./host";
|
|
14
14
|
import type { Model } from "./model";
|
|
15
|
-
import { modelProxy
|
|
15
|
+
import { modelProxy } from "./model-proxy";
|
|
16
16
|
import type { ModelState } from "./types";
|
|
17
17
|
|
|
18
18
|
export const experimental: Experimental = {
|
|
@@ -80,6 +80,10 @@ class WidgetDefRegistry {
|
|
|
80
80
|
return promise;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
invalidate(jsHash: string): void {
|
|
84
|
+
this.#cache.delete(jsHash);
|
|
85
|
+
}
|
|
86
|
+
|
|
83
87
|
async #doImport(
|
|
84
88
|
jsUrl: string,
|
|
85
89
|
opts: { kernelAuthored?: boolean } = {},
|
|
@@ -266,9 +270,8 @@ export class WidgetBinding<T extends ModelState = ModelState> {
|
|
|
266
270
|
* when the caller's `signal` fires or the binding is destroyed, and
|
|
267
271
|
* listeners registered inside `render` auto-clear on abort.
|
|
268
272
|
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
* to this view's listeners; re-firing at other views double-paints.
|
|
273
|
+
* `render` reads current state via `model.get`; change listeners
|
|
274
|
+
* observe only subsequent changes, matching Jupyter semantics.
|
|
272
275
|
*/
|
|
273
276
|
async createView(
|
|
274
277
|
target: { el: HTMLElement },
|
|
@@ -305,11 +308,8 @@ export class WidgetBinding<T extends ModelState = ModelState> {
|
|
|
305
308
|
// Each view gets a host scoped to its own signal so child views tear
|
|
306
309
|
// down with this view.
|
|
307
310
|
const host = this.#createHost(renderSignal);
|
|
308
|
-
const registrations: ProxyRegistration[] = [];
|
|
309
311
|
const renderCleanup = await widget.render({
|
|
310
|
-
model: modelProxy(this.#model, renderSignal,
|
|
311
|
-
registrations.push(registration),
|
|
312
|
-
),
|
|
312
|
+
model: modelProxy(this.#model, renderSignal),
|
|
313
313
|
el: target.el,
|
|
314
314
|
experimental,
|
|
315
315
|
signal: renderSignal,
|
|
@@ -333,7 +333,6 @@ export class WidgetBinding<T extends ModelState = ModelState> {
|
|
|
333
333
|
once: true,
|
|
334
334
|
});
|
|
335
335
|
}
|
|
336
|
-
this.#replayState(registrations, renderSignal);
|
|
337
336
|
}
|
|
338
337
|
|
|
339
338
|
#trackCleanup(cleanup: Cleanup, reason: string): Promise<void> {
|
|
@@ -343,31 +342,6 @@ export class WidgetBinding<T extends ModelState = ModelState> {
|
|
|
343
342
|
return task;
|
|
344
343
|
}
|
|
345
344
|
|
|
346
|
-
/**
|
|
347
|
-
* The hydration guarantee documented on `createView`.
|
|
348
|
-
*/
|
|
349
|
-
#replayState(
|
|
350
|
-
registrations: readonly ProxyRegistration[],
|
|
351
|
-
renderSignal: AbortSignal,
|
|
352
|
-
): void {
|
|
353
|
-
const changePrefix = "change:";
|
|
354
|
-
for (const { event, callback } of registrations) {
|
|
355
|
-
if (renderSignal.aborted) {
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
try {
|
|
359
|
-
if (event.startsWith(changePrefix)) {
|
|
360
|
-
const key = event.slice(changePrefix.length);
|
|
361
|
-
callback(this.#model.get(key));
|
|
362
|
-
} else if (event === "change") {
|
|
363
|
-
callback();
|
|
364
|
-
}
|
|
365
|
-
} catch (error) {
|
|
366
|
-
Logger.error("[WidgetBinding] Error replaying state", error);
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
345
|
/**
|
|
372
346
|
* Destroy this generation, running initialize/render cleanups and
|
|
373
347
|
* clearing listeners registered through its model proxies.
|
|
@@ -12,6 +12,13 @@ export interface ImageComparisonData {
|
|
|
12
12
|
height?: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// Truncate long sources (e.g. base64 data URLs) so error messages stay
|
|
16
|
+
// readable.
|
|
17
|
+
function truncateSrc(src: string): string {
|
|
18
|
+
const MAX_LENGTH = 100;
|
|
19
|
+
return src.length > MAX_LENGTH ? `${src.slice(0, MAX_LENGTH)}…` : src;
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
const ImageComparisonComponent: React.FC<ImageComparisonData> = ({
|
|
16
23
|
beforeSrc,
|
|
17
24
|
afterSrc,
|
|
@@ -20,17 +27,61 @@ const ImageComparisonComponent: React.FC<ImageComparisonData> = ({
|
|
|
20
27
|
width,
|
|
21
28
|
height,
|
|
22
29
|
}) => {
|
|
30
|
+
const [failedSrcs, setFailedSrcs] = React.useState<ReadonlySet<string>>(
|
|
31
|
+
() => new Set(),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
React.useEffect(() => {
|
|
35
|
+
setFailedSrcs(new Set());
|
|
36
|
+
}, [beforeSrc, afterSrc]);
|
|
37
|
+
|
|
38
|
+
const handleError = React.useCallback((src: string) => {
|
|
39
|
+
setFailedSrcs((prev) => new Set(prev).add(src));
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
23
42
|
const containerStyle: React.CSSProperties = {
|
|
24
43
|
width: width || "100%",
|
|
25
44
|
height: height || (direction === "vertical" ? "400px" : "auto"),
|
|
26
45
|
maxWidth: "100%",
|
|
46
|
+
// The slider derives its height entirely from its (loaded) images, so a
|
|
47
|
+
// broken/slow-loading source would otherwise collapse it to nothing and
|
|
48
|
+
// render an empty output. Keep a minimum height so it stays visible.
|
|
49
|
+
minHeight: "2rem",
|
|
27
50
|
};
|
|
28
51
|
|
|
52
|
+
// If an image fails to load, the slider collapses to nothing; surface a
|
|
53
|
+
// visible error instead of silently rendering an empty output.
|
|
54
|
+
if (failedSrcs.size > 0) {
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
style={containerStyle}
|
|
58
|
+
className="flex items-center justify-center rounded border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive"
|
|
59
|
+
>
|
|
60
|
+
<span>
|
|
61
|
+
Failed to load {failedSrcs.size > 1 ? "images" : "image"}:{" "}
|
|
62
|
+
{[...failedSrcs].map((src) => `"${truncateSrc(src)}"`).join(", ")}
|
|
63
|
+
</span>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
29
68
|
return (
|
|
30
69
|
<div style={containerStyle}>
|
|
31
70
|
<ImgComparisonSlider value={value} direction={direction}>
|
|
32
|
-
<img
|
|
33
|
-
|
|
71
|
+
<img
|
|
72
|
+
slot="first"
|
|
73
|
+
src={beforeSrc}
|
|
74
|
+
alt="Before"
|
|
75
|
+
width="100%"
|
|
76
|
+
onError={() => handleError(beforeSrc)}
|
|
77
|
+
/>
|
|
78
|
+
<img
|
|
79
|
+
slot="second"
|
|
80
|
+
src={afterSrc}
|
|
81
|
+
alt="After"
|
|
82
|
+
width="100%"
|
|
83
|
+
onError={() => handleError(afterSrc)}
|
|
84
|
+
/>
|
|
34
85
|
</ImgComparisonSlider>
|
|
35
86
|
</div>
|
|
36
87
|
);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render } from "@testing-library/react";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import ImageComparisonComponent from "../ImageComparisonComponent";
|
|
6
|
+
|
|
7
|
+
const baseProps = {
|
|
8
|
+
beforeSrc: "before.png",
|
|
9
|
+
afterSrc: "after.png",
|
|
10
|
+
value: 50,
|
|
11
|
+
direction: "horizontal" as const,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("ImageComparisonComponent", () => {
|
|
15
|
+
it("renders the comparison slider with both images", () => {
|
|
16
|
+
const { getByAltText, container } = render(
|
|
17
|
+
<ImageComparisonComponent {...baseProps} />,
|
|
18
|
+
);
|
|
19
|
+
expect(getByAltText("Before")).toBeTruthy();
|
|
20
|
+
expect(getByAltText("After")).toBeTruthy();
|
|
21
|
+
expect(container.querySelector("img-comparison-slider")).toBeTruthy();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("shows a visible error instead of collapsing when an image fails to load", () => {
|
|
25
|
+
const brokenSrc = "https://example.com/does-not-exist.png";
|
|
26
|
+
const { getByAltText, queryByText, container } = render(
|
|
27
|
+
<ImageComparisonComponent {...baseProps} beforeSrc={brokenSrc} />,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
// No error before the image fails.
|
|
31
|
+
expect(queryByText(/Failed to load/)).toBeNull();
|
|
32
|
+
|
|
33
|
+
fireEvent.error(getByAltText("Before"));
|
|
34
|
+
|
|
35
|
+
// The slider is replaced with a visible error mentioning the source, so
|
|
36
|
+
// the output is never a silent blank.
|
|
37
|
+
expect(container.querySelector("img-comparison-slider")).toBeNull();
|
|
38
|
+
const error = queryByText(/Failed to load image/);
|
|
39
|
+
expect(error).toBeTruthy();
|
|
40
|
+
expect(error?.textContent).toContain(brokenSrc);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("clears the error when the sources change so new images can load", () => {
|
|
44
|
+
const brokenSrc = "https://example.com/does-not-exist.png";
|
|
45
|
+
const { getByAltText, queryByText, container, rerender } = render(
|
|
46
|
+
<ImageComparisonComponent {...baseProps} beforeSrc={brokenSrc} />,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
fireEvent.error(getByAltText("Before"));
|
|
50
|
+
expect(queryByText(/Failed to load/)).toBeTruthy();
|
|
51
|
+
|
|
52
|
+
// Re-render with a new source (e.g. the user fixed the notebook): the error
|
|
53
|
+
// should clear and the slider should mount again rather than stay stuck.
|
|
54
|
+
rerender(<ImageComparisonComponent {...baseProps} beforeSrc="fixed.png" />);
|
|
55
|
+
expect(queryByText(/Failed to load/)).toBeNull();
|
|
56
|
+
expect(container.querySelector("img-comparison-slider")).toBeTruthy();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("truncates long sources (e.g. data URLs) in the error message", () => {
|
|
60
|
+
const longSrc = `data:image/png;base64,${"A".repeat(200)}`;
|
|
61
|
+
const { getByAltText, queryByText } = render(
|
|
62
|
+
<ImageComparisonComponent {...baseProps} afterSrc={longSrc} />,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
fireEvent.error(getByAltText("After"));
|
|
66
|
+
|
|
67
|
+
const error = queryByText(/Failed to load image/);
|
|
68
|
+
expect(error?.textContent).toContain("…");
|
|
69
|
+
expect(error?.textContent).not.toContain("A".repeat(200));
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
3
|
-
import
|
|
4
|
-
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
type Data,
|
|
5
|
+
MatplotlibRenderer,
|
|
6
|
+
type MatplotlibState,
|
|
7
|
+
visibleForTesting,
|
|
8
|
+
} from "../matplotlib-renderer";
|
|
5
9
|
|
|
6
10
|
const {
|
|
7
11
|
pixelToData,
|
|
@@ -185,3 +189,67 @@ describe("isInAxes", () => {
|
|
|
185
189
|
expect(isInAxes({ x: 300, y: 351 }, LINEAR_AXES)).toBe(false);
|
|
186
190
|
});
|
|
187
191
|
});
|
|
192
|
+
|
|
193
|
+
describe("MatplotlibRenderer", () => {
|
|
194
|
+
afterEach(() => {
|
|
195
|
+
vi.unstubAllGlobals();
|
|
196
|
+
vi.restoreAllMocks();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("restores a new selection when the chart changes", () => {
|
|
200
|
+
vi.stubGlobal(
|
|
201
|
+
"matchMedia",
|
|
202
|
+
vi.fn(() => ({ addEventListener: vi.fn() })),
|
|
203
|
+
);
|
|
204
|
+
vi.stubGlobal(
|
|
205
|
+
"requestAnimationFrame",
|
|
206
|
+
vi.fn(() => 1),
|
|
207
|
+
);
|
|
208
|
+
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
|
209
|
+
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
|
210
|
+
|
|
211
|
+
const value = {
|
|
212
|
+
type: "box",
|
|
213
|
+
has_selection: true,
|
|
214
|
+
data: { x_min: 2, x_max: 4, y_min: 2, y_max: 4 },
|
|
215
|
+
} as const;
|
|
216
|
+
const state: MatplotlibState = {
|
|
217
|
+
...LINEAR_AXES,
|
|
218
|
+
axesPixelBounds: [0, 0, 100, 100],
|
|
219
|
+
yBounds: [0, 10],
|
|
220
|
+
chartBase64: "first",
|
|
221
|
+
width: 100,
|
|
222
|
+
height: 100,
|
|
223
|
+
selectionColor: "blue",
|
|
224
|
+
selectionOpacity: 0.15,
|
|
225
|
+
strokeWidth: 2,
|
|
226
|
+
debounce: false,
|
|
227
|
+
value: { has_selection: false },
|
|
228
|
+
setValue: vi.fn(),
|
|
229
|
+
};
|
|
230
|
+
const container = document.createElement("div");
|
|
231
|
+
const controller = new AbortController();
|
|
232
|
+
const renderer = new MatplotlibRenderer(container, {
|
|
233
|
+
state,
|
|
234
|
+
signal: controller.signal,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Cell reruns reuse the renderer, updating the chart and initial value together.
|
|
238
|
+
renderer.update({ ...state, chartBase64: "second", value });
|
|
239
|
+
|
|
240
|
+
const canvas = container.querySelector("canvas");
|
|
241
|
+
expect(canvas).not.toBeNull();
|
|
242
|
+
if (!canvas) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
vi.spyOn(canvas, "getBoundingClientRect").mockReturnValue(
|
|
246
|
+
DOMRect.fromRect({ width: 100, height: 100 }),
|
|
247
|
+
);
|
|
248
|
+
canvas.dispatchEvent(
|
|
249
|
+
new MouseEvent("pointermove", { clientX: 30, clientY: 70 }),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
expect(canvas.style.cursor).toBe("move");
|
|
253
|
+
controller.abort();
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { configOverridesAtom, userConfigAtom } from "@/core/config/config";
|
|
5
|
+
import { defaultUserConfig } from "@/core/config/config-schema";
|
|
6
|
+
import { store } from "@/core/state/jotai";
|
|
7
|
+
import type { Theme } from "../useTheme";
|
|
8
|
+
import { resolvedThemeAtom, visibleForTesting } from "../useTheme";
|
|
9
|
+
|
|
10
|
+
const { themeFromQueryParam } = visibleForTesting;
|
|
11
|
+
|
|
12
|
+
function setQuery(search: string): void {
|
|
13
|
+
window.history.replaceState({}, "", search === "" ? "/" : `/?${search}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function setConfigTheme(theme: Theme): void {
|
|
17
|
+
const config = defaultUserConfig();
|
|
18
|
+
store.set(userConfigAtom, {
|
|
19
|
+
...config,
|
|
20
|
+
display: { ...config.display, theme },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
setQuery("");
|
|
26
|
+
store.set(userConfigAtom, defaultUserConfig());
|
|
27
|
+
store.set(configOverridesAtom, {});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("themeFromQueryParam", () => {
|
|
31
|
+
it.each(["light", "dark", "system"] as const)(
|
|
32
|
+
"returns the valid theme %s",
|
|
33
|
+
(theme) => {
|
|
34
|
+
setQuery(`theme=${theme}`);
|
|
35
|
+
expect(themeFromQueryParam()).toBe(theme);
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
it("returns undefined when the param is absent", () => {
|
|
40
|
+
setQuery("");
|
|
41
|
+
expect(themeFromQueryParam()).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns undefined for an invalid value", () => {
|
|
45
|
+
setQuery("theme=blue");
|
|
46
|
+
expect(themeFromQueryParam()).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("resolvedThemeAtom with a theme query param", () => {
|
|
51
|
+
it("uses the saved config theme when no param is present", () => {
|
|
52
|
+
setConfigTheme("dark");
|
|
53
|
+
setQuery("");
|
|
54
|
+
expect(store.get(resolvedThemeAtom)).toBe("dark");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("lets the query param override the saved config theme", () => {
|
|
58
|
+
setConfigTheme("light");
|
|
59
|
+
setQuery("theme=dark");
|
|
60
|
+
expect(store.get(resolvedThemeAtom)).toBe("dark");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("falls back to the config theme for an invalid param value", () => {
|
|
64
|
+
setConfigTheme("dark");
|
|
65
|
+
setQuery("theme=blue");
|
|
66
|
+
expect(store.get(resolvedThemeAtom)).toBe("dark");
|
|
67
|
+
});
|
|
68
|
+
});
|
package/src/theme/useTheme.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { atom, useAtomValue } from "jotai";
|
|
4
4
|
import { resolvedMarimoConfigAtom } from "@/core/config/config";
|
|
5
|
+
import { KnownQueryParams } from "@/core/constants";
|
|
5
6
|
import { isIslands } from "@/core/islands/utils";
|
|
6
7
|
import { store } from "@/core/state/jotai";
|
|
7
8
|
|
|
@@ -10,6 +11,16 @@ export type ResolvedTheme = "light" | "dark";
|
|
|
10
11
|
|
|
11
12
|
export const THEMES: Theme[] = ["light", "dark", "system"];
|
|
12
13
|
|
|
14
|
+
function themeFromQueryParam(): Theme | undefined {
|
|
15
|
+
if (typeof window === "undefined") {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
const value = new URLSearchParams(window.location.search).get(
|
|
19
|
+
KnownQueryParams.theme,
|
|
20
|
+
);
|
|
21
|
+
return THEMES.includes(value as Theme) ? (value as Theme) : undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
const themeAtom = atom((get) => {
|
|
14
25
|
// If it is islands, try a few ways to infer if it is dark mode.
|
|
15
26
|
if (isIslands()) {
|
|
@@ -51,7 +62,7 @@ const themeAtom = atom((get) => {
|
|
|
51
62
|
return "light";
|
|
52
63
|
}
|
|
53
64
|
|
|
54
|
-
return get(resolvedMarimoConfigAtom).display.theme;
|
|
65
|
+
return themeFromQueryParam() ?? get(resolvedMarimoConfigAtom).display.theme;
|
|
55
66
|
});
|
|
56
67
|
|
|
57
68
|
const prefersDarkModeAtom = atom(false);
|
|
@@ -122,3 +133,7 @@ export function useTheme(): { theme: ResolvedTheme } {
|
|
|
122
133
|
const theme = useAtomValue(resolvedThemeAtom, { store });
|
|
123
134
|
return { theme };
|
|
124
135
|
}
|
|
136
|
+
|
|
137
|
+
export const visibleForTesting = {
|
|
138
|
+
themeFromQueryParam,
|
|
139
|
+
};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{t as e}from"./worker-DAWRHcPq.js";var t=e(((e,t)=>{t.exports={}}));export default t();
|