@marimo-team/islands 0.23.15-dev10 → 0.23.15-dev12

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.15-dev10",
3
+ "version": "0.23.15-dev12",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -42,6 +42,23 @@ describe("SlidesLayoutPlugin validator", () => {
42
42
  }).success,
43
43
  ).toBe(false);
44
44
  });
45
+
46
+ it("accepts each valid deck vertical alignment", () => {
47
+ for (const verticalAlign of ["top", "center", "bottom"]) {
48
+ expect(
49
+ SlidesLayoutPlugin.validator.safeParse({ deck: { verticalAlign } })
50
+ .success,
51
+ ).toBe(true);
52
+ }
53
+ });
54
+
55
+ it("rejects an unknown deck vertical alignment", () => {
56
+ expect(
57
+ SlidesLayoutPlugin.validator.safeParse({
58
+ deck: { verticalAlign: "middle" },
59
+ }).success,
60
+ ).toBe(false);
61
+ });
45
62
  });
46
63
 
47
64
  describe("SlidesLayoutPlugin deserializeLayout", () => {
@@ -268,6 +285,18 @@ const BACKWARDS_COMPAT_SNAPSHOTS: BackwardsCompatCase[] = [
268
285
  ],
269
286
  },
270
287
  },
288
+ {
289
+ // `verticalAlign` was added to DeckConfig.
290
+ label: "deck.verticalAlign round-trips through validate + (de)serialize",
291
+ input: {
292
+ cells: [{}],
293
+ deck: { transition: "fade", verticalAlign: "top" },
294
+ },
295
+ expected: {
296
+ deck: { transition: "fade", verticalAlign: "top" },
297
+ cellIds: ["a"],
298
+ },
299
+ },
271
300
  ];
272
301
 
273
302
  describe("SlidesLayoutPlugin backwards compatibility", () => {
@@ -23,8 +23,12 @@ const DeckTransitionSchema = z.enum([
23
23
  ]);
24
24
  export type DeckTransition = z.infer<typeof DeckTransitionSchema>;
25
25
 
26
+ const DeckVerticalAlignSchema = z.enum(["top", "center", "bottom"]);
27
+ export type DeckVerticalAlign = z.infer<typeof DeckVerticalAlignSchema>;
28
+
26
29
  const DeckConfigSchema = z.looseObject({
27
30
  transition: DeckTransitionSchema.optional(),
31
+ verticalAlign: DeckVerticalAlignSchema.optional(),
28
32
  });
29
33
  export type DeckConfig = z.infer<typeof DeckConfigSchema>;
30
34
 
@@ -1,6 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import {
4
+ type CSSProperties,
4
5
  startTransition,
5
6
  useEffect,
6
7
  useMemo,
@@ -26,6 +27,7 @@ import { Logger } from "@/utils/Logger";
26
27
  import "./slides.css";
27
28
  import "./reveal-slides.css";
28
29
  import type {
30
+ DeckVerticalAlign,
29
31
  SlideConfig,
30
32
  SlidesLayout,
31
33
  SlideType,
@@ -39,6 +41,7 @@ import {
39
41
  type ComposedSubslide,
40
42
  } from "./compose-slides";
41
43
  import {
44
+ DEFAULT_DECK_VERTICAL_ALIGN,
42
45
  DEFAULT_DECK_TRANSITION,
43
46
  DEFAULT_SLIDE_TYPE,
44
47
  SlideSidebar,
@@ -292,16 +295,38 @@ export function useParkedPreview(options: {
292
295
  };
293
296
  }
294
297
 
298
+ /**
299
+ * Margin style that positions a slide's content vertically within the
300
+ * full-height slide. The content is a flex item, so the vertical margins decide
301
+ * where the free space lands: `auto` on both sides centers it, while pinning one
302
+ * side to `0` pushes content to the top or bottom. The horizontal `20px` keeps
303
+ * content off the slide edges regardless of alignment.
304
+ */
305
+ function resolveSlideContentStyle(
306
+ verticalAlign: DeckVerticalAlign | undefined,
307
+ ): CSSProperties {
308
+ switch (verticalAlign ?? DEFAULT_DECK_VERTICAL_ALIGN) {
309
+ case "top":
310
+ return { margin: "0 20px auto" };
311
+ case "bottom":
312
+ return { margin: "auto 20px 0" };
313
+ default:
314
+ return { margin: "auto 20px" };
315
+ }
316
+ }
317
+
295
318
  const SubslideView = ({
296
319
  subslide,
297
320
  resolveShowCode,
298
321
  isEditable,
299
322
  slideConfigs,
323
+ contentStyle,
300
324
  }: {
301
325
  subslide: ComposedSubslide<RuntimeCell>;
302
326
  resolveShowCode: (cellId: CellId) => boolean;
303
327
  isEditable: boolean;
304
328
  slideConfigs: ReadonlyMap<CellId, SlideConfig>;
329
+ contentStyle: CSSProperties;
305
330
  }) => {
306
331
  const { slideLevel, cumulativeByBlock } = buildSubslideNotes(
307
332
  subslide,
@@ -321,9 +346,7 @@ const SubslideView = ({
321
346
  ? "mo-slide-content flex flex-col gap-3"
322
347
  : "mo-slide-content"
323
348
  }
324
- style={{
325
- margin: "auto 20px",
326
- }}
349
+ style={contentStyle}
327
350
  >
328
351
  {subslide.blocks.map((block, i) => {
329
352
  const rendered = block.cells.map((cell) => {
@@ -514,6 +537,10 @@ const RevealSlidesComponent = ({
514
537
  );
515
538
 
516
539
  const deckTransition = layout.deck?.transition ?? DEFAULT_DECK_TRANSITION;
540
+ const slideContentStyle = resolveSlideContentStyle(
541
+ layout.deck?.verticalAlign,
542
+ );
543
+
517
544
  // Reveal's Notes plugin iframes the deck for the current/upcoming-slide
518
545
  // previews. We load the same URL but as a read-only kiosk client with the
519
546
  // app chrome hidden, which `<SlidesLayoutRenderer>` interprets the same as
@@ -704,6 +731,7 @@ const RevealSlidesComponent = ({
704
731
  resolveShowCode={resolveShowCode}
705
732
  isEditable={isEditable}
706
733
  slideConfigs={layout.cells}
734
+ contentStyle={slideContentStyle}
707
735
  />
708
736
  );
709
737
  }
@@ -717,6 +745,7 @@ const RevealSlidesComponent = ({
717
745
  resolveShowCode={resolveShowCode}
718
746
  isEditable={isEditable}
719
747
  slideConfigs={layout.cells}
748
+ contentStyle={slideContentStyle}
720
749
  />
721
750
  );
722
751
  })}
@@ -743,7 +772,7 @@ const RevealSlidesComponent = ({
743
772
  ? "mo-slide-content flex flex-col gap-3"
744
773
  : "mo-slide-content"
745
774
  }
746
- style={{ margin: "auto 20px" }}
775
+ style={slideContentStyle}
747
776
  >
748
777
  <ParkedPreviewContent
749
778
  cell={parkedPreviewCell}
@@ -23,6 +23,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
23
23
  import type { CellId } from "@/core/cells/ids";
24
24
  import { cn } from "@/utils/cn";
25
25
  import type {
26
+ DeckVerticalAlign,
26
27
  DeckTransition,
27
28
  SlidesLayout,
28
29
  SlideType,
@@ -37,6 +38,7 @@ import { jotaiJsonStorage } from "@/utils/storage/jotai";
37
38
 
38
39
  export const DEFAULT_SLIDE_TYPE: SlideType = "slide";
39
40
  export const DEFAULT_DECK_TRANSITION: DeckTransition = "slide";
41
+ export const DEFAULT_DECK_VERTICAL_ALIGN: DeckVerticalAlign = "center";
40
42
  const COLLAPSED_CONFIG_WIDTH = 36;
41
43
  const slideConfigOpenAtom = atomWithStorage<boolean>(
42
44
  "marimo:slides:config-open",
@@ -118,6 +120,30 @@ const DECK_TRANSITION_OPTIONS: DeckTransitionOption[] = [
118
120
  { value: "zoom", label: "Zoom", description: "Zoom into the next slide." },
119
121
  ];
120
122
 
123
+ interface DeckVerticalAlignOption {
124
+ value: DeckVerticalAlign;
125
+ label: string;
126
+ description: string;
127
+ }
128
+
129
+ const DECK_VERTICAL_ALIGN_OPTIONS: DeckVerticalAlignOption[] = [
130
+ {
131
+ value: "center",
132
+ label: "Center",
133
+ description: "Vertically center each slide's content.",
134
+ },
135
+ {
136
+ value: "top",
137
+ label: "Top",
138
+ description: "Align content to the top, like the cell view.",
139
+ },
140
+ {
141
+ value: "bottom",
142
+ label: "Bottom",
143
+ description: "Align content to the bottom of each slide.",
144
+ },
145
+ ];
146
+
121
147
  const SlidesForm = ({
122
148
  layout,
123
149
  setLayout,
@@ -300,6 +326,12 @@ const DeckConfigForm = ({
300
326
  (opt) => opt.value === currentTransition,
301
327
  )?.description;
302
328
 
329
+ const currentVerticalAlign: DeckVerticalAlign =
330
+ layout.deck?.verticalAlign ?? DEFAULT_DECK_VERTICAL_ALIGN;
331
+ const activeVerticalAlignDescription = DECK_VERTICAL_ALIGN_OPTIONS.find(
332
+ (opt) => opt.value === currentVerticalAlign,
333
+ )?.description;
334
+
303
335
  const handleTransitionChange = (value: DeckTransition) => {
304
336
  setLayout({
305
337
  ...layout,
@@ -307,6 +339,13 @@ const DeckConfigForm = ({
307
339
  });
308
340
  };
309
341
 
342
+ const handleVerticalAlignChange = (value: DeckVerticalAlign) => {
343
+ setLayout({
344
+ ...layout,
345
+ deck: { ...layout.deck, verticalAlign: value },
346
+ });
347
+ };
348
+
310
349
  return (
311
350
  <div className="flex flex-col gap-3">
312
351
  <div className="flex flex-col gap-1.5">
@@ -337,6 +376,39 @@ const DeckConfigForm = ({
337
376
  <p className="text-xs text-foreground/70">{activeDescription}</p>
338
377
  )}
339
378
  </div>
379
+ <div className="flex flex-col gap-1.5">
380
+ <label
381
+ htmlFor="deck-vertical-align"
382
+ className="font-semibold text-sm text-foreground"
383
+ >
384
+ Vertical alignment
385
+ </label>
386
+ <Select
387
+ value={currentVerticalAlign}
388
+ onValueChange={(value) =>
389
+ handleVerticalAlignChange(value as DeckVerticalAlign)
390
+ }
391
+ >
392
+ <SelectTrigger
393
+ id="deck-vertical-align"
394
+ aria-label="Vertical alignment"
395
+ >
396
+ <SelectValue />
397
+ </SelectTrigger>
398
+ <SelectContent>
399
+ {DECK_VERTICAL_ALIGN_OPTIONS.map(({ value, label }) => (
400
+ <SelectItem key={value} value={value}>
401
+ {label}
402
+ </SelectItem>
403
+ ))}
404
+ </SelectContent>
405
+ </Select>
406
+ {activeVerticalAlignDescription && (
407
+ <p className="text-xs text-foreground/70">
408
+ {activeVerticalAlignDescription}
409
+ </p>
410
+ )}
411
+ </div>
340
412
  </div>
341
413
  );
342
414
  };
@@ -277,25 +277,22 @@ describe("WidgetBinding receives a host in render props", () => {
277
277
  });
278
278
  });
279
279
 
280
- describe("host-mounted views hydrate like display-mounted ones", () => {
281
- it("replays current state to a child's render listeners", async () => {
282
- const childId = asModelId("host-hydration-child");
280
+ describe("host-mounted views", () => {
281
+ it("render with the child's current model state", async () => {
282
+ const childId = asModelId("host-child-state");
283
283
  const parentController = new AbortController();
284
284
  const getModuleSpy = vi.spyOn(WIDGET_DEF_REGISTRY, "getModule");
285
285
  try {
286
286
  const childWidget = {
287
287
  render: vi.fn(({ model, el }) => {
288
- el.textContent = "count is 5";
289
- model.on("change:count", () => {
290
- el.textContent = `count is ${model.get("count")}`;
291
- });
288
+ el.textContent = `count is ${model.get("count")}`;
292
289
  }),
293
290
  };
294
291
  const childModel = new Model<ModelState>({ count: 8 }, createMockComm());
295
292
  WIDGET_REGISTRY.setModel(childId, childModel);
296
293
  WIDGET_REGISTRY.setSpec(childId, {
297
- url: "./@file/10-host-hydration-child.js",
298
- hash: "hash-host-hydration-child",
294
+ url: "./@file/10-host-child-state.js",
295
+ hash: "hash-host-child-state",
299
296
  });
300
297
  getModuleSpy.mockResolvedValue({ default: childWidget });
301
298
 
@@ -337,13 +337,12 @@ describe("WidgetBinding", () => {
337
337
  { el: document.createElement("div") },
338
338
  { signal: controller.signal },
339
339
  );
340
- // Called once by the hydration replay at mount, once by the set.
341
340
  model.set("count", 1);
342
- expect(onCount).toHaveBeenCalledTimes(2);
341
+ expect(onCount).toHaveBeenCalledTimes(1);
343
342
 
344
343
  controller.abort();
345
344
  model.set("count", 2);
346
- expect(onCount).toHaveBeenCalledTimes(2);
345
+ expect(onCount).toHaveBeenCalledTimes(1);
347
346
  });
348
347
 
349
348
  it("should auto-clear initialize listeners on destroy", async () => {
@@ -366,92 +365,54 @@ describe("WidgetBinding", () => {
366
365
  });
367
366
  });
368
367
 
369
- describe("WidgetBinding hydration replay", () => {
370
- it("replays current state to render listeners exactly once", async () => {
368
+ describe("WidgetBinding render listeners", () => {
369
+ it("fire only for changes after mount, never for pre-existing state", async () => {
371
370
  const model = new Model<ModelState>({ count: 8 }, createMockComm());
372
- const el = document.createElement("div");
373
- const widgetDef = {
374
- render: vi.fn(({ model, el }) => {
375
- // A widget view that starts with a local default and relies on
376
- // change events for hydration.
377
- el.textContent = "count is 5";
378
- model.on("change:count", () => {
379
- el.textContent = `count is ${model.get("count")}`;
380
- });
381
- }),
382
- };
383
- const binding = await createBinding(widgetDef, model);
384
-
385
- const controller = new AbortController();
386
- await binding.createView({ el }, { signal: controller.signal });
387
- expect(el.textContent).toBe("count is 8");
388
- });
389
-
390
- it("does not re-fire listeners of already-mounted views", async () => {
391
- // Mounting view B must not double-paint view A: the replay is
392
- // scoped to the listeners the new render attached.
393
- const model = new Model<ModelState>({ count: 0 }, createMockComm());
394
- const listeners: Array<ReturnType<typeof vi.fn>> = [];
371
+ const onCount = vi.fn();
372
+ const onAnyChange = vi.fn();
395
373
  const widgetDef = {
396
374
  render: vi.fn(({ model }) => {
397
- const listener = vi.fn();
398
- listeners.push(listener);
399
- model.on("change:count", listener);
375
+ model.on("change:count", onCount);
376
+ model.on("change", onAnyChange);
400
377
  }),
401
378
  };
402
379
  const binding = await createBinding(widgetDef, model);
403
380
  const controller = new AbortController();
404
-
405
381
  await binding.createView(
406
382
  { el: document.createElement("div") },
407
383
  { signal: controller.signal },
408
384
  );
409
- expect(listeners[0]).toHaveBeenCalledTimes(1);
385
+ expect(onCount).not.toHaveBeenCalled();
386
+ expect(onAnyChange).not.toHaveBeenCalled();
410
387
 
411
- await binding.createView(
412
- { el: document.createElement("div") },
413
- { signal: controller.signal },
414
- );
415
- // View B's listener hydrated once; view A's was left alone.
416
- expect(listeners[1]).toHaveBeenCalledTimes(1);
417
- expect(listeners[0]).toHaveBeenCalledTimes(1);
388
+ model.set("count", 9);
389
+ expect(onCount).toHaveBeenCalledTimes(1);
390
+ expect(onCount).toHaveBeenCalledWith(9);
418
391
  });
419
392
 
420
- it("replays the any-change event to its listeners", async () => {
421
- const model = new Model<ModelState>({ count: 1 }, createMockComm());
422
- const onAnyChange = vi.fn();
393
+ it("mounting a second view does not fire the first view's listeners", async () => {
394
+ const model = new Model<ModelState>({ count: 0 }, createMockComm());
395
+ const listeners: Array<ReturnType<typeof vi.fn>> = [];
423
396
  const widgetDef = {
424
397
  render: vi.fn(({ model }) => {
425
- model.on("change", onAnyChange);
398
+ const listener = vi.fn();
399
+ listeners.push(listener);
400
+ model.on("change:count", listener);
426
401
  }),
427
402
  };
428
403
  const binding = await createBinding(widgetDef, model);
429
404
  const controller = new AbortController();
405
+
430
406
  await binding.createView(
431
407
  { el: document.createElement("div") },
432
408
  { signal: controller.signal },
433
409
  );
434
- expect(onAnyChange).toHaveBeenCalledTimes(1);
435
- });
436
-
437
- it("does not replay initialize listeners", async () => {
438
- // The guarantee is per-view: initialize listeners existed before
439
- // any view, and replaying at them would fire once per mount.
440
- const model = new Model<ModelState>({ count: 1 }, createMockComm());
441
- const initListener = vi.fn();
442
- const widgetDef = {
443
- initialize: vi.fn(({ model }) => {
444
- model.on("change:count", initListener);
445
- }),
446
- render: vi.fn(),
447
- };
448
- const binding = await createBinding(widgetDef, model);
449
- const controller = new AbortController();
450
410
  await binding.createView(
451
411
  { el: document.createElement("div") },
452
412
  { signal: controller.signal },
453
413
  );
454
- expect(initListener).not.toHaveBeenCalled();
414
+ expect(listeners[0]).not.toHaveBeenCalled();
415
+ expect(listeners[1]).not.toHaveBeenCalled();
455
416
  });
456
417
 
457
418
  it("clears the element before rendering into it", async () => {
@@ -5,15 +5,6 @@ import type { ModelState } from "./types";
5
5
 
6
6
  type ModelEventCallback = Parameters<AnyModel<ModelState>["on"]>[1];
7
7
 
8
- /**
9
- * A listener registered through a model proxy (see the hydration
10
- * replay in `WidgetBinding.createView`).
11
- */
12
- export interface ProxyRegistration {
13
- event: string;
14
- callback: ModelEventCallback;
15
- }
16
-
17
8
  /**
18
9
  * Wrap a model so every `on()` call from inside `initialize` or `render`
19
10
  * is auto-tied to a lifetime `AbortSignal`. When the signal aborts,
@@ -27,14 +18,11 @@ export interface ProxyRegistration {
27
18
  * The proxy is purely a host-side ergonomic helper. The widget author
28
19
  * still writes `model.on("change:foo", handler)` exactly as before; the
29
20
  * cleanup signal is supplied transparently.
30
- *
31
- * `onRegister`, when given, observes each `on()` registration.
32
21
  */
33
22
  // oxlint-disable-next-line marimo/prefer-object-params -- concise internal helper used at protocol call sites
34
23
  export function modelProxy<T extends ModelState>(
35
24
  model: AnyModel<T>,
36
25
  signal: AbortSignal,
37
- onRegister?: (registration: ProxyRegistration) => void,
38
26
  ): AnyModel<T> {
39
27
  return {
40
28
  get(key) {
@@ -55,7 +43,6 @@ export function modelProxy<T extends ModelState>(
55
43
  signal.addEventListener("abort", () => model.off(name, callback), {
56
44
  once: true,
57
45
  });
58
- onRegister?.({ event: name, callback });
59
46
  },
60
47
  off(name?: string | null, callback?: ModelEventCallback | null): void {
61
48
  model.off(name ?? null, callback ?? null);
@@ -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, type ProxyRegistration } from "./model-proxy";
15
+ import { modelProxy } from "./model-proxy";
16
16
  import type { ModelState } from "./types";
17
17
 
18
18
  export const experimental: Experimental = {
@@ -266,9 +266,8 @@ export class WidgetBinding<T extends ModelState = ModelState> {
266
266
  * when the caller's `signal` fires or the binding is destroyed, and
267
267
  * listeners registered inside `render` auto-clear on abort.
268
268
  *
269
- * Hydration guarantee: listeners attached during `render` observe
270
- * current model state exactly once, after `render` settles. Scoped
271
- * to this view's listeners; re-firing at other views double-paints.
269
+ * `render` reads current state via `model.get`; change listeners
270
+ * observe only subsequent changes, matching Jupyter semantics.
272
271
  */
273
272
  async createView(
274
273
  target: { el: HTMLElement },
@@ -305,11 +304,8 @@ export class WidgetBinding<T extends ModelState = ModelState> {
305
304
  // Each view gets a host scoped to its own signal so child views tear
306
305
  // down with this view.
307
306
  const host = this.#createHost(renderSignal);
308
- const registrations: ProxyRegistration[] = [];
309
307
  const renderCleanup = await widget.render({
310
- model: modelProxy(this.#model, renderSignal, (registration) =>
311
- registrations.push(registration),
312
- ),
308
+ model: modelProxy(this.#model, renderSignal),
313
309
  el: target.el,
314
310
  experimental,
315
311
  signal: renderSignal,
@@ -333,7 +329,6 @@ export class WidgetBinding<T extends ModelState = ModelState> {
333
329
  once: true,
334
330
  });
335
331
  }
336
- this.#replayState(registrations, renderSignal);
337
332
  }
338
333
 
339
334
  #trackCleanup(cleanup: Cleanup, reason: string): Promise<void> {
@@ -343,31 +338,6 @@ export class WidgetBinding<T extends ModelState = ModelState> {
343
338
  return task;
344
339
  }
345
340
 
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
341
  /**
372
342
  * Destroy this generation, running initialize/render cleanups and
373
343
  * clearing listeners registered through its model proxies.