@marimo-team/islands 0.23.3-dev9 → 0.23.3

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 (42) hide show
  1. package/dist/{chat-ui-BLFhPclV.js → chat-ui-DEd_Ndal.js} +82 -82
  2. package/dist/{html-to-image-XYwXqg2E.js → html-to-image-DBosi5GK.js} +2240 -2214
  3. package/dist/main.js +2627 -2746
  4. package/dist/{process-output-BDVjDpbu.js → process-output-k-4WHpxz.js} +1 -1
  5. package/dist/{reveal-component-CrnLosc4.js → reveal-component-CFuofbBD.js} +827 -561
  6. package/dist/{slide-Dl7Rf496.js → slide-form-DgMI37ES.js} +1729 -894
  7. package/dist/style.css +1 -1
  8. package/package.json +1 -1
  9. package/src/components/editor/file-tree/renderers.tsx +1 -1
  10. package/src/components/editor/output/JsonOutput.tsx +187 -4
  11. package/src/components/editor/output/__tests__/JsonOutput-mimetype.test.tsx +80 -0
  12. package/src/components/editor/output/__tests__/json-output.test.ts +185 -2
  13. package/src/components/editor/renderers/slides-layout/__tests__/compute-slide-cells.test.ts +150 -0
  14. package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +298 -0
  15. package/src/components/editor/renderers/slides-layout/compute-slide-cells.ts +50 -0
  16. package/src/components/editor/renderers/slides-layout/plugin.tsx +54 -9
  17. package/src/components/editor/renderers/slides-layout/slides-layout.tsx +30 -12
  18. package/src/components/editor/renderers/slides-layout/types.ts +31 -3
  19. package/src/components/editor/renderers/types.ts +2 -0
  20. package/src/components/slides/__tests__/compose-slides.test.ts +433 -0
  21. package/src/components/slides/compose-slides.ts +337 -0
  22. package/src/components/slides/minimap.tsx +133 -12
  23. package/src/components/slides/reveal-component.tsx +337 -74
  24. package/src/components/slides/reveal-slides.css +33 -1
  25. package/src/components/slides/slide-form.tsx +347 -0
  26. package/src/components/ui/radio-group.tsx +5 -3
  27. package/src/core/cells/types.ts +2 -0
  28. package/src/core/islands/__tests__/bridge.test.ts +116 -5
  29. package/src/core/islands/bridge.ts +5 -1
  30. package/src/core/layout/layout.ts +6 -2
  31. package/src/core/static/__tests__/export-context.test.ts +122 -0
  32. package/src/core/static/__tests__/static-state.test.ts +80 -0
  33. package/src/core/static/export-context.ts +84 -0
  34. package/src/core/static/static-state.ts +44 -6
  35. package/src/plugins/core/RenderHTML.tsx +23 -2
  36. package/src/plugins/core/__test__/RenderHTML.test.ts +86 -1
  37. package/src/plugins/core/__test__/trusted-url.test.ts +130 -18
  38. package/src/plugins/core/sanitize.ts +11 -5
  39. package/src/plugins/core/trusted-url.ts +32 -10
  40. package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +29 -1
  41. package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +34 -0
  42. package/src/plugins/impl/panel/__tests__/PanelPlugin.test.ts +35 -2
@@ -0,0 +1,347 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import {
4
+ EyeOffIcon,
5
+ LayoutTemplateIcon,
6
+ type LucideIcon,
7
+ Rows2Icon,
8
+ CookieIcon,
9
+ PanelRightCloseIcon,
10
+ PanelRightOpenIcon,
11
+ } from "lucide-react";
12
+ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
13
+ import {
14
+ Select,
15
+ SelectContent,
16
+ SelectItem,
17
+ SelectTrigger,
18
+ SelectValue,
19
+ } from "@/components/ui/select";
20
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
21
+ import type { CellId } from "@/core/cells/ids";
22
+ import { cn } from "@/utils/cn";
23
+ import type {
24
+ DeckTransition,
25
+ SlidesLayout,
26
+ SlideType,
27
+ } from "../editor/renderers/slides-layout/types";
28
+ import { useState } from "react";
29
+ import { Tooltip } from "../ui/tooltip";
30
+ import { Button } from "../ui/button";
31
+ import type { RuntimeCell } from "@/core/cells/types";
32
+
33
+ export const DEFAULT_SLIDE_TYPE: SlideType = "slide";
34
+ export const DEFAULT_DECK_TRANSITION: DeckTransition = "slide";
35
+ const COLLAPSED_CONFIG_WIDTH = 36;
36
+
37
+ export interface SlideTypeOption {
38
+ value: SlideType;
39
+ label: string;
40
+ description: string;
41
+ Icon: LucideIcon;
42
+ }
43
+
44
+ export const SLIDE_TYPE_OPTIONS: readonly SlideTypeOption[] = [
45
+ {
46
+ value: "slide",
47
+ label: "Slide",
48
+ description:
49
+ "A new top-level slide. Advances horizontally with the right arrow.",
50
+ Icon: LayoutTemplateIcon,
51
+ },
52
+ {
53
+ value: "sub-slide",
54
+ label: "Sub-slide",
55
+ description:
56
+ "Stacks vertically under the previous slide. Reached with the down arrow.",
57
+ Icon: Rows2Icon,
58
+ },
59
+ {
60
+ value: "fragment",
61
+ label: "Fragment",
62
+ description: "Reveals step-by-step on the current slide without advancing.",
63
+ Icon: CookieIcon,
64
+ },
65
+ {
66
+ value: "skip",
67
+ label: "Skip",
68
+ description:
69
+ "Hidden from the presentation. Still visible here in the editor.",
70
+ Icon: EyeOffIcon,
71
+ },
72
+ ];
73
+
74
+ /**
75
+ * Lookup form of {@link SLIDE_TYPE_OPTIONS} for O(1) access by `SlideType`.
76
+ */
77
+ export const SLIDE_TYPE_OPTIONS_BY_VALUE: Readonly<
78
+ Record<SlideType, SlideTypeOption>
79
+ > = Object.fromEntries(
80
+ SLIDE_TYPE_OPTIONS.map((option) => [option.value, option]),
81
+ ) as Record<SlideType, SlideTypeOption>;
82
+
83
+ interface DeckTransitionOption {
84
+ value: DeckTransition;
85
+ label: string;
86
+ description: string;
87
+ }
88
+
89
+ const DECK_TRANSITION_OPTIONS: DeckTransitionOption[] = [
90
+ { value: "none", label: "None", description: "No animation between slides." },
91
+ { value: "fade", label: "Fade", description: "Cross-fade between slides." },
92
+ {
93
+ value: "slide",
94
+ label: "Slide",
95
+ description: "Slides move horizontally / vertically.",
96
+ },
97
+ {
98
+ value: "convex",
99
+ label: "Convex",
100
+ description: "Rotate with a convex curve.",
101
+ },
102
+ {
103
+ value: "concave",
104
+ label: "Concave",
105
+ description: "Rotate with a concave curve.",
106
+ },
107
+ { value: "zoom", label: "Zoom", description: "Zoom into the next slide." },
108
+ ];
109
+
110
+ const SlidesForm = ({
111
+ layout,
112
+ setLayout,
113
+ cellId,
114
+ }: {
115
+ layout: SlidesLayout;
116
+ setLayout: (layout: SlidesLayout) => void;
117
+ cellId: CellId;
118
+ }) => {
119
+ return (
120
+ <Tabs defaultValue="slide" className="flex flex-col flex-1 p-3 gap-3">
121
+ <TabsList className="grid grid-cols-2">
122
+ <TabsTrigger value="slide">Slide</TabsTrigger>
123
+ <TabsTrigger value="deck">Deck</TabsTrigger>
124
+ </TabsList>
125
+ <TabsContent value="slide" className="mt-0 flex-1">
126
+ <SlideConfigForm
127
+ layout={layout}
128
+ setLayout={setLayout}
129
+ cellId={cellId}
130
+ />
131
+ </TabsContent>
132
+ <TabsContent value="deck" className="mt-0 flex-1">
133
+ <DeckConfigForm layout={layout} setLayout={setLayout} />
134
+ </TabsContent>
135
+ </Tabs>
136
+ );
137
+ };
138
+
139
+ const SlideConfigForm = ({
140
+ layout,
141
+ setLayout,
142
+ cellId,
143
+ }: {
144
+ layout: SlidesLayout;
145
+ setLayout: (layout: SlidesLayout) => void;
146
+ cellId: CellId;
147
+ }) => {
148
+ const currentSlideType: SlideType =
149
+ layout.cells.get(cellId)?.type ?? DEFAULT_SLIDE_TYPE;
150
+
151
+ const handleSlideTypeChange = (value: SlideType) => {
152
+ const existingConfig = layout.cells.get(cellId);
153
+ const newCells = new Map(layout.cells);
154
+ newCells.set(cellId, { ...existingConfig, type: value });
155
+ setLayout({
156
+ ...layout,
157
+ cells: newCells,
158
+ });
159
+ };
160
+
161
+ return (
162
+ <div className="flex flex-col gap-3">
163
+ <span className="font-semibold text-sm">Slide type</span>
164
+ <RadioGroup
165
+ aria-label="Slide type"
166
+ value={currentSlideType}
167
+ onValueChange={(value) => handleSlideTypeChange(value as SlideType)}
168
+ className="flex flex-col gap-1.5"
169
+ >
170
+ {SLIDE_TYPE_OPTIONS.map(({ value, label, description, Icon }) => {
171
+ const isSelected = currentSlideType === value;
172
+ return (
173
+ <RadioGroupItem
174
+ key={value}
175
+ value={value}
176
+ className={cn(
177
+ "group h-auto w-full text-left rounded-md p-2.5 transition-colors shadow-none! border",
178
+ "focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
179
+ isSelected
180
+ ? "border-primary bg-primary/5"
181
+ : "border-border bg-background hover:bg-accent/50 hover:border-foreground/30",
182
+ )}
183
+ >
184
+ <div className="flex items-start gap-2.5">
185
+ <span
186
+ className={cn(
187
+ "mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded",
188
+ isSelected
189
+ ? "bg-primary/10 text-primary"
190
+ : "bg-muted text-muted-foreground group-hover:text-foreground",
191
+ )}
192
+ >
193
+ <Icon className="h-3.5 w-3.5" />
194
+ </span>
195
+ <div>
196
+ <p
197
+ className={cn(
198
+ "text-sm font-medium leading-tight",
199
+ isSelected ? "text-primary" : "text-foreground",
200
+ )}
201
+ >
202
+ {label}
203
+ </p>
204
+ <p className="mt-0.5 text-xs text-foreground/70">
205
+ {description}
206
+ </p>
207
+ </div>
208
+ </div>
209
+ </RadioGroupItem>
210
+ );
211
+ })}
212
+ </RadioGroup>
213
+ </div>
214
+ );
215
+ };
216
+
217
+ const DeckConfigForm = ({
218
+ layout,
219
+ setLayout,
220
+ }: {
221
+ layout: SlidesLayout;
222
+ setLayout: (layout: SlidesLayout) => void;
223
+ }) => {
224
+ const currentTransition: DeckTransition =
225
+ layout.deck?.transition ?? DEFAULT_DECK_TRANSITION;
226
+ const activeDescription = DECK_TRANSITION_OPTIONS.find(
227
+ (opt) => opt.value === currentTransition,
228
+ )?.description;
229
+
230
+ const handleTransitionChange = (value: DeckTransition) => {
231
+ setLayout({
232
+ ...layout,
233
+ deck: { ...layout.deck, transition: value },
234
+ });
235
+ };
236
+
237
+ return (
238
+ <div className="flex flex-col gap-3">
239
+ <div className="flex flex-col gap-1.5">
240
+ <label
241
+ htmlFor="deck-transition"
242
+ className="font-semibold text-sm text-foreground"
243
+ >
244
+ Transition
245
+ </label>
246
+ <Select
247
+ value={currentTransition}
248
+ onValueChange={(value) =>
249
+ handleTransitionChange(value as DeckTransition)
250
+ }
251
+ >
252
+ <SelectTrigger id="deck-transition" aria-label="Slide transition">
253
+ <SelectValue />
254
+ </SelectTrigger>
255
+ <SelectContent>
256
+ {DECK_TRANSITION_OPTIONS.map(({ value, label }) => (
257
+ <SelectItem key={value} value={value}>
258
+ {label}
259
+ </SelectItem>
260
+ ))}
261
+ </SelectContent>
262
+ </Select>
263
+ {activeDescription && (
264
+ <p className="text-xs text-foreground/70">{activeDescription}</p>
265
+ )}
266
+ </div>
267
+ </div>
268
+ );
269
+ };
270
+
271
+ export const SlideSidebar = ({
272
+ configWidth,
273
+ layout,
274
+ setLayout,
275
+ activeConfigCell,
276
+ }: {
277
+ configWidth: number;
278
+ layout: SlidesLayout;
279
+ setLayout: (layout: SlidesLayout) => void;
280
+ activeConfigCell?: RuntimeCell;
281
+ }) => {
282
+ const [isConfigOpen, setIsConfigOpen] = useState(false);
283
+
284
+ return (
285
+ <aside
286
+ className="h-full flex flex-col border-l border-border/60 bg-muted/20 transition-[width] duration-200 ease-out overflow-hidden"
287
+ style={{
288
+ width: isConfigOpen ? configWidth : COLLAPSED_CONFIG_WIDTH,
289
+ }}
290
+ aria-label="Slide configuration"
291
+ >
292
+ <header
293
+ className={cn(
294
+ "flex items-center h-9 shrink-0 border-b border-border/60",
295
+ isConfigOpen ? "justify-between px-2" : "justify-center px-0",
296
+ )}
297
+ >
298
+ {isConfigOpen && (
299
+ <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground pl-1">
300
+ Configuration
301
+ </span>
302
+ )}
303
+ <Tooltip content={isConfigOpen ? "Collapse panel" : "Expand panel"}>
304
+ <Button
305
+ variant="ghost"
306
+ size="icon"
307
+ className="h-7 w-7 text-muted-foreground hover:text-foreground"
308
+ onClick={() => setIsConfigOpen(!isConfigOpen)}
309
+ aria-expanded={isConfigOpen}
310
+ aria-controls="slide-config-panel"
311
+ >
312
+ {isConfigOpen ? (
313
+ <PanelRightCloseIcon className="h-4 w-4" />
314
+ ) : (
315
+ <PanelRightOpenIcon className="h-4 w-4" />
316
+ )}
317
+ </Button>
318
+ </Tooltip>
319
+ </header>
320
+
321
+ {isConfigOpen && (
322
+ <div
323
+ id="slide-config-panel"
324
+ className="flex-1 overflow-y-auto overflow-x-hidden"
325
+ >
326
+ {activeConfigCell ? (
327
+ <SlidesForm
328
+ layout={layout}
329
+ setLayout={setLayout}
330
+ cellId={activeConfigCell.id}
331
+ />
332
+ ) : (
333
+ <div className="flex flex-col gap-1.5 p-3 text-xs text-muted-foreground">
334
+ <span className="font-semibold text-sm text-foreground">
335
+ No slides yet
336
+ </span>
337
+ <p>
338
+ Run a cell that produces output to add it to the deck. Slide
339
+ settings will appear here once a slide is selected.
340
+ </p>
341
+ </div>
342
+ )}
343
+ </div>
344
+ )}
345
+ </aside>
346
+ );
347
+ };
@@ -33,9 +33,11 @@ const RadioGroupItem = React.forwardRef<
33
33
  )}
34
34
  {...props}
35
35
  >
36
- <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
37
- <Circle className="h-[10px] w-[10px] fill-primary text-current" />
38
- </RadioGroupPrimitive.Indicator>
36
+ {children ?? (
37
+ <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
38
+ <Circle className="h-[10px] w-[10px] fill-primary text-current" />
39
+ </RadioGroupPrimitive.Indicator>
40
+ )}
39
41
  </RadioGroupPrimitive.Item>
40
42
  );
41
43
  });
@@ -119,6 +119,8 @@ export interface CellRuntimeState {
119
119
  serialization?: string | null;
120
120
  }
121
121
 
122
+ export type RuntimeCell = CellRuntimeState & CellData;
123
+
122
124
  export type WithResponse<T> = T & {
123
125
  /**
124
126
  * This is not saved to the server, but we update this field
@@ -10,6 +10,14 @@ import {
10
10
  } from "@/__tests__/branded";
11
11
 
12
12
  type Base64String = components["schemas"]["Base64String"];
13
+ interface TestIslandApp {
14
+ id: string;
15
+ cells: { code: string; idx: number; output: string }[];
16
+ }
17
+ interface TestExportContext {
18
+ trusted: true;
19
+ notebookCode?: string;
20
+ }
13
21
 
14
22
  // Mock browser APIs before any imports
15
23
  vi.stubGlobal(
@@ -33,8 +41,23 @@ class MockURL {
33
41
  vi.stubGlobal("URL", MockURL);
34
42
 
35
43
  // Mock the worker RPC before importing the bridge
36
- const mockBridge = vi.fn();
37
- const mockLoadPackages = vi.fn();
44
+ const {
45
+ mockBridge,
46
+ mockLoadPackages,
47
+ mockStartSessionRequest,
48
+ mockParseMarimoIslandApps,
49
+ mockCreateMarimoFile,
50
+ mockGetMarimoExportContext,
51
+ } = vi.hoisted(() => ({
52
+ mockBridge: vi.fn(),
53
+ mockLoadPackages: vi.fn(),
54
+ mockStartSessionRequest: vi.fn(),
55
+ mockParseMarimoIslandApps: vi.fn<() => TestIslandApp[]>(() => []),
56
+ mockCreateMarimoFile: vi.fn(),
57
+ mockGetMarimoExportContext: vi.fn<() => TestExportContext | undefined>(
58
+ () => undefined,
59
+ ),
60
+ }));
38
61
 
39
62
  vi.mock("@/core/wasm/rpc", () => ({
40
63
  getWorkerRPC: () => ({
@@ -42,7 +65,7 @@ vi.mock("@/core/wasm/rpc", () => ({
42
65
  request: {
43
66
  bridge: mockBridge,
44
67
  loadPackages: mockLoadPackages,
45
- startSession: vi.fn(),
68
+ startSession: mockStartSessionRequest,
46
69
  },
47
70
  send: {
48
71
  consumerReady: vi.fn(),
@@ -54,8 +77,8 @@ vi.mock("@/core/wasm/rpc", () => ({
54
77
 
55
78
  // Mock the parse module to avoid DOM dependencies
56
79
  vi.mock("../parse", () => ({
57
- parseMarimoIslandApps: () => [],
58
- createMarimoFile: vi.fn(),
80
+ parseMarimoIslandApps: mockParseMarimoIslandApps,
81
+ createMarimoFile: mockCreateMarimoFile,
59
82
  }));
60
83
 
61
84
  // Mock uuid to have predictable tokens
@@ -63,6 +86,10 @@ vi.mock("@/utils/uuid", () => ({
63
86
  generateUUID: () => "test-uuid-12345",
64
87
  }));
65
88
 
89
+ vi.mock("@/core/static/export-context", () => ({
90
+ getMarimoExportContext: mockGetMarimoExportContext,
91
+ }));
92
+
66
93
  // Mock getMarimoVersion
67
94
  vi.mock("@/core/meta/globals", () => ({
68
95
  getMarimoVersion: () => "0.0.0-test",
@@ -71,6 +98,7 @@ vi.mock("@/core/meta/globals", () => ({
71
98
  // Mock the jotai store
72
99
  vi.mock("@/core/state/jotai", () => ({
73
100
  store: {
101
+ get: vi.fn(),
74
102
  set: vi.fn(),
75
103
  },
76
104
  }));
@@ -83,9 +111,92 @@ describe("IslandsPyodideBridge", () => {
83
111
 
84
112
  beforeEach(() => {
85
113
  vi.clearAllMocks();
114
+ mockParseMarimoIslandApps.mockReturnValue([]);
115
+ mockCreateMarimoFile.mockReset();
116
+ mockGetMarimoExportContext.mockReturnValue(undefined);
86
117
  bridge = new IslandsPyodideBridge({ autoStartSessions: false });
87
118
  });
88
119
 
120
+ describe("startSessionsForAllApps", () => {
121
+ it("should prefer trusted export notebook code when there is exactly one reactive app", async () => {
122
+ mockParseMarimoIslandApps.mockReturnValue([
123
+ {
124
+ id: "app-1",
125
+ cells: [{ code: "x = 1", idx: 0, output: "<div>1</div>" }],
126
+ },
127
+ ]);
128
+ mockGetMarimoExportContext.mockReturnValue({
129
+ trusted: true,
130
+ notebookCode:
131
+ "import marimo\napp = marimo.App()\n@app.cell\ndef __():\n x = 1\n return",
132
+ });
133
+
134
+ await (
135
+ bridge as unknown as { startSessionsForAllApps(): Promise<void> }
136
+ ).startSessionsForAllApps();
137
+
138
+ expect(mockCreateMarimoFile).not.toHaveBeenCalled();
139
+ expect(mockStartSessionRequest).toHaveBeenCalledWith({
140
+ appId: "app-1",
141
+ code: "import marimo\napp = marimo.App()\n@app.cell\ndef __():\n x = 1\n return",
142
+ });
143
+ });
144
+
145
+ it("should keep synthesized per-app files for multiple reactive apps even when export context exists", async () => {
146
+ mockParseMarimoIslandApps.mockReturnValue([
147
+ {
148
+ id: "app-1",
149
+ cells: [{ code: "x = 1", idx: 0, output: "<div>1</div>" }],
150
+ },
151
+ {
152
+ id: "app-2",
153
+ cells: [{ code: "y = 2", idx: 0, output: "<div>2</div>" }],
154
+ },
155
+ ]);
156
+ mockGetMarimoExportContext.mockReturnValue({
157
+ trusted: true,
158
+ notebookCode: "full notebook should be ignored",
159
+ });
160
+ mockCreateMarimoFile
161
+ .mockReturnValueOnce("generated app 1")
162
+ .mockReturnValueOnce("generated app 2");
163
+
164
+ await (
165
+ bridge as unknown as { startSessionsForAllApps(): Promise<void> }
166
+ ).startSessionsForAllApps();
167
+
168
+ expect(mockCreateMarimoFile).toHaveBeenCalledTimes(2);
169
+ expect(mockStartSessionRequest).toHaveBeenNthCalledWith(1, {
170
+ appId: "app-1",
171
+ code: "generated app 1",
172
+ });
173
+ expect(mockStartSessionRequest).toHaveBeenNthCalledWith(2, {
174
+ appId: "app-2",
175
+ code: "generated app 2",
176
+ });
177
+ });
178
+
179
+ it("should synthesize a file for a single app when no trusted export context is present", async () => {
180
+ mockParseMarimoIslandApps.mockReturnValue([
181
+ {
182
+ id: "app-1",
183
+ cells: [{ code: "x = 1", idx: 0, output: "<div>1</div>" }],
184
+ },
185
+ ]);
186
+ mockCreateMarimoFile.mockReturnValue("generated app 1");
187
+
188
+ await (
189
+ bridge as unknown as { startSessionsForAllApps(): Promise<void> }
190
+ ).startSessionsForAllApps();
191
+
192
+ expect(mockCreateMarimoFile).toHaveBeenCalledTimes(1);
193
+ expect(mockStartSessionRequest).toHaveBeenCalledWith({
194
+ appId: "app-1",
195
+ code: "generated app 1",
196
+ });
197
+ });
198
+ });
199
+
89
200
  describe("sendComponentValues", () => {
90
201
  it("should include type field and token in control request", async () => {
91
202
  const request = {
@@ -10,6 +10,7 @@ import { generateUUID } from "@/utils/uuid";
10
10
  import type { CommandMessage, NotificationPayload } from "../kernel/messages";
11
11
  import type { EditRequests, RunRequests } from "../network/types";
12
12
  import { store as defaultStore } from "../state/jotai";
13
+ import { getMarimoExportContext } from "../static/export-context";
13
14
  import { createMarimoFile, parseMarimoIslandApps } from "./parse";
14
15
  import { islandsInitializedAtom } from "./state";
15
16
  import type { WorkerSchema } from "./worker/worker";
@@ -123,8 +124,11 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
123
124
  `Starting sessions for ${apps.length} app(s):`,
124
125
  apps.map((a) => `${a.id} (${a.cells.length} cells)`),
125
126
  );
127
+ const exportContext =
128
+ apps.length === 1 ? getMarimoExportContext() : undefined;
129
+ const notebookCode = exportContext?.notebookCode;
126
130
  for (const app of apps) {
127
- const file = createMarimoFile(app);
131
+ const file = notebookCode || createMarimoFile(app);
128
132
  Logger.debug(`App ${app.id} marimo file:\n`, file);
129
133
  this.startSession({
130
134
  code: file,
@@ -84,7 +84,6 @@ export function getSerializedLayout() {
84
84
  return null;
85
85
  }
86
86
 
87
- const data = layoutData[selectedLayout];
88
87
  const plugin = cellRendererPlugins.find(
89
88
  (plugin) => plugin.type === selectedLayout,
90
89
  );
@@ -92,8 +91,13 @@ export function getSerializedLayout() {
92
91
  Logger.error(`Unknown layout type: ${selectedLayout}`);
93
92
  return null;
94
93
  }
94
+ const cells = notebookCells(notebook);
95
+ // Fall back to the plugin's initial layout when the user has not yet
96
+ // interacted with this layout — otherwise serializers that expect a
97
+ // structured layout object crash on `undefined`.
98
+ const data = layoutData[selectedLayout] ?? plugin.getInitialLayout(cells);
95
99
  return {
96
100
  type: selectedLayout,
97
- data: plugin.serializeLayout(data, notebookCells(notebook)),
101
+ data: plugin.serializeLayout(data, cells),
98
102
  };
99
103
  }