@marimo-team/islands 0.23.7-dev64 → 0.23.7-dev65

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.7-dev64",
3
+ "version": "0.23.7-dev65",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -4,6 +4,7 @@ import { describe, it, expect } from "vitest";
4
4
  import { computeSlideCellsInfo } from "../compute-slide-cells";
5
5
  import type { SlideConfig, SlidesLayout } from "../types";
6
6
  import type { CellId } from "@/core/cells/ids";
7
+ import { cellId } from "@/__tests__/branded";
7
8
 
8
9
  interface TestCell {
9
10
  id: CellId;
@@ -15,10 +16,10 @@ const DEFAULT_OUTPUT: TestCell["output"] = { data: "ok" };
15
16
  const cell = (
16
17
  id: string,
17
18
  output: TestCell["output"] = DEFAULT_OUTPUT,
18
- ): TestCell => ({ id: id as CellId, output });
19
+ ): TestCell => ({ id: cellId(id), output });
19
20
 
20
21
  const layoutOf = (entries: Array<[string, SlideConfig]>): SlidesLayout => ({
21
- cells: new Map(entries.map(([id, cfg]) => [id as CellId, cfg])),
22
+ cells: new Map(entries.map(([id, cfg]) => [cellId(id), cfg])),
22
23
  deck: {},
23
24
  });
24
25
 
@@ -121,7 +122,7 @@ describe("computeSlideCellsInfo", () => {
121
122
  // Skipped cells are still "visible" deck cells — they just aren't rendered
122
123
  // in reveal. The minimap relies on the full list plus skippedIds.
123
124
  expect(result.cellsWithOutput.map((c) => c.id)).toEqual(["a", "b", "c"]);
124
- expect(result.slideTypes.get("b" as CellId)).toBe("skip");
125
+ expect(result.slideTypes.get(cellId("b"))).toBe("skip");
125
126
  });
126
127
 
127
128
  it("ignores layout entries for cells that have no output", () => {
@@ -137,7 +138,7 @@ describe("computeSlideCellsInfo", () => {
137
138
  );
138
139
  expect(result.cellsWithOutput.map((c) => c.id)).toEqual(["a"]);
139
140
  expect(result.skippedIds.size).toBe(0);
140
- expect(result.slideTypes.has("b" as CellId)).toBe(false);
141
+ expect(result.slideTypes.has(cellId("b"))).toBe(false);
141
142
  });
142
143
 
143
144
  it("preserves the input order of cells in cellsWithOutput", () => {
@@ -4,10 +4,11 @@ import { describe, it, expect } from "vitest";
4
4
  import { SlidesLayoutPlugin } from "../plugin";
5
5
  import type { CellData } from "@/core/cells/types";
6
6
  import type { CellId } from "@/core/cells/ids";
7
+ import { cellId } from "@/__tests__/branded";
7
8
 
8
9
  function makeCell(id: string, code = "print('hi')"): CellData {
9
10
  return {
10
- id: id as CellId,
11
+ id: cellId(id),
11
12
  name: id,
12
13
  code,
13
14
  edited: false,
@@ -198,7 +199,10 @@ const BACKWARDS_COMPAT_SNAPSHOTS: BackwardsCompatCase[] = [
198
199
  },
199
200
  {
200
201
  // Defensive: if a future version adds a new SlideConfig field and a user
201
- // downgrades, we must not crash on unknown keys.
202
+ // downgrades, we must not crash on unknown keys — AND we must not silently
203
+ // drop them either. `notes` / `background` aren't in the current schema;
204
+ // they must still be present after validate + (de)serialize so a downgrade
205
+ // followed by a save doesn't erase the newer marimo's data.
202
206
  label: "forward-compat: unknown SlideConfig fields present",
203
207
  input: {
204
208
  cells: [{ type: "slide", notes: "x", background: "#000" }],
@@ -206,7 +210,42 @@ const BACKWARDS_COMPAT_SNAPSHOTS: BackwardsCompatCase[] = [
206
210
  expected: {
207
211
  deck: {},
208
212
  cellIds: ["a"],
209
- cellEntries: [["a", { type: "slide" }]],
213
+ cellEntries: [["a", { type: "slide", notes: "x", background: "#000" }]],
214
+ },
215
+ },
216
+ {
217
+ // Same forward-compat guarantee for unknown deck-level fields (e.g. future
218
+ // Reveal options we haven't modeled yet).
219
+ label: "forward-compat: unknown deck fields present",
220
+ input: {
221
+ cells: [{}],
222
+ deck: { transition: "fade", controls: false, autoSlide: 5000 },
223
+ },
224
+ expected: {
225
+ deck: { transition: "fade", controls: false, autoSlide: 5000 },
226
+ cellIds: ["a"],
227
+ },
228
+ },
229
+ {
230
+ // `speakerNotes` was added to SlideConfig. The validator must
231
+ // know about it (so it isn't silently stripped), the deserializer must
232
+ // carry it through, and serialize → deserialize must round-trip it.
233
+ label: "speakerNotes round-trips through validate + (de)serialize",
234
+ input: {
235
+ cells: [
236
+ { type: "slide", speakerNotes: "intro" },
237
+ { type: "fragment", speakerNotes: "" },
238
+ { type: "fragment", speakerNotes: "multi\n\nline\n\nnotes" },
239
+ ],
240
+ },
241
+ expected: {
242
+ deck: {},
243
+ cellIds: ["a", "b", "c"],
244
+ cellEntries: [
245
+ ["a", { type: "slide", speakerNotes: "intro" }],
246
+ ["b", { type: "fragment", speakerNotes: "" }],
247
+ ["c", { type: "fragment", speakerNotes: "multi\n\nline\n\nnotes" }],
248
+ ],
210
249
  },
211
250
  },
212
251
  ];
@@ -223,20 +262,21 @@ describe("SlidesLayoutPlugin backwards compatibility", () => {
223
262
  parsed.success,
224
263
  `validator rejected: ${JSON.stringify(input)}`,
225
264
  ).toBe(true);
265
+ if (!parsed.success) {
266
+ return;
267
+ }
226
268
 
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
- );
269
+ // 2. Deserializing the *validator output* (not the raw input) must
270
+ // preserve every field listed in `expected.cellEntries`. This is what
271
+ // catches a schema regression: if the validator silently strips a
272
+ // known field, the deserialized config won't carry it and the
273
+ // assertion below fails.
274
+ const layout = SlidesLayoutPlugin.deserializeLayout(parsed.data, cells);
235
275
  if (expected.deck !== undefined) {
236
276
  expect(layout.deck).toEqual(expected.deck);
237
277
  }
238
- for (const [cellId, expectedConfig] of expected.cellEntries ?? []) {
239
- expect(layout.cells.get(cellId as CellId)).toMatchObject(
278
+ for (const [cellEntryId, expectedConfig] of expected.cellEntries ?? []) {
279
+ expect(layout.cells.get(cellId(cellEntryId))).toMatchObject(
240
280
  expectedConfig as object,
241
281
  );
242
282
  }
@@ -251,8 +291,8 @@ describe("SlidesLayoutPlugin backwards compatibility", () => {
251
291
  reserialized,
252
292
  cells,
253
293
  );
254
- for (const [cellId, expectedConfig] of expected.cellEntries ?? []) {
255
- expect(redeserialized.cells.get(cellId as CellId)).toMatchObject(
294
+ for (const [cellEntryId, expectedConfig] of expected.cellEntries ?? []) {
295
+ expect(redeserialized.cells.get(cellId(cellEntryId))).toMatchObject(
256
296
  expectedConfig as object,
257
297
  );
258
298
  }
@@ -1,15 +1,15 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { z } from "zod";
3
+ import type { CellId } from "@/core/cells/ids";
4
+ import { Logger } from "@/utils/Logger";
4
5
  import type { ICellRendererPlugin } from "../types";
5
6
  import { SlidesLayoutRenderer } from "./slides-layout";
6
- import type {
7
- SerializedSlidesLayout,
8
- SlideConfig,
9
- SlidesLayout,
7
+ import {
8
+ type SerializedSlidesLayout,
9
+ type SlideConfig,
10
+ type SlidesLayout,
11
+ SlidesLayoutSchema,
10
12
  } from "./types";
11
- import { Logger } from "@/utils/Logger";
12
- import type { CellId } from "@/core/cells/ids";
13
13
 
14
14
  /**
15
15
  * Plugin definition for the slides layout.
@@ -20,24 +20,7 @@ export const SlidesLayoutPlugin: ICellRendererPlugin<
20
20
  > = {
21
21
  type: "slides",
22
22
  name: "Slides",
23
-
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
- }),
23
+ validator: SlidesLayoutSchema,
41
24
 
42
25
  deserializeLayout: (serialized, cells): SlidesLayout => {
43
26
  const serializedCells = serialized.cells ?? [];
@@ -3,6 +3,7 @@ import React, { useMemo, 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
+ import { kioskModeAtom } from "@/core/mode";
6
7
  import type { ICellRendererProps } from "../types";
7
8
  import type { SlidesLayout } from "./types";
8
9
  import { computeSlideCellsInfo } from "./compute-slide-cells";
@@ -21,7 +22,10 @@ export const SlidesLayoutRenderer: React.FC<Props> = ({
21
22
  cells,
22
23
  mode,
23
24
  }) => {
24
- const isReading = mode === "read";
25
+ // Kiosk clients (e.g. reveal.js's speaker-view iframes) are read-only and
26
+ // shouldn't show authoring chrome, so we collapse to the read-mode layout.
27
+ const kioskMode = useAtomValue(kioskModeAtom);
28
+ const isReading = mode === "read" || kioskMode;
25
29
  const numColumns = useAtomValue(numColumnsAtom);
26
30
  const isMultiColumn = numColumns > 1;
27
31
  const [activeCellId, setActiveCellId] = useState<CellId | null>(null);
@@ -52,14 +56,23 @@ export const SlidesLayoutRenderer: React.FC<Props> = ({
52
56
  activeIndex={resolvedIndex}
53
57
  onSlideChange={handleSlideChange}
54
58
  configWidth={300}
55
- mode={mode}
56
- isEditable={mode !== "read"}
59
+ mode={isReading ? "read" : mode}
60
+ isEditable={!isReading}
57
61
  />
58
62
  );
59
63
 
60
64
  if (isReading) {
61
- // Cap the deck height and derive width from height via aspect-video so it stays 16:9 without
62
- // ballooning to the full viewport on wide screens.
65
+ // In kiosk mode (e.g. reveal.js's speaker-view iframes), anchor to the
66
+ // iframe viewport with `dvh`/`dvw` so the deck resizes with the popup
67
+ // window. The non-kiosk read mode keeps its 16:9 cap so the deck doesn't
68
+ // balloon to the full viewport on wide screens.
69
+ if (kioskMode) {
70
+ return (
71
+ <div className="flex h-dvh w-dvw overflow-hidden bg-background">
72
+ {slides}
73
+ </div>
74
+ );
75
+ }
63
76
  return (
64
77
  <div className="p-4 flex flex-1 items-center justify-center min-h-0">
65
78
  <div className="h-full max-h-[95vh] aspect-video max-w-full flex">
@@ -1,41 +1,50 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
- /* oxlint-disable typescript/no-empty-object-type */
3
2
 
3
+ import { z } from "zod";
4
4
  import type { CellId } from "@/core/cells/ids";
5
5
 
6
+ const SlideTypeSchema = z.enum(["slide", "sub-slide", "fragment", "skip"]);
7
+ export type SlideType = z.infer<typeof SlideTypeSchema>;
8
+
9
+ const SlideConfigSchema = z.looseObject({
10
+ type: SlideTypeSchema.optional(),
11
+ speakerNotes: z.string().optional(),
12
+ });
13
+ export type SlideConfig = z.infer<typeof SlideConfigSchema>;
14
+
15
+ const DeckTransitionSchema = z.enum([
16
+ "none",
17
+ "fade",
18
+ "slide",
19
+ "convex",
20
+ "concave",
21
+ "zoom",
22
+ ]);
23
+ export type DeckTransition = z.infer<typeof DeckTransitionSchema>;
24
+
25
+ const DeckConfigSchema = z.looseObject({
26
+ transition: DeckTransitionSchema.optional(),
27
+ });
28
+ export type DeckConfig = z.infer<typeof DeckConfigSchema>;
29
+
6
30
  /**
7
- * The serialized form of a slides layout.
8
- * This must be backwards-compatible as it is stored on the user's disk.
31
+ * Schema for the serialized form of a slides layout.
32
+ *
33
+ * This must be backwards-compatible as it is stored on the user's disk —
34
+ * fields are optional so files saved before they existed (e.g. the bare `{}`
35
+ * emitted by earlier marimo versions) still deserialize cleanly. Unknown
36
+ * keys are preserved (via `looseObject`) for the same reason.
9
37
  */
10
- // oxlint-disable-next-line typescript/consistent-type-definitions
11
- export type SerializedSlidesLayout = {
12
- // Both fields are optional so files saved before these existed (e.g. the
13
- // bare `{}` emitted by earlier marimo versions) still deserialize cleanly.
14
- deck?: DeckConfig;
15
- cells?: SlideConfig[];
16
- };
38
+ export const SlidesLayoutSchema = z.looseObject({
39
+ cells: z.array(SlideConfigSchema).optional(),
40
+ deck: DeckConfigSchema.optional(),
41
+ });
42
+ export type SerializedSlidesLayout = z.infer<typeof SlidesLayoutSchema>;
17
43
 
18
- export interface SlidesLayout extends Omit<
19
- SerializedSlidesLayout,
20
- "cells" | "deck"
21
- > {
22
- // We map the cells to their IDs so that we can track them as they move around.
44
+ /**
45
+ * Runtime form of a slides layout.
46
+ */
47
+ export interface SlidesLayout {
23
48
  cells: Map<CellId, SlideConfig>;
24
49
  deck: DeckConfig;
25
50
  }
26
-
27
- export type SlideType = "slide" | "sub-slide" | "fragment" | "skip";
28
- export interface SlideConfig {
29
- type?: SlideType;
30
- }
31
-
32
- export type DeckTransition =
33
- | "none"
34
- | "fade"
35
- | "slide"
36
- | "convex"
37
- | "concave"
38
- | "zoom";
39
- export interface DeckConfig {
40
- transition?: DeckTransition;
41
- }
@@ -0,0 +1,131 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import type {
5
+ SlideConfig,
6
+ SlideType,
7
+ } from "@/components/editor/renderers/slides-layout/types";
8
+ import type { CellId } from "@/core/cells/ids";
9
+ import { cellId } from "@/__tests__/branded";
10
+ import { composeSlides } from "../compose-slides";
11
+ import { buildSubslideNotes, collectBlockNotes } from "../slide-notes";
12
+
13
+ interface Cell {
14
+ id: CellId;
15
+ type?: SlideType;
16
+ }
17
+
18
+ const cell = (id: string, type?: SlideType): Cell => ({
19
+ id: cellId(id),
20
+ type,
21
+ });
22
+
23
+ const configs = (
24
+ notes: Record<string, string>,
25
+ ): ReadonlyMap<CellId, SlideConfig> =>
26
+ new Map(
27
+ Object.entries(notes).map(([id, speakerNotes]) => [
28
+ cellId(id),
29
+ { speakerNotes } satisfies SlideConfig,
30
+ ]),
31
+ );
32
+
33
+ const firstSubslide = (cells: Cell[]) =>
34
+ composeSlides({ cells, getType: (c) => c.type ?? "slide" }).stacks[0]
35
+ .subslides[0];
36
+
37
+ describe("collectBlockNotes", () => {
38
+ it("concatenates non-empty notes with paragraph spacing", () => {
39
+ const result = collectBlockNotes(
40
+ [cell("a"), cell("b"), cell("c")],
41
+ configs({ a: "first", b: "", c: "third" }),
42
+ );
43
+ expect(result).toBe("first\n\nthird");
44
+ });
45
+
46
+ it("returns an empty string when no cell has notes", () => {
47
+ expect(collectBlockNotes([cell("a")], configs({}))).toBe("");
48
+ });
49
+
50
+ it("ignores whitespace-only notes", () => {
51
+ expect(
52
+ collectBlockNotes([cell("a"), cell("b")], configs({ a: " ", b: "x" })),
53
+ ).toBe("x");
54
+ });
55
+ });
56
+
57
+ describe("buildSubslideNotes", () => {
58
+ it("returns empty notes when no cell has any", () => {
59
+ const subslide = firstSubslide([cell("a"), cell("b", "fragment")]);
60
+ expect(buildSubslideNotes(subslide, configs({}))).toEqual({
61
+ slideLevel: "",
62
+ cumulativeByBlock: new Map(),
63
+ });
64
+ });
65
+
66
+ it("returns only slide-level notes when there are no fragments", () => {
67
+ const subslide = firstSubslide([cell("a")]);
68
+ expect(buildSubslideNotes(subslide, configs({ a: "intro" }))).toEqual({
69
+ slideLevel: "intro",
70
+ cumulativeByBlock: new Map(),
71
+ });
72
+ });
73
+
74
+ it("accumulates fragments below the slide-level notes with a divider", () => {
75
+ const subslide = firstSubslide([
76
+ cell("a"),
77
+ cell("b", "fragment"),
78
+ cell("c", "fragment"),
79
+ ]);
80
+ const { slideLevel, cumulativeByBlock } = buildSubslideNotes(
81
+ subslide,
82
+ configs({ a: "intro", b: "step one", c: "step two" }),
83
+ );
84
+ expect(slideLevel).toBe("intro");
85
+ expect(cumulativeByBlock.get(1)).toBe("intro\n\n---\n\nstep one");
86
+ expect(cumulativeByBlock.get(2)).toBe(
87
+ "intro\n\n---\n\nstep one\n\n---\n\nstep two",
88
+ );
89
+ });
90
+
91
+ it("accumulates fragments with no slide-level notes", () => {
92
+ const subslide = firstSubslide([
93
+ cell("a"),
94
+ cell("b", "fragment"),
95
+ cell("c", "fragment"),
96
+ ]);
97
+ const { slideLevel, cumulativeByBlock } = buildSubslideNotes(
98
+ subslide,
99
+ configs({ b: "first reveal", c: "second reveal" }),
100
+ );
101
+ expect(slideLevel).toBe("");
102
+ expect(cumulativeByBlock.get(1)).toBe("first reveal");
103
+ expect(cumulativeByBlock.get(2)).toBe(
104
+ "first reveal\n\n---\n\nsecond reveal",
105
+ );
106
+ });
107
+
108
+ it("skips empty fragments without leaving dangling dividers", () => {
109
+ const subslide = firstSubslide([
110
+ cell("a"),
111
+ cell("b", "fragment"),
112
+ cell("c", "fragment"),
113
+ ]);
114
+ const { cumulativeByBlock } = buildSubslideNotes(
115
+ subslide,
116
+ configs({ a: "intro", c: "third" }),
117
+ );
118
+ expect(cumulativeByBlock.get(1)).toBe("intro");
119
+ expect(cumulativeByBlock.get(2)).toBe("intro\n\n---\n\nthird");
120
+ });
121
+
122
+ it("returns no cumulative entries when fragments and slide have no notes", () => {
123
+ const subslide = firstSubslide([
124
+ cell("a"),
125
+ cell("b", "fragment"),
126
+ cell("c", "fragment"),
127
+ ]);
128
+ const { cumulativeByBlock } = buildSubslideNotes(subslide, configs({}));
129
+ expect(cumulativeByBlock.size).toBe(0);
130
+ });
131
+ });