@marimo-team/islands 0.23.3-dev9 → 0.23.4-dev0

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,150 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describe, it, expect } from "vitest";
4
+ import { computeSlideCellsInfo } from "../compute-slide-cells";
5
+ import type { SlideConfig, SlidesLayout } from "../types";
6
+ import type { CellId } from "@/core/cells/ids";
7
+
8
+ interface TestCell {
9
+ id: CellId;
10
+ output: { data: unknown } | null;
11
+ }
12
+
13
+ const DEFAULT_OUTPUT: TestCell["output"] = { data: "ok" };
14
+
15
+ const cell = (
16
+ id: string,
17
+ output: TestCell["output"] = DEFAULT_OUTPUT,
18
+ ): TestCell => ({ id: id as CellId, output });
19
+
20
+ const layoutOf = (entries: Array<[string, SlideConfig]>): SlidesLayout => ({
21
+ cells: new Map(entries.map(([id, cfg]) => [id as CellId, cfg])),
22
+ deck: {},
23
+ });
24
+
25
+ describe("computeSlideCellsInfo", () => {
26
+ it("returns empty results for empty input", () => {
27
+ const result = computeSlideCellsInfo([], layoutOf([]));
28
+ expect(result.cellsWithOutput).toEqual([]);
29
+ expect(result.skippedIds.size).toBe(0);
30
+ expect(result.slideTypes.size).toBe(0);
31
+ expect(result.startCellIndex).toBe(0);
32
+ });
33
+
34
+ it("computes firstNonSkippedIndex as the first non-skipped cell", () => {
35
+ const result = computeSlideCellsInfo(
36
+ [cell("a"), cell("b"), cell("c")],
37
+ layoutOf([
38
+ ["a", { type: "skip" }],
39
+ ["b", { type: "skip" }],
40
+ ["c", { type: "slide" }],
41
+ ]),
42
+ );
43
+ expect(result.startCellIndex).toBe(2);
44
+ });
45
+
46
+ it("falls back to 0 when every cell is skipped", () => {
47
+ // If the user marked everything as skip we still have to land somewhere;
48
+ // the renderer treats 0 as a safe default rather than rendering nothing.
49
+ const result = computeSlideCellsInfo(
50
+ [cell("a"), cell("b")],
51
+ layoutOf([
52
+ ["a", { type: "skip" }],
53
+ ["b", { type: "skip" }],
54
+ ]),
55
+ );
56
+ expect(result.startCellIndex).toBe(0);
57
+ });
58
+
59
+ it("uses index 0 when no cells are skipped", () => {
60
+ const result = computeSlideCellsInfo([cell("a"), cell("b")], layoutOf([]));
61
+ expect(result.startCellIndex).toBe(0);
62
+ });
63
+
64
+ it("filters out cells with no output", () => {
65
+ const result = computeSlideCellsInfo(
66
+ [cell("a"), cell("b", null), cell("c")],
67
+ layoutOf([]),
68
+ );
69
+ expect(result.cellsWithOutput.map((c) => c.id)).toEqual(["a", "c"]);
70
+ });
71
+
72
+ it("filters out cells whose output data is empty string", () => {
73
+ // Mirrors the editor contract: an explicit empty-string payload means the
74
+ // cell rendered nothing, so it should not occupy a slide.
75
+ const result = computeSlideCellsInfo(
76
+ [cell("a"), cell("b", { data: "" }), cell("c")],
77
+ layoutOf([]),
78
+ );
79
+ expect(result.cellsWithOutput.map((c) => c.id)).toEqual(["a", "c"]);
80
+ });
81
+
82
+ it("keeps cells whose output data is a non-empty value (including falsy ones)", () => {
83
+ // Only "" is treated as empty — 0 / false / null-shaped payloads still
84
+ // represent rendered output and should stay in the deck.
85
+ const result = computeSlideCellsInfo(
86
+ [
87
+ cell("a", { data: 0 }),
88
+ cell("b", { data: false }),
89
+ cell("c", { data: null }),
90
+ ],
91
+ layoutOf([]),
92
+ );
93
+ expect(result.cellsWithOutput.map((c) => c.id)).toEqual(["a", "b", "c"]);
94
+ });
95
+
96
+ it("populates slideTypes only for cells with an explicit type", () => {
97
+ const result = computeSlideCellsInfo(
98
+ [cell("a"), cell("b"), cell("c")],
99
+ layoutOf([
100
+ ["a", { type: "slide" }],
101
+ ["b", {}],
102
+ ["c", { type: "fragment" }],
103
+ ]),
104
+ );
105
+ expect(Object.fromEntries(result.slideTypes)).toEqual({
106
+ a: "slide",
107
+ c: "fragment",
108
+ });
109
+ });
110
+
111
+ it("tracks skipped cells in skippedIds", () => {
112
+ const result = computeSlideCellsInfo(
113
+ [cell("a"), cell("b"), cell("c")],
114
+ layoutOf([
115
+ ["a", { type: "slide" }],
116
+ ["b", { type: "skip" }],
117
+ ["c", { type: "skip" }],
118
+ ]),
119
+ );
120
+ expect([...result.skippedIds]).toEqual(["b", "c"]);
121
+ // Skipped cells are still "visible" deck cells — they just aren't rendered
122
+ // in reveal. The minimap relies on the full list plus skippedIds.
123
+ expect(result.cellsWithOutput.map((c) => c.id)).toEqual(["a", "b", "c"]);
124
+ expect(result.slideTypes.get("b" as CellId)).toBe("skip");
125
+ });
126
+
127
+ it("ignores layout entries for cells that have no output", () => {
128
+ // If a cell was skipped in the layout but no longer produces output (e.g.
129
+ // the user deleted its code), it should drop out of both maps — otherwise
130
+ // the skip set would reference ghosts.
131
+ const result = computeSlideCellsInfo(
132
+ [cell("a"), cell("b", null)],
133
+ layoutOf([
134
+ ["a", { type: "slide" }],
135
+ ["b", { type: "skip" }],
136
+ ]),
137
+ );
138
+ expect(result.cellsWithOutput.map((c) => c.id)).toEqual(["a"]);
139
+ expect(result.skippedIds.size).toBe(0);
140
+ expect(result.slideTypes.has("b" as CellId)).toBe(false);
141
+ });
142
+
143
+ it("preserves the input order of cells in cellsWithOutput", () => {
144
+ const result = computeSlideCellsInfo(
145
+ [cell("c"), cell("a"), cell("b")],
146
+ layoutOf([]),
147
+ );
148
+ expect(result.cellsWithOutput.map((c) => c.id)).toEqual(["c", "a", "b"]);
149
+ });
150
+ });
@@ -0,0 +1,298 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describe, it, expect } from "vitest";
4
+ import { SlidesLayoutPlugin } from "../plugin";
5
+ import type { CellData } from "@/core/cells/types";
6
+ import type { CellId } from "@/core/cells/ids";
7
+
8
+ function makeCell(id: string, code = "print('hi')"): CellData {
9
+ return {
10
+ id: id as CellId,
11
+ name: id,
12
+ code,
13
+ edited: false,
14
+ lastCodeRun: null,
15
+ lastExecutionTime: null,
16
+ config: { hide_code: false, disabled: false, column: null },
17
+ serializedEditorState: null,
18
+ };
19
+ }
20
+
21
+ describe("SlidesLayoutPlugin validator", () => {
22
+ it("accepts the legacy empty-object shape written by older marimo versions", () => {
23
+ // Backwards compat: any slides file saved before we introduced `cells` /
24
+ // `deck` looks like `{}` on disk. It must still validate.
25
+ expect(SlidesLayoutPlugin.validator.safeParse({}).success).toBe(true);
26
+ });
27
+
28
+ it("accepts a fully populated layout", () => {
29
+ expect(
30
+ SlidesLayoutPlugin.validator.safeParse({
31
+ cells: [{ type: "slide" }, {}],
32
+ deck: { transition: "fade" },
33
+ }).success,
34
+ ).toBe(true);
35
+ });
36
+
37
+ it("rejects unknown slide types", () => {
38
+ expect(
39
+ SlidesLayoutPlugin.validator.safeParse({
40
+ cells: [{ type: "bogus" }],
41
+ }).success,
42
+ ).toBe(false);
43
+ });
44
+ });
45
+
46
+ describe("SlidesLayoutPlugin deserializeLayout", () => {
47
+ it("returns an empty map when serialized.cells is missing", () => {
48
+ // Regression: previously accessed `serialized.cells.length` on undefined
49
+ // and threw, preventing the app from initializing.
50
+ const layout = SlidesLayoutPlugin.deserializeLayout({}, [makeCell("a")]);
51
+ expect(layout.cells.size).toBe(0);
52
+ expect(layout.deck).toEqual({});
53
+ });
54
+
55
+ it("tolerates missing deck", () => {
56
+ const layout = SlidesLayoutPlugin.deserializeLayout(
57
+ { cells: [{ type: "slide" }] },
58
+ [makeCell("a")],
59
+ );
60
+ expect(layout.deck).toEqual({});
61
+ expect(layout.cells.get("a" as CellId)).toEqual({ type: "slide" });
62
+ });
63
+
64
+ it("passes through every serialized entry by position (thin passthrough)", () => {
65
+ const layout = SlidesLayoutPlugin.deserializeLayout(
66
+ // Third entry carries an unknown key to prove the deserializer does not
67
+ // strip fields it does not recognize (forward-compat).
68
+ // oxlint-disable-next-line typescript/no-explicit-any
69
+ { cells: [{}, { type: "fragment" }, { notes: "x" } as any] },
70
+ [makeCell("a"), makeCell("b"), makeCell("c")],
71
+ );
72
+ expect([...layout.cells.keys()]).toEqual(["a", "b", "c"]);
73
+ expect(layout.cells.get("a" as CellId)).toEqual({});
74
+ expect(layout.cells.get("b" as CellId)).toEqual({ type: "fragment" });
75
+ expect(layout.cells.get("c" as CellId)).toEqual({ notes: "x" });
76
+ });
77
+ });
78
+
79
+ describe("SlidesLayoutPlugin serializeLayout", () => {
80
+ it("emits one entry per notebook cell (dense, positional)", () => {
81
+ // Even with no per-cell config, the output array must have one slot per
82
+ // notebook cell so positional alignment is preserved on reload.
83
+ const serialized = SlidesLayoutPlugin.serializeLayout(
84
+ { cells: new Map(), deck: {} },
85
+ [makeCell("a"), makeCell("b", "print(42)")],
86
+ );
87
+ expect(serialized.cells).toEqual([{}, {}]);
88
+ expect(serialized.deck).toEqual({});
89
+ });
90
+
91
+ it("passes user-set SlideConfig fields through unchanged", () => {
92
+ const cells = [makeCell("a"), makeCell("b"), makeCell("c")];
93
+ const serialized = SlidesLayoutPlugin.serializeLayout(
94
+ {
95
+ cells: new Map([["b" as CellId, { type: "fragment" }]]),
96
+ deck: {},
97
+ },
98
+ cells,
99
+ );
100
+ expect(serialized.cells).toHaveLength(3);
101
+ expect(serialized.cells?.[1]).toMatchObject({ type: "fragment" });
102
+ });
103
+
104
+ it("serializes deck config verbatim", () => {
105
+ const serialized = SlidesLayoutPlugin.serializeLayout(
106
+ { cells: new Map(), deck: { transition: "fade" } },
107
+ [makeCell("a")],
108
+ );
109
+ expect(serialized.deck).toEqual({ transition: "fade" });
110
+ });
111
+
112
+ it("passes through unknown SlideConfig fields (forward compat)", () => {
113
+ // When a future version adds a new SlideConfig field, serialize should
114
+ // not need updating. Exercise that by stuffing an arbitrary property
115
+ // into the in-memory config and checking it survives.
116
+ const cells = [makeCell("a")];
117
+ // oxlint-disable-next-line typescript/no-explicit-any
118
+ const futureConfig = { type: "slide", notes: "speaker notes" } as any;
119
+ const serialized = SlidesLayoutPlugin.serializeLayout(
120
+ { cells: new Map([["a" as CellId, futureConfig]]), deck: {} },
121
+ cells,
122
+ );
123
+ expect(serialized.cells?.[0]).toMatchObject({
124
+ type: "slide",
125
+ notes: "speaker notes",
126
+ });
127
+ });
128
+
129
+ it("round-trips user-set config through serialize + deserialize", () => {
130
+ const cells = [makeCell("a"), makeCell("b")];
131
+ const before = {
132
+ cells: new Map([["b" as CellId, { type: "fragment" as const }]]),
133
+ deck: { transition: "fade" as const },
134
+ };
135
+ const serialized = SlidesLayoutPlugin.serializeLayout(before, cells);
136
+ const after = SlidesLayoutPlugin.deserializeLayout(serialized, cells);
137
+ expect(after.deck).toEqual({ transition: "fade" });
138
+ expect(after.cells.get("b" as CellId)).toMatchObject({ type: "fragment" });
139
+ });
140
+ });
141
+
142
+ /**
143
+ * Frozen snapshots of every on-disk shape this plugin has ever had to parse.
144
+ *
145
+ * RULES FOR THIS BLOCK:
146
+ * - Never delete or modify an existing snapshot — users have `.slides.json`
147
+ * files in these exact shapes sitting on their disks.
148
+ * - When the on-disk shape evolves, add a new snapshot here (with the release
149
+ * / commit that introduced it) so we keep proving we can load the old ones.
150
+ * - If you need to change the serializer's output, add a snapshot of the new
151
+ * shape to `serializedSnapshot` so a diff will surface the change in review.
152
+ *
153
+ * Every snapshot is required to:
154
+ * 1. pass the zod validator (today it is defined but not applied on load;
155
+ * this guards against regressions once it is),
156
+ * 2. deserialize without throwing,
157
+ * 3. survive a deserialize → serialize → deserialize round trip without
158
+ * throwing or losing any user-set field carried in the expectations.
159
+ */
160
+ interface BackwardsCompatCase {
161
+ label: string;
162
+ input: unknown;
163
+ /**
164
+ * Expected state after deserializing `input` against `cells`. Only asserted
165
+ * properties need to be listed — extra properties are ignored.
166
+ */
167
+ expected: {
168
+ deck?: unknown;
169
+ cellIds: string[];
170
+ cellEntries?: Array<[string, unknown]>;
171
+ };
172
+ }
173
+
174
+ const BACKWARDS_COMPAT_SNAPSHOTS: BackwardsCompatCase[] = [
175
+ {
176
+ // Shape written by every marimo version before we added per-slide config.
177
+ label: "legacy bare {} (pre-slide-config)",
178
+ input: {},
179
+ expected: { deck: {}, cellIds: [] },
180
+ },
181
+ {
182
+ // Current serializer output as of this commit. If you change the
183
+ // serializer, add the new shape as an additional snapshot — don't edit
184
+ // this one.
185
+ label: "current: cells + deck",
186
+ input: {
187
+ cells: [{ type: "slide" }, {}, { type: "fragment" }],
188
+ deck: { transition: "slide" },
189
+ },
190
+ expected: {
191
+ deck: { transition: "slide" },
192
+ cellIds: ["a", "b", "c"],
193
+ cellEntries: [
194
+ ["a", { type: "slide" }],
195
+ ["c", { type: "fragment" }],
196
+ ],
197
+ },
198
+ },
199
+ {
200
+ // Defensive: if a future version adds a new SlideConfig field and a user
201
+ // downgrades, we must not crash on unknown keys.
202
+ label: "forward-compat: unknown SlideConfig fields present",
203
+ input: {
204
+ cells: [{ type: "slide", notes: "x", background: "#000" }],
205
+ },
206
+ expected: {
207
+ deck: {},
208
+ cellIds: ["a"],
209
+ cellEntries: [["a", { type: "slide" }]],
210
+ },
211
+ },
212
+ ];
213
+
214
+ describe("SlidesLayoutPlugin backwards compatibility", () => {
215
+ it.each(BACKWARDS_COMPAT_SNAPSHOTS)(
216
+ "loads snapshot: $label",
217
+ ({ input, expected }) => {
218
+ const cells = expected.cellIds.map((id) => makeCell(id));
219
+
220
+ // 1. Validator must accept the shape.
221
+ const parsed = SlidesLayoutPlugin.validator.safeParse(input);
222
+ expect(
223
+ parsed.success,
224
+ `validator rejected: ${JSON.stringify(input)}`,
225
+ ).toBe(true);
226
+
227
+ // 2. Deserialize must succeed and reflect the user-set fields.
228
+ const layout = SlidesLayoutPlugin.deserializeLayout(
229
+ // Use the raw input (not the validator output) because that is what
230
+ // `deserializeLayout` actually receives in production today.
231
+ // oxlint-disable-next-line typescript/no-explicit-any
232
+ input as any,
233
+ cells,
234
+ );
235
+ if (expected.deck !== undefined) {
236
+ expect(layout.deck).toEqual(expected.deck);
237
+ }
238
+ for (const [cellId, expectedConfig] of expected.cellEntries ?? []) {
239
+ expect(layout.cells.get(cellId as CellId)).toMatchObject(
240
+ expectedConfig as object,
241
+ );
242
+ }
243
+
244
+ // 3. Round trip must not throw and must preserve the same user fields.
245
+ const reserialized = SlidesLayoutPlugin.serializeLayout(layout, cells);
246
+ expect(
247
+ SlidesLayoutPlugin.validator.safeParse(reserialized).success,
248
+ `serializer produced a shape that no longer validates: ${JSON.stringify(reserialized)}`,
249
+ ).toBe(true);
250
+ const redeserialized = SlidesLayoutPlugin.deserializeLayout(
251
+ reserialized,
252
+ cells,
253
+ );
254
+ for (const [cellId, expectedConfig] of expected.cellEntries ?? []) {
255
+ expect(redeserialized.cells.get(cellId as CellId)).toMatchObject(
256
+ expectedConfig as object,
257
+ );
258
+ }
259
+ if (expected.deck !== undefined) {
260
+ expect(redeserialized.deck).toEqual(expected.deck);
261
+ }
262
+ },
263
+ );
264
+
265
+ it("current serializer output is captured by an inline snapshot", () => {
266
+ // If this snapshot changes you are changing the on-disk shape. That is
267
+ // allowed, but:
268
+ // 1. Add the previous shape to BACKWARDS_COMPAT_SNAPSHOTS above so we
269
+ // keep proving we can still load it.
270
+ // 2. Update this inline snapshot.
271
+ const cells = [makeCell("a", "print('hello')"), makeCell("b", "x = 1")];
272
+ const serialized = SlidesLayoutPlugin.serializeLayout(
273
+ {
274
+ cells: new Map([
275
+ ["a" as CellId, { type: "slide" }],
276
+ ["b" as CellId, { type: "fragment" }],
277
+ ]),
278
+ deck: { transition: "fade" },
279
+ },
280
+ cells,
281
+ );
282
+ expect(serialized).toMatchInlineSnapshot(`
283
+ {
284
+ "cells": [
285
+ {
286
+ "type": "slide",
287
+ },
288
+ {
289
+ "type": "fragment",
290
+ },
291
+ ],
292
+ "deck": {
293
+ "transition": "fade",
294
+ },
295
+ }
296
+ `);
297
+ });
298
+ });
@@ -0,0 +1,50 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { CellId } from "@/core/cells/ids";
4
+ import type { SlideType, SlidesLayout } from "./types";
5
+
6
+ export interface SlideCellLike {
7
+ id: CellId;
8
+ output: { data: unknown } | null;
9
+ }
10
+
11
+ export interface SlideCellsInfo<T extends SlideCellLike> {
12
+ cellsWithOutput: T[];
13
+ skippedIds: Set<CellId>;
14
+ slideTypes: Map<CellId, SlideType>;
15
+ // Index of the first cell in `cellsWithOutput` that is not skipped
16
+ startCellIndex: number;
17
+ }
18
+
19
+ export function computeSlideCellsInfo<T extends SlideCellLike>(
20
+ cells: readonly T[],
21
+ layout: Pick<SlidesLayout, "cells">,
22
+ ): SlideCellsInfo<T> {
23
+ const cellsWithOutput = cells.filter(
24
+ (cell) => cell.output != null && cell.output.data !== "",
25
+ );
26
+ const skippedIds = new Set<CellId>();
27
+ const slideTypes = new Map<CellId, SlideType>();
28
+
29
+ let startCell: T | null = null;
30
+ let startCellIndex = 0;
31
+
32
+ for (const [index, cell] of cellsWithOutput.entries()) {
33
+ const type = layout.cells.get(cell.id)?.type;
34
+ if (type) {
35
+ slideTypes.set(cell.id, type);
36
+ }
37
+ if (type === "skip") {
38
+ skippedIds.add(cell.id);
39
+ } else if (startCell === null) {
40
+ startCell = cell;
41
+ startCellIndex = index;
42
+ }
43
+ }
44
+ return {
45
+ cellsWithOutput,
46
+ skippedIds,
47
+ slideTypes,
48
+ startCellIndex,
49
+ };
50
+ }
@@ -3,29 +3,74 @@
3
3
  import { z } from "zod";
4
4
  import type { ICellRendererPlugin } from "../types";
5
5
  import { SlidesLayoutRenderer } from "./slides-layout";
6
- import type { SlidesLayout } from "./types";
6
+ import type {
7
+ SerializedSlidesLayout,
8
+ SlideConfig,
9
+ SlidesLayout,
10
+ } from "./types";
11
+ import { Logger } from "@/utils/Logger";
12
+ import type { CellId } from "@/core/cells/ids";
7
13
 
8
14
  /**
9
15
  * Plugin definition for the slides layout.
10
16
  */
11
17
  export const SlidesLayoutPlugin: ICellRendererPlugin<
12
- SlidesLayout,
18
+ SerializedSlidesLayout,
13
19
  SlidesLayout
14
20
  > = {
15
21
  type: "slides",
16
22
  name: "Slides",
17
23
 
18
- validator: z.object({}),
24
+ // All fields are optional so layouts saved by older marimo versions will work
25
+ validator: z.object({
26
+ cells: z
27
+ .array(
28
+ z.object({
29
+ type: z.enum(["slide", "sub-slide", "fragment", "skip"]).optional(),
30
+ }),
31
+ )
32
+ .optional(),
33
+ deck: z
34
+ .object({
35
+ transition: z
36
+ .enum(["none", "fade", "slide", "convex", "concave", "zoom"])
37
+ .optional(),
38
+ })
39
+ .optional(),
40
+ }),
19
41
 
20
- deserializeLayout: (_serialized, _cells): SlidesLayout => {
21
- return {};
22
- },
42
+ deserializeLayout: (serialized, cells): SlidesLayout => {
43
+ const serializedCells = serialized.cells ?? [];
44
+ const deck = serialized.deck ?? {};
23
45
 
24
- serializeLayout: (_layout, _cells): SlidesLayout => {
25
- return {};
46
+ if (serializedCells.length > 0 && serializedCells.length !== cells.length) {
47
+ Logger.warn(
48
+ "Number of cells in layout does not match number of cells in notebook",
49
+ );
50
+ }
51
+
52
+ const slideCells = new Map<CellId, SlideConfig>();
53
+ for (const [idx, cell] of cells.entries()) {
54
+ const slideConfig = serializedCells.at(idx);
55
+ if (slideConfig) {
56
+ slideCells.set(cell.id, slideConfig);
57
+ }
58
+ }
59
+
60
+ return { cells: slideCells, deck };
26
61
  },
27
62
 
63
+ serializeLayout: (layout, cells): SerializedSlidesLayout => ({
64
+ cells: cells.map((cell) => ({
65
+ ...layout.cells.get(cell.id),
66
+ })),
67
+ deck: layout.deck,
68
+ }),
69
+
28
70
  Component: SlidesLayoutRenderer,
29
71
 
30
- getInitialLayout: () => ({}),
72
+ getInitialLayout: () => ({
73
+ cells: new Map(),
74
+ deck: {},
75
+ }),
31
76
  };
@@ -1,10 +1,11 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
- import React, { useRef, useState } from "react";
2
+ import React, { useMemo, useRef, useState } from "react";
3
3
  import { useAtomValue } from "jotai";
4
4
  import { numColumnsAtom } from "@/core/cells/cells";
5
5
  import type { CellId } from "@/core/cells/ids";
6
6
  import type { ICellRendererProps } from "../types";
7
7
  import type { SlidesLayout } from "./types";
8
+ import { computeSlideCellsInfo } from "./compute-slide-cells";
8
9
  import { SlidesMinimap } from "@/components/slides/minimap";
9
10
  import useEvent from "react-use-event-hook";
10
11
  import type { RevealApi } from "reveal.js";
@@ -16,9 +17,8 @@ const LazySlidesComponent = React.lazy(
16
17
  );
17
18
 
18
19
  export const SlidesLayoutRenderer: React.FC<Props> = ({
19
- // Currently we don't have layout config
20
- // layout,
21
- // setLayout,
20
+ layout,
21
+ setLayout,
22
22
  cells,
23
23
  mode,
24
24
  }) => {
@@ -28,14 +28,16 @@ export const SlidesLayoutRenderer: React.FC<Props> = ({
28
28
  const [activeCellId, setActiveCellId] = useState<CellId | null>(null);
29
29
  const deckRef = useRef<RevealApi | null>(null);
30
30
 
31
- const cellsWithOutput = cells.filter(
32
- (cell) => cell.output != null && cell.output.data !== "",
31
+ const { cellsWithOutput, skippedIds, slideTypes, startCellIndex } = useMemo(
32
+ () => computeSlideCellsInfo(cells, layout),
33
+ [cells, layout],
33
34
  );
34
35
 
35
36
  const activeSlideIndex = activeCellId
36
37
  ? cellsWithOutput.findIndex((c) => c.id === activeCellId)
37
- : 0;
38
- const resolvedIndex = activeSlideIndex === -1 ? 0 : activeSlideIndex;
38
+ : startCellIndex;
39
+ const resolvedIndex =
40
+ activeSlideIndex === -1 ? startCellIndex : activeSlideIndex;
39
41
 
40
42
  const handleSlideChange = useEvent((index: number) => {
41
43
  const cell = cellsWithOutput[index];
@@ -47,23 +49,39 @@ export const SlidesLayoutRenderer: React.FC<Props> = ({
47
49
  const slides = (
48
50
  <LazySlidesComponent
49
51
  cellsWithOutput={cellsWithOutput}
52
+ layout={layout}
53
+ setLayout={setLayout}
50
54
  activeIndex={resolvedIndex}
51
55
  onSlideChange={handleSlideChange}
52
56
  deckRef={deckRef}
57
+ configWidth={250}
58
+ mode={mode}
53
59
  />
54
60
  );
55
61
 
56
62
  if (isReading) {
57
- return <div className="p-4 flex flex-1 max-h-[95%]">{slides}</div>;
63
+ // Cap the deck height and derive width from height via aspect-video so it stays 16:9 without
64
+ // ballooning to the full viewport on wide screens.
65
+ return (
66
+ <div className="p-4 flex flex-1 items-center justify-center min-h-0">
67
+ <div className="h-full max-h-[95vh] aspect-video max-w-full flex">
68
+ {slides}
69
+ </div>
70
+ </div>
71
+ );
58
72
  }
59
73
 
60
74
  return (
61
- // Use 11/12 to ensure all content fits on the page (no overflow, scrolling required)
62
- <div className="pr-18 pb-5 flex-1 flex flex-row max-h-11/12 gap-2">
75
+ <div className="pr-18 pb-2 flex flex-row gap-2 min-h-0">
63
76
  <SlidesMinimap
64
77
  cells={cellsWithOutput}
78
+ thumbnailWidth={220}
65
79
  canReorder={!isMultiColumn}
66
- activeCellId={activeCellId ?? cellsWithOutput[0]?.id ?? null}
80
+ activeCellId={
81
+ activeCellId ?? cellsWithOutput[startCellIndex]?.id ?? null
82
+ }
83
+ skippedIds={skippedIds}
84
+ slideTypes={slideTypes}
67
85
  onSlideClick={handleSlideChange}
68
86
  />
69
87
  {slides}