@marimo-team/islands 0.23.17-dev20 → 0.23.17-dev21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.23.17-dev20",
3
+ "version": "0.23.17-dev21",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -1,13 +1,61 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { fireEvent, render, screen } from "@testing-library/react";
4
- import { beforeAll, describe, expect, it } from "vitest";
3
+ import { EditorView } from "@codemirror/view";
4
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
5
+ import {
6
+ afterAll,
7
+ beforeAll,
8
+ beforeEach,
9
+ describe,
10
+ expect,
11
+ it,
12
+ vi,
13
+ } from "vitest";
5
14
  import { SetupMocks } from "@/__mocks__/common";
6
15
  import { TooltipProvider } from "@/components/ui/tooltip";
7
16
  import { ReadonlyCode } from "../readonly-python-code";
8
17
 
18
+ const themeState = vi.hoisted(() => ({
19
+ value: "light" as "light" | "dark",
20
+ }));
21
+
22
+ let originalRangeGetClientRects: PropertyDescriptor | undefined;
23
+
24
+ vi.mock("@/theme/useTheme", async (importOriginal) => {
25
+ const actual = await importOriginal<typeof import("@/theme/useTheme")>();
26
+ return {
27
+ ...actual,
28
+ useTheme: () => ({ theme: themeState.value }),
29
+ };
30
+ });
31
+
9
32
  beforeAll(() => {
10
33
  SetupMocks.resizeObserver();
34
+ originalRangeGetClientRects = Object.getOwnPropertyDescriptor(
35
+ Range.prototype,
36
+ "getClientRects",
37
+ );
38
+ Object.defineProperty(Range.prototype, "getClientRects", {
39
+ configurable: true,
40
+ value: () => [],
41
+ });
42
+ });
43
+
44
+ afterAll(() => {
45
+ // Keep the shared JSDOM prototype unchanged for other test files.
46
+ if (originalRangeGetClientRects) {
47
+ Object.defineProperty(
48
+ Range.prototype,
49
+ "getClientRects",
50
+ originalRangeGetClientRects,
51
+ );
52
+ } else {
53
+ Reflect.deleteProperty(Range.prototype, "getClientRects");
54
+ }
55
+ });
56
+
57
+ beforeEach(() => {
58
+ themeState.value = "light";
11
59
  });
12
60
 
13
61
  /**
@@ -26,7 +74,71 @@ function isCollapsed(root: ParentNode) {
26
74
  return root.querySelector(".cm")?.classList.contains("opacity-20") ?? false;
27
75
  }
28
76
 
77
+ function getEditorIdentity(view: EditorView) {
78
+ return {
79
+ doc: view.state.doc,
80
+ dom: view.dom,
81
+ contentDOM: view.contentDOM,
82
+ scrollDOM: view.scrollDOM,
83
+ };
84
+ }
85
+
86
+ function expectEditorIdentity(
87
+ view: EditorView,
88
+ identity: ReturnType<typeof getEditorIdentity>,
89
+ ) {
90
+ expect(view.state.doc).toBe(identity.doc);
91
+ expect(view.dom).toBe(identity.dom);
92
+ expect(view.contentDOM).toBe(identity.contentDOM);
93
+ expect(view.scrollDOM).toBe(identity.scrollDOM);
94
+ }
95
+
29
96
  describe("ReadonlyCode", () => {
97
+ it("reconfigures the theme without replacing the editor", async () => {
98
+ let createdView: EditorView | undefined;
99
+ const onCreateEditor = (view: EditorView) => {
100
+ createdView = view;
101
+ };
102
+ const renderCode = (id: string) => (
103
+ <TooltipProvider>
104
+ <ReadonlyCode
105
+ id={id}
106
+ code={"line_1 = 1\nline_2 = 2\nline_3 = 3"}
107
+ showCopyCode={false}
108
+ onCreateEditor={onCreateEditor}
109
+ />
110
+ </TooltipProvider>
111
+ );
112
+
113
+ const { rerender } = render(renderCode("light"));
114
+ await waitFor(() => {
115
+ expect(createdView).toBeDefined();
116
+ });
117
+
118
+ const view = createdView;
119
+ if (!view) {
120
+ throw new Error("ReadonlyCode did not create an editor view");
121
+ }
122
+ const identity = getEditorIdentity(view);
123
+ expect(view.state.facet(EditorView.darkTheme)).toBe(false);
124
+
125
+ themeState.value = "dark";
126
+ rerender(renderCode("dark"));
127
+ await waitFor(() => {
128
+ expect(view.state.facet(EditorView.darkTheme)).toBe(true);
129
+ });
130
+ expect(createdView).toBe(view);
131
+ expectEditorIdentity(view, identity);
132
+
133
+ themeState.value = "light";
134
+ rerender(renderCode("light-again"));
135
+ await waitFor(() => {
136
+ expect(view.state.facet(EditorView.darkTheme)).toBe(false);
137
+ });
138
+ expect(createdView).toBe(view);
139
+ expectEditorIdentity(view, identity);
140
+ });
141
+
30
142
  it("starts collapsed when initiallyHideCode is true", () => {
31
143
  const { container } = renderReadonly({ initiallyHideCode: true });
32
144
  expect(isCollapsed(container)).toBe(true);
@@ -140,11 +140,16 @@ export const searchHighlighter = ViewPlugin.fromClass(
140
140
  );
141
141
 
142
142
  export const highlightTheme = EditorView.baseTheme({
143
- "&light .cm-searchMatch": { backgroundColor: "#99ff7780" },
144
- "&dark .cm-searchMatch": { backgroundColor: "#22bb0070" },
143
+ "&light .cm-searchMatch": {
144
+ backgroundColor: "var(--cm-search-match-background-light)",
145
+ },
146
+ "&dark .cm-searchMatch": {
147
+ backgroundColor: "var(--cm-search-match-background-dark)",
148
+ },
145
149
 
146
150
  "&light .cm-searchMatch-selected": { backgroundColor: "transparent" },
147
151
  "&dark .cm-searchMatch.cm-searchMatch-selected": {
148
- backgroundColor: "#6199ff88 !important",
152
+ backgroundColor:
153
+ "var(--cm-search-match-selected-background-dark) !important",
149
154
  },
150
155
  });
@@ -1,136 +1,72 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { type Tag, tags as t } from "@lezer/highlight";
4
- import { describe, expect, it } from "vitest";
3
+ import type { Extension } from "@codemirror/state";
4
+ import { EditorView } from "@codemirror/view";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { darkTheme } from "../dark";
5
7
  import { lightTheme } from "../light";
6
8
 
7
- // Helper function to get theme configuration from the source
8
- const getThemeConfig = () => ({
9
- variant: "light",
10
- settings: {
11
- background: "#ffffff",
12
- foreground: "#000000",
13
- caret: "#000000",
14
- selection: "#d7d4f0",
15
- lineHighlight: "#cceeff44",
16
- gutterBackground: "var(--color-background)",
17
- gutterForeground: "var(--gray-10)",
18
- },
19
- styles: [
20
- { tag: t.comment, color: "#708090" },
21
- { tag: t.variableName, color: "#000000" },
22
- { tag: [t.string, t.special(t.brace)], color: "#a11" },
23
- { tag: t.number, color: "#164" },
24
- { tag: t.bool, color: "#219" },
25
- { tag: t.null, color: "#219" },
26
- { tag: t.keyword, color: "#708", fontWeight: 500 },
27
- { tag: t.className, color: "#00f" },
28
- { tag: t.definition(t.typeName), color: "#00f" },
29
- { tag: t.typeName, color: "#085" },
30
- { tag: t.angleBracket, color: "#000000" },
31
- { tag: t.tagName, color: "#170" },
32
- { tag: t.attributeName, color: "#00c" },
33
- { tag: t.operator, color: "#a2f", fontWeight: 500 },
34
- { tag: [t.function(t.variableName)], color: "#00c" },
35
- { tag: [t.propertyName], color: "#05a" },
36
- ],
37
- });
38
-
39
- describe("lightTheme", () => {
40
- it("should export a theme", () => {
41
- expect(lightTheme).toBeDefined();
42
- expect(typeof lightTheme).toBe("object");
43
- });
44
-
45
- describe("theme configuration", () => {
46
- const config = getThemeConfig();
47
-
48
- it("should have correct basic settings", () => {
49
- expect(config.variant).toBe("light");
50
- expect(config.settings).toEqual({
51
- background: "#ffffff",
52
- foreground: "#000000",
53
- caret: "#000000",
54
- selection: "#d7d4f0",
55
- lineHighlight: "#cceeff44",
56
- gutterBackground: "var(--color-background)",
57
- gutterForeground: "var(--gray-10)",
58
- });
59
- });
9
+ const mounted: Array<{ host: HTMLDivElement; view: EditorView }> = [];
60
10
 
61
- describe("syntax highlighting", () => {
62
- const findStyle = (tag: Tag | readonly Tag[]) =>
63
- config.styles.find((s) =>
64
- Array.isArray(s.tag) ? s.tag.includes(tag as Tag) : s.tag === tag,
65
- );
11
+ function mountTheme(extension: Extension) {
12
+ const host = document.createElement("div");
13
+ document.body.append(host);
14
+ const root = host.attachShadow({ mode: "open" });
15
+ const parent = document.createElement("div");
16
+ root.append(parent);
17
+ const view = new EditorView({ extensions: extension, parent, root });
18
+ mounted.push({ host, view });
66
19
 
67
- it("should style comments", () => {
68
- expect(findStyle(t.comment)).toEqual({
69
- tag: t.comment,
70
- color: "#708090",
71
- });
72
- });
20
+ const style = root.querySelector("style");
21
+ if (!style) {
22
+ throw new Error("CodeMirror did not mount its theme styles");
23
+ }
24
+ return { css: style.textContent ?? "", view };
25
+ }
73
26
 
74
- it("should style strings", () => {
75
- const style = findStyle(t.string);
76
- expect(style).toBeDefined();
77
- expect(style?.color).toBe("#a11");
78
- expect(Array.isArray(style?.tag)).toBe(true);
79
- expect(style?.tag).toContain(t.string);
80
- expect(style?.tag).toContain(t.special(t.brace));
81
- });
82
-
83
- it("should style numbers and boolean values", () => {
84
- expect(findStyle(t.number)).toEqual({
85
- tag: t.number,
86
- color: "#164",
87
- });
88
- expect(findStyle(t.bool)).toEqual({
89
- tag: t.bool,
90
- color: "#219",
91
- });
92
- });
93
-
94
- it("should style keywords with emphasis", () => {
95
- expect(findStyle(t.keyword)).toEqual({
96
- tag: t.keyword,
97
- color: "#708",
98
- fontWeight: 500,
99
- });
100
- });
27
+ afterEach(() => {
28
+ for (const { host, view } of mounted.splice(0)) {
29
+ view.destroy();
30
+ host.remove();
31
+ }
32
+ });
101
33
 
102
- it("should style operators with emphasis", () => {
103
- expect(findStyle(t.operator)).toEqual({
104
- tag: t.operator,
105
- color: "#a2f",
106
- fontWeight: 500,
107
- });
108
- });
34
+ const themeCases = [
35
+ {
36
+ name: "light",
37
+ extension: lightTheme,
38
+ dark: false,
39
+ selectionAlias: "var(--cm-selection-background-light)",
40
+ reactiveColor: "var(--cm-reactive-reference-color-light)",
41
+ },
42
+ {
43
+ name: "dark",
44
+ extension: darkTheme,
45
+ dark: true,
46
+ selectionAlias: "var(--cm-selection-background-dark)",
47
+ reactiveColor: "var(--cm-reactive-reference-color-dark)",
48
+ },
49
+ ] as const;
109
50
 
110
- it("should style class and type names", () => {
111
- expect(findStyle(t.className)).toEqual({
112
- tag: t.className,
113
- color: "#00f",
114
- });
115
- expect(findStyle(t.typeName)).toEqual({
116
- tag: t.typeName,
117
- color: "#085",
118
- });
119
- });
51
+ describe.each(themeCases)("$name theme", (theme) => {
52
+ it("uses the production theme tokens", () => {
53
+ const { css, view } = mountTheme(theme.extension);
120
54
 
121
- it("should style function names", () => {
122
- const style = findStyle(t.function(t.variableName));
123
- expect(style).toBeDefined();
124
- expect(style?.color).toBe("#00c");
125
- expect(Array.isArray(style?.tag)).toBe(true);
126
- });
55
+ expect(view.state.facet(EditorView.darkTheme)).toBe(theme.dark);
56
+ expect(css).toContain(
57
+ `--cm-selection-background: ${theme.selectionAlias};`,
58
+ );
59
+ expect(css).toContain("background-color: var(--cm-selection-background);");
60
+ expect(css).toContain(`color: ${theme.reactiveColor};`);
61
+ expect(css).toContain(
62
+ "border-bottom: 2px solid var(--cm-reactive-reference-border-color);",
63
+ );
127
64
 
128
- it("should style property names", () => {
129
- const style = findStyle(t.propertyName);
130
- expect(style).toBeDefined();
131
- expect(style?.color).toBe("#05a");
132
- expect(Array.isArray(style?.tag)).toBe(true);
133
- });
134
- });
65
+ const hoverRule = css
66
+ .split("\n")
67
+ .find((rule) => rule.includes(".mo-cm-reactive-reference-hover"));
68
+ expect(hoverRule).toBeDefined();
69
+ expect(hoverRule).toContain("cursor: pointer;");
70
+ expect(hoverRule).not.toContain("border-bottom");
135
71
  });
136
72
  });
@@ -11,7 +11,7 @@ export const darkTheme = [
11
11
  background: "var(--cm-background)",
12
12
  foreground: "#abb2bf",
13
13
  caret: "#528bff",
14
- selection: "#3E4451",
14
+ selection: "var(--cm-selection-background)",
15
15
  lineHighlight: "#2c313c",
16
16
  gutterBackground: "var(--color-background)",
17
17
  gutterForeground: "var(--gray-10)",
@@ -36,15 +36,16 @@ export const darkTheme = [
36
36
  ],
37
37
  }),
38
38
  EditorView.theme({
39
+ "&": {
40
+ "--cm-selection-background": "var(--cm-selection-background-dark)",
41
+ },
39
42
  ".mo-cm-reactive-reference": {
40
43
  fontWeight: "400",
41
- color: "#2a7aa5",
42
- borderBottom: "2px solid #bad3de",
44
+ color: "var(--cm-reactive-reference-color-dark)",
45
+ borderBottom: "2px solid var(--cm-reactive-reference-border-color)",
43
46
  },
44
47
  ".mo-cm-reactive-reference-hover": {
45
48
  cursor: "pointer",
46
- borderBottomWidth: "3px",
47
- borderBottomColor: "#4a90a5",
48
49
  },
49
50
  }),
50
51
  ];
@@ -11,7 +11,7 @@ export const lightTheme = [
11
11
  background: "#ffffff",
12
12
  foreground: "#000000",
13
13
  caret: "#000000",
14
- selection: "#d7d4f0",
14
+ selection: "var(--cm-selection-background)",
15
15
  lineHighlight: "#cceeff44",
16
16
  gutterBackground: "var(--color-background)",
17
17
  gutterForeground: "var(--gray-10)",
@@ -45,14 +45,16 @@ export const lightTheme = [
45
45
  ],
46
46
  }),
47
47
  EditorView.theme({
48
+ "&": {
49
+ "--cm-selection-background": "var(--cm-selection-background-light)",
50
+ },
48
51
  ".mo-cm-reactive-reference": {
49
52
  fontWeight: "400",
50
- color: "#005f87",
51
- borderBottom: "2px solid #bad3de",
53
+ color: "var(--cm-reactive-reference-color-light)",
54
+ borderBottom: "2px solid var(--cm-reactive-reference-border-color)",
52
55
  },
53
56
  ".mo-cm-reactive-reference-hover": {
54
57
  cursor: "pointer",
55
- borderBottomWidth: "3px",
56
58
  },
57
59
  }),
58
60
  ];
@@ -1,14 +1,18 @@
1
1
  @reference "../globals.css";
2
2
 
3
3
  .light .cm-selectionBackground {
4
- /* This is the background for selected text when focused,
5
- but we want to use it when not in focus either because we
6
- will often lose focus during find/replace or in another editor */
7
- background-color: #d7d4f0 !important;
4
+ /* Keep drawn selections visible when focus moves to find/replace or another editor. */
5
+ background-color: var(
6
+ --cm-selection-background,
7
+ var(--cm-selection-background-light)
8
+ ) !important;
8
9
  }
9
10
 
10
11
  .dark .cm-selectionBackground {
11
- background-color: #1177cc80 !important;
12
+ background-color: var(
13
+ --cm-selection-background,
14
+ var(--cm-selection-background-dark)
15
+ ) !important;
12
16
  }
13
17
 
14
18
  .cm-focused.cm-focused {
@@ -125,7 +129,7 @@
125
129
  /* -- Codeium -- */
126
130
 
127
131
  .cm-codeium-cycle {
128
- font-size: 9px;
132
+ font-size: 0.5625rem;
129
133
  background-color: var(--sky-3);
130
134
  padding: 2px;
131
135
  border-radius: 2px;
@@ -133,7 +137,7 @@
133
137
  }
134
138
 
135
139
  .cm-codeium-cycle-key {
136
- font-size: 9px;
140
+ font-size: 0.5625rem;
137
141
  font-family: monospace;
138
142
  display: inline-block;
139
143
  padding: 2px;
@@ -107,6 +107,14 @@
107
107
  /* Codemirror editor */
108
108
  --cm-background: light-dark(var(--background), #282c34);
109
109
  --cm-comment: light-dark(#708090, #6b7280);
110
+ --cm-selection-background-light: #d7d4f0;
111
+ --cm-selection-background-dark: #1177cc80;
112
+ --cm-reactive-reference-color-light: #005f87;
113
+ --cm-reactive-reference-color-dark: #2a7aa5;
114
+ --cm-reactive-reference-border-color: #bad3de;
115
+ --cm-search-match-background-light: #99ff7780;
116
+ --cm-search-match-background-dark: #22bb0070;
117
+ --cm-search-match-selected-background-dark: #6199ff88;
110
118
  }
111
119
 
112
120
  .dark,
@@ -4,11 +4,14 @@ import { python } from "@codemirror/lang-python";
4
4
  import { EditorState, type Extension } from "@codemirror/state";
5
5
  import { EditorView } from "@codemirror/view";
6
6
  import type { Meta, StoryObj } from "@storybook/react-vite";
7
- import React, { useEffect, useRef } from "react";
7
+ import React, { useEffect, useRef, useState } from "react";
8
+ import { ReadonlyCode } from "../components/editor/code/readonly-python-code";
8
9
  import { basicBundle, type CodeMirrorSetupOpts } from "../core/codemirror/cm";
10
+ import { userConfigAtom } from "../core/config/config";
9
11
  import { darkTheme } from "../core/codemirror/theme/dark";
10
12
  import { lightTheme } from "../core/codemirror/theme/light";
11
13
  import { OverridingHotkeyProvider } from "../core/hotkeys/hotkeys";
14
+ import { store } from "../core/state/jotai";
12
15
 
13
16
  // Partial config for storybook demo
14
17
  const demoConfig: Partial<CodeMirrorSetupOpts> = {
@@ -52,6 +55,11 @@ result = example.get_value()
52
55
  size = example.data_size
53
56
  `.trim();
54
57
 
58
+ const READONLY_CONTENT = Array.from(
59
+ { length: 80 },
60
+ (_, index) => `value_${index + 1} = ${index + 1}`,
61
+ ).join("\n");
62
+
55
63
  const Editor = (opts: { extensions?: Extension[] }): React.ReactNode => {
56
64
  const ref = useRef<HTMLDivElement>(null);
57
65
  useEffect(() => {
@@ -110,3 +118,65 @@ export const ThemeComparison: Story = {
110
118
  </div>
111
119
  ),
112
120
  };
121
+
122
+ const ReadonlyCodeThemeFixture = (): React.ReactNode => {
123
+ const [theme, setTheme] = useState<"light" | "dark">("light");
124
+ const [initialTheme] = useState(
125
+ () => store.get(userConfigAtom).display.theme,
126
+ );
127
+
128
+ useEffect(() => {
129
+ store.set(userConfigAtom, (config) => ({
130
+ ...config,
131
+ display: { ...config.display, theme },
132
+ }));
133
+ }, [theme]);
134
+
135
+ useEffect(() => {
136
+ // Leave Storybook's theme in the state it had before this fixture mounted.
137
+ return () => {
138
+ store.set(userConfigAtom, (config) => ({
139
+ ...config,
140
+ display: { ...config.display, theme: initialTheme },
141
+ }));
142
+ };
143
+ }, [initialTheme]);
144
+
145
+ return (
146
+ <div className="max-w-3xl space-y-3" style={{ colorScheme: theme }}>
147
+ <div className="flex items-center justify-between gap-4">
148
+ <div>
149
+ <h3 className="text-lg font-semibold">ReadonlyCode fixture</h3>
150
+ <p className="text-sm text-muted-foreground">
151
+ Select text, scroll, then switch themes to check that both remain.
152
+ </p>
153
+ </div>
154
+ <div className="flex gap-2" role="group" aria-label="Theme">
155
+ {(["light", "dark"] as const).map((nextTheme) => (
156
+ <button
157
+ aria-pressed={theme === nextTheme}
158
+ className="rounded border px-3 py-1 text-sm"
159
+ key={nextTheme}
160
+ onClick={() => setTheme(nextTheme)}
161
+ type="button"
162
+ >
163
+ {nextTheme}
164
+ </button>
165
+ ))}
166
+ </div>
167
+ </div>
168
+ <div className="h-64 overflow-hidden rounded border">
169
+ <ReadonlyCode
170
+ className="h-full [&_.cm-editor]:h-full [&_.cm-scroller]:h-full [&_.cm-theme-none]:h-full"
171
+ code={READONLY_CONTENT}
172
+ showCopyCode={false}
173
+ showHideCode={false}
174
+ />
175
+ </div>
176
+ </div>
177
+ );
178
+ };
179
+
180
+ export const ReadonlyCodeThemeReconfiguration: Story = {
181
+ render: () => <ReadonlyCodeThemeFixture />,
182
+ };