@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,337 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { SlideType } from "../editor/renderers/slides-layout/types";
4
+
5
+ /**
6
+ * A contiguous run of cells that render together on a single subslide.
7
+ *
8
+ * - `isFragment=false`: cells are emitted inline in the <Slide>.
9
+ * - `isFragment=true`: cells are wrapped in a single <Fragment> so they reveal
10
+ * as one step.
11
+ */
12
+ export interface ComposedBlock<T> {
13
+ isFragment: boolean;
14
+ cells: T[];
15
+ }
16
+
17
+ /**
18
+ * One <Slide>. Vertical stacking is expressed at the Stack level.
19
+ */
20
+ export interface ComposedSubslide<T> {
21
+ blocks: ComposedBlock<T>[];
22
+ }
23
+
24
+ /**
25
+ * One horizontal position in the deck.
26
+ *
27
+ * - `subslides.length === 1` -> render as a single <Slide>.
28
+ * - `subslides.length > 1` -> render as <Stack><Slide/>...</Stack>.
29
+ */
30
+ export interface ComposedStack<T> {
31
+ subslides: ComposedSubslide<T>[];
32
+ }
33
+
34
+ export interface Composition<T> {
35
+ stacks: ComposedStack<T>[];
36
+ }
37
+
38
+ /**
39
+ * Groups a flat list of cells into a tree of stacks / subslides / blocks based
40
+ * on each cell's {@link SlideType}.
41
+ *
42
+ * Inspired by the RISE JupyterLab extension's `markupSlides`, but adapted for a
43
+ * declarative React renderer: instead of mutating DOM during the walk, we
44
+ * produce a tree the caller can render however they like.
45
+ *
46
+ * Callers are responsible for normalizing "no type set" to a concrete
47
+ * {@link SlideType} before getting here — the convention in this codebase is
48
+ * that a cell with no configured type is a `"slide"`.
49
+ *
50
+ * Rules (per cell):
51
+ * - `"slide"` -> open a new stack + subslide, cell goes in a plain block.
52
+ * - `"sub-slide"` -> open a new subslide inside the current stack.
53
+ * - `"fragment"` -> open a new fragment block inside the current subslide.
54
+ * - `"skip"` -> dropped entirely. If a caller wants to preserve these,
55
+ * they can remap the type in `getType`.
56
+ *
57
+ * If the very first cell is a `fragment` or `sub-slide`, a containing stack /
58
+ * subslide is created implicitly.
59
+ */
60
+ export function composeSlides<T>({
61
+ cells,
62
+ getType,
63
+ }: {
64
+ cells: readonly T[];
65
+ getType: (cell: T) => SlideType;
66
+ }): Composition<T> {
67
+ const stacks: ComposedStack<T>[] = [];
68
+ let stack: ComposedStack<T> | null = null;
69
+ let subslide: ComposedSubslide<T> | null = null;
70
+
71
+ const openStack = (): ComposedStack<T> => {
72
+ const next: ComposedStack<T> = { subslides: [] };
73
+ stacks.push(next);
74
+ stack = next;
75
+ subslide = null;
76
+ return next;
77
+ };
78
+ const openSubslide = (): ComposedSubslide<T> => {
79
+ const parent = stack ?? openStack();
80
+ const next: ComposedSubslide<T> = { blocks: [] };
81
+ parent.subslides.push(next);
82
+ subslide = next;
83
+ return next;
84
+ };
85
+ const openBlock = (isFragment: boolean): ComposedBlock<T> => {
86
+ const parent = subslide ?? openSubslide();
87
+ const next: ComposedBlock<T> = { isFragment, cells: [] };
88
+ parent.blocks.push(next);
89
+ return next;
90
+ };
91
+
92
+ for (const cell of cells) {
93
+ const type = getType(cell);
94
+
95
+ switch (type) {
96
+ case "skip":
97
+ break;
98
+ case "slide":
99
+ openStack();
100
+ openSubslide();
101
+ openBlock(false).cells.push(cell);
102
+ break;
103
+ case "sub-slide":
104
+ openSubslide();
105
+ openBlock(false).cells.push(cell);
106
+ break;
107
+ case "fragment":
108
+ openBlock(true).cells.push(cell);
109
+ break;
110
+ }
111
+ }
112
+
113
+ return { stacks };
114
+ }
115
+
116
+ /**
117
+ * A location within the composed tree. Mirrors reveal.js's `{h, v, f}` indices
118
+ * so callers can feed it directly to `Reveal.slide(h, v, f?)`.
119
+ *
120
+ * - `h`: stack index
121
+ * - `v`: subslide index within that stack
122
+ * - `f`: fragment block index within the subslide, or `-1` for non-fragment
123
+ * cells (i.e. content that's visible before any fragment is revealed).
124
+ */
125
+ export interface SlideTarget {
126
+ h: number;
127
+ v: number;
128
+ f: number;
129
+ }
130
+
131
+ export interface SlideIndices<Id> {
132
+ /** Where each cell lives in the deck. */
133
+ cellToTarget: Map<Id, SlideTarget>;
134
+ /**
135
+ * `"h,v,f"` -> flat index into the original cell list. The value is the
136
+ * "active" cell for that position — i.e. the last cell currently visible
137
+ * on screen, so the active cell advances as the user steps through
138
+ * fragments.
139
+ *
140
+ * `f === -1` represents "nothing revealed yet" and is always populated when
141
+ * a subslide exists, so it doubles as a fallback for subslides that have
142
+ * no fragments at all (reveal reports `f === 0` in that case).
143
+ */
144
+ targetToCellIndex: Map<string, number>;
145
+ }
146
+
147
+ /**
148
+ * Build {@link SlideIndices} for a composition so callers can translate
149
+ * between a flat cell list and reveal.js's `{h, v, f}` indices.
150
+ */
151
+ export function buildSlideIndices<T, Id>({
152
+ composition,
153
+ cells,
154
+ getId,
155
+ }: {
156
+ composition: Composition<T>;
157
+ cells: readonly T[];
158
+ getId: (cell: T) => Id;
159
+ }): SlideIndices<Id> {
160
+ const cellToTarget = new Map<Id, SlideTarget>();
161
+ const targetToCellIndex = new Map<string, number>();
162
+ const cellIndexById = new Map<Id, number>();
163
+ for (const [i, cell] of cells.entries()) {
164
+ cellIndexById.set(getId(cell), i);
165
+ }
166
+
167
+ composition.stacks.forEach((stack, h) => {
168
+ stack.subslides.forEach((sub, v) => {
169
+ // f = -1: "nothing revealed yet" state. Active cell = last cell that
170
+ // appears before the first fragment block. If the subslide starts with
171
+ // a fragment block, fall back to its first cell so we still have an
172
+ // anchor when nothing is visible yet.
173
+ let preFragmentActiveId: Id | undefined;
174
+ for (const block of sub.blocks) {
175
+ if (block.isFragment) {
176
+ break;
177
+ }
178
+ const last = block.cells.at(-1);
179
+ if (last) {
180
+ preFragmentActiveId = getId(last);
181
+ }
182
+ }
183
+ if (preFragmentActiveId === undefined) {
184
+ const fallback = sub.blocks[0]?.cells[0];
185
+ if (fallback) {
186
+ preFragmentActiveId = getId(fallback);
187
+ }
188
+ }
189
+ if (preFragmentActiveId !== undefined) {
190
+ const idx = cellIndexById.get(preFragmentActiveId);
191
+ if (idx != null) {
192
+ targetToCellIndex.set(`${h},${v},-1`, idx);
193
+ }
194
+ }
195
+
196
+ let fragmentCounter = -1;
197
+ for (const block of sub.blocks) {
198
+ if (block.isFragment) {
199
+ fragmentCounter++;
200
+ for (const cell of block.cells) {
201
+ cellToTarget.set(getId(cell), { h, v, f: fragmentCounter });
202
+ }
203
+ const last = block.cells.at(-1);
204
+ if (last) {
205
+ const idx = cellIndexById.get(getId(last));
206
+ if (idx != null) {
207
+ targetToCellIndex.set(`${h},${v},${fragmentCounter}`, idx);
208
+ }
209
+ }
210
+ } else {
211
+ for (const cell of block.cells) {
212
+ cellToTarget.set(getId(cell), { h, v, f: -1 });
213
+ }
214
+ }
215
+ }
216
+ });
217
+ });
218
+ return { cellToTarget, targetToCellIndex };
219
+ }
220
+
221
+ /**
222
+ * Resolve the flat cell index for the current reveal indices. For slides
223
+ * without fragments reveal may report `f = 0`, so we fall back to the `-1`
224
+ * entry (which is always populated when a subslide exists).
225
+ */
226
+ export function resolveActiveCellIndex(
227
+ targetToCellIndex: ReadonlyMap<string, number>,
228
+ indices: { h: number; v: number; f: number },
229
+ ): number | undefined {
230
+ const { h, v, f } = indices;
231
+ return (
232
+ targetToCellIndex.get(`${h},${v},${f}`) ??
233
+ targetToCellIndex.get(`${h},${v},-1`)
234
+ );
235
+ }
236
+
237
+ function findDeckTarget<T, Id>({
238
+ start,
239
+ stop,
240
+ step,
241
+ cells,
242
+ cellToTarget,
243
+ getId,
244
+ }: {
245
+ start: number;
246
+ stop: number;
247
+ step: 1 | -1;
248
+ cells: readonly T[];
249
+ cellToTarget: ReadonlyMap<Id, SlideTarget>;
250
+ getId: (cell: T) => Id;
251
+ }): SlideTarget | undefined {
252
+ for (let i = start; i !== stop; i += step) {
253
+ const target = cellToTarget.get(getId(cells[i]));
254
+ if (target) {
255
+ return target;
256
+ }
257
+ }
258
+ return undefined;
259
+ }
260
+
261
+ /**
262
+ * Resolve the deck location that should back the currently selected minimap
263
+ * entry.
264
+ *
265
+ * Cells marked `"skip"` are not part of the composed deck; for those we
266
+ * "park" on the closest real slide in notebook order (preferring the
267
+ * predecessor). Handling the UI consequences of parking — ignoring reveal.js
268
+ * echo events and intercepting keyboard nav — is the caller's job.
269
+ */
270
+ export function resolveDeckNavigationTarget<T, Id>({
271
+ activeIndex,
272
+ cells,
273
+ cellToTarget,
274
+ getId,
275
+ }: {
276
+ activeIndex: number | undefined;
277
+ cells: readonly T[];
278
+ cellToTarget: ReadonlyMap<Id, SlideTarget>;
279
+ getId: (cell: T) => Id;
280
+ }): SlideTarget | undefined {
281
+ if (activeIndex == null) {
282
+ return undefined;
283
+ }
284
+
285
+ const activeCell = cells[activeIndex];
286
+ if (!activeCell) {
287
+ return undefined;
288
+ }
289
+
290
+ const directTarget = cellToTarget.get(getId(activeCell));
291
+ if (directTarget) {
292
+ return directTarget;
293
+ }
294
+
295
+ return (
296
+ findDeckTarget({
297
+ start: activeIndex - 1,
298
+ stop: -1,
299
+ step: -1,
300
+ cells,
301
+ cellToTarget,
302
+ getId,
303
+ }) ??
304
+ findDeckTarget({
305
+ start: activeIndex + 1,
306
+ stop: cells.length,
307
+ step: 1,
308
+ cells,
309
+ cellToTarget,
310
+ getId,
311
+ })
312
+ );
313
+ }
314
+
315
+ /**
316
+ * Decide where the deck should navigate to given the user's target cell, or
317
+ * `null` if the deck is already there (so the caller can skip the imperative
318
+ * `deck.slide()` call).
319
+ *
320
+ * Each minimap entry is a distinct navigation target, so the fragment index
321
+ * is always pinned:
322
+ * - fragment cells (`target.f >= 0`) advance to that fragment
323
+ * - non-fragment cells reset to `f = -1` so any revealed fragments on the
324
+ * destination slide collapse — otherwise jumping to the parent slide
325
+ * from elsewhere in the deck leaves fragments looking "completed"
326
+ * instead of clean.
327
+ */
328
+ export function computeDeckNavigation(
329
+ current: { h: number; v: number; f: number },
330
+ target: SlideTarget,
331
+ ): SlideTarget | null {
332
+ const next = { h: target.h, v: target.v, f: target.f >= 0 ? target.f : -1 };
333
+ if (current.h === next.h && current.v === next.v && current.f === next.f) {
334
+ return null;
335
+ }
336
+ return next;
337
+ }
@@ -5,7 +5,10 @@ import type { CellId } from "@/core/cells/ids";
5
5
  import type { CellColumnId } from "@/utils/id-tree";
6
6
  import { useEffect, useRef, useState } from "react";
7
7
  import type { ICellRendererProps } from "../editor/renderers/types";
8
- import type { SlidesLayout } from "../editor/renderers/slides-layout/types";
8
+ import type {
9
+ SlideType,
10
+ SlidesLayout,
11
+ } from "../editor/renderers/slides-layout/types";
9
12
  import {
10
13
  DndContext,
11
14
  DragOverlay,
@@ -29,8 +32,10 @@ import {
29
32
  import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
30
33
  import { cn } from "@/utils/cn";
31
34
  import { Slide } from "./slide";
32
- import { InfoIcon } from "lucide-react";
35
+ import { InfoIcon, type LucideIcon } from "lucide-react";
36
+ import { Tooltip } from "@/components/ui/tooltip";
33
37
  import { Logger } from "@/utils/Logger";
38
+ import { SLIDE_TYPE_OPTIONS_BY_VALUE } from "./slide-form";
34
39
 
35
40
  type Props = ICellRendererProps<SlidesLayout>;
36
41
  type SlideCell = Props["cells"][number];
@@ -48,34 +53,65 @@ interface ResolvedDropTarget {
48
53
  index: number;
49
54
  }
50
55
 
56
+ interface ThumbnailDimensions {
57
+ width: number;
58
+ height: number;
59
+ scale: number;
60
+ }
61
+
51
62
  interface SlideThumbnailCardProps extends React.HTMLAttributes<HTMLDivElement> {
52
63
  cell: SlideCell;
64
+ dimensions: ThumbnailDimensions;
53
65
  isActiveSlide?: boolean;
54
66
  isActiveDragSource?: boolean;
55
67
  isOverlay?: boolean;
56
68
  isVisible?: boolean;
69
+ slideType?: SlideType;
57
70
  ref?: React.Ref<HTMLDivElement>;
58
71
  }
59
72
 
60
73
  interface SlideThumbnailRowProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
61
74
  cell: SlideCell;
75
+ dimensions: ThumbnailDimensions;
62
76
  isActiveSlide?: boolean;
63
77
  dropIndicator?: DropPosition | null;
64
78
  isActiveDragSource?: boolean;
65
79
  isVisible?: boolean;
80
+ slideType?: SlideType;
66
81
  ref?: React.Ref<HTMLButtonElement>;
67
82
  }
68
83
 
69
84
  interface SlidesMinimapProps {
70
85
  cells: SlideCell[];
86
+ thumbnailWidth: number;
71
87
  canReorder: boolean;
72
88
  activeCellId: CellId | null;
89
+ // Set of cell ids that are marked `skip` in the slides layout.
90
+ skippedIds?: ReadonlySet<CellId>;
91
+ slideTypes?: ReadonlyMap<CellId, SlideType>;
73
92
  onSlideClick: (index: number) => void;
74
93
  }
75
94
 
76
- const THUMBNAIL_WIDTH = 256;
77
- const THUMBNAIL_HEIGHT = 144;
78
- const THUMBNAIL_SCALE = 0.3;
95
+ function getSlideTypeVisual(
96
+ slideType: SlideType | undefined,
97
+ ): { label: string; description: string; Icon: LucideIcon } | null {
98
+ if (!slideType || slideType === "slide") {
99
+ return null;
100
+ }
101
+ const { label, description, Icon } = SLIDE_TYPE_OPTIONS_BY_VALUE[slideType];
102
+ return { label, description, Icon };
103
+ }
104
+
105
+ const SLIDE_ASPECT_RATIO = 16 / 9;
106
+ const SLIDE_BASE_WIDTH = 960;
107
+
108
+ function computeThumbnailDimensions(width: number): ThumbnailDimensions {
109
+ return {
110
+ width,
111
+ height: width / SLIDE_ASPECT_RATIO,
112
+ scale: width / SLIDE_BASE_WIDTH,
113
+ };
114
+ }
79
115
  const MINIMAP_AUTO_SCROLL = {
80
116
  threshold: { x: 0, y: 0.1 },
81
117
  };
@@ -156,8 +192,11 @@ function useVisibleCellIds(
156
192
 
157
193
  export const SlidesMinimap = ({
158
194
  cells,
195
+ thumbnailWidth,
159
196
  canReorder,
160
197
  activeCellId,
198
+ skippedIds,
199
+ slideTypes,
161
200
  onSlideClick,
162
201
  }: SlidesMinimapProps) => {
163
202
  const cellIds = useCellIds();
@@ -168,6 +207,7 @@ export const SlidesMinimap = ({
168
207
  const [dropTarget, setDropTarget] = useState<ProjectedDropTarget | null>(
169
208
  null,
170
209
  );
210
+ const dimensions = computeThumbnailDimensions(thumbnailWidth);
171
211
 
172
212
  useEffect(() => {
173
213
  if (!activeCellId || !containerRef.current) {
@@ -245,8 +285,14 @@ export const SlidesMinimap = ({
245
285
  <SlideThumbnailRow
246
286
  key={cell.id}
247
287
  cell={cell}
288
+ dimensions={dimensions}
248
289
  isActiveSlide={cell.id === activeCellId}
249
290
  isVisible={visibleIds.has(cell.id)}
291
+ slideType={resolveSlideType({
292
+ cellId: cell.id,
293
+ slideTypes,
294
+ skippedIds,
295
+ })}
250
296
  onClick={() => onSlideClick(index)}
251
297
  />
252
298
  ))}
@@ -275,9 +321,15 @@ export const SlidesMinimap = ({
275
321
  <SortableSlideThumbnail
276
322
  key={cell.id}
277
323
  cell={cell}
324
+ dimensions={dimensions}
278
325
  isActive={activeId === cell.id}
279
326
  isActiveSlide={cell.id === activeCellId}
280
327
  isVisible={visibleIds.has(cell.id)}
328
+ slideType={resolveSlideType({
329
+ cellId: cell.id,
330
+ slideTypes,
331
+ skippedIds,
332
+ })}
281
333
  dropIndicator={
282
334
  dropTarget?.overId === cell.id && activeId !== cell.id
283
335
  ? dropTarget.position
@@ -292,6 +344,7 @@ export const SlidesMinimap = ({
292
344
  {activeCell && (
293
345
  <SlideThumbnailCard
294
346
  cell={activeCell}
347
+ dimensions={dimensions}
295
348
  isOverlay={true}
296
349
  isActiveDragSource={true}
297
350
  />
@@ -320,19 +373,23 @@ const SlideThumbnailsContainer = ({
320
373
 
321
374
  interface SortableSlideThumbnailProps {
322
375
  cell: SlideCell;
376
+ dimensions: ThumbnailDimensions;
323
377
  dropIndicator?: DropPosition | null;
324
378
  isActive: boolean;
325
379
  isActiveSlide?: boolean;
326
380
  isVisible?: boolean;
381
+ slideType?: SlideType;
327
382
  onClick?: () => void;
328
383
  }
329
384
 
330
385
  const SortableSlideThumbnail = ({
331
386
  cell,
387
+ dimensions,
332
388
  dropIndicator,
333
389
  isActive,
334
390
  isActiveSlide,
335
391
  isVisible,
392
+ slideType,
336
393
  onClick,
337
394
  }: SortableSlideThumbnailProps) => {
338
395
  const { attributes, listeners, setNodeRef } = useSortable({
@@ -343,10 +400,12 @@ const SortableSlideThumbnail = ({
343
400
  <SlideThumbnailRow
344
401
  ref={setNodeRef}
345
402
  cell={cell}
403
+ dimensions={dimensions}
346
404
  dropIndicator={dropIndicator}
347
405
  isActiveDragSource={isActive}
348
406
  isActiveSlide={isActiveSlide}
349
407
  isVisible={isVisible}
408
+ slideType={slideType}
350
409
  onClick={onClick}
351
410
  {...attributes}
352
411
  {...listeners}
@@ -356,12 +415,14 @@ const SortableSlideThumbnail = ({
356
415
 
357
416
  const SlideThumbnailRow = ({
358
417
  cell,
418
+ dimensions,
359
419
  className,
360
420
  style,
361
421
  dropIndicator,
362
422
  isActiveSlide = false,
363
423
  isActiveDragSource = false,
364
424
  isVisible,
425
+ slideType,
365
426
  onClick,
366
427
  ref,
367
428
  ...props
@@ -397,9 +458,11 @@ const SlideThumbnailRow = ({
397
458
  )}
398
459
  <SlideThumbnailCard
399
460
  cell={cell}
461
+ dimensions={dimensions}
400
462
  isActiveSlide={isActiveSlide}
401
463
  isActiveDragSource={isActiveDragSource}
402
464
  isVisible={isVisible}
465
+ slideType={slideType}
403
466
  />
404
467
  </button>
405
468
  );
@@ -407,24 +470,30 @@ const SlideThumbnailRow = ({
407
470
 
408
471
  const SlideThumbnailCard = ({
409
472
  cell,
473
+ dimensions,
410
474
  className,
411
475
  style,
412
476
  isActiveSlide = false,
413
477
  isActiveDragSource = false,
414
478
  isOverlay = false,
415
479
  isVisible = false,
480
+ slideType,
416
481
  ref,
417
482
  ...props
418
483
  }: SlideThumbnailCardProps) => {
484
+ const { width, height, scale } = dimensions;
485
+ const visual = getSlideTypeVisual(slideType);
486
+ const isSkipped = slideType === "skip";
487
+
419
488
  const outerStyle: React.CSSProperties = {
420
- width: THUMBNAIL_WIDTH,
421
- height: THUMBNAIL_HEIGHT,
489
+ width,
490
+ height,
422
491
  contain: "strict",
423
492
  ...(isOverlay
424
493
  ? null
425
494
  : {
426
495
  contentVisibility: "auto",
427
- containIntrinsicSize: `${THUMBNAIL_WIDTH}px ${THUMBNAIL_HEIGHT}px`,
496
+ containIntrinsicSize: `${width}px ${height}px`,
428
497
  }),
429
498
  ...style,
430
499
  };
@@ -435,7 +504,7 @@ const SlideThumbnailCard = ({
435
504
  <div
436
505
  ref={ref}
437
506
  className={cn(
438
- "border-2 shrink-0 rounded-md relative select-none bg-background cursor-pointer active:cursor-grabbing",
507
+ "border-2 shrink-0 rounded-md relative select-none bg-background cursor-pointer active:cursor-grabbing overflow-hidden",
439
508
  isActiveSlide || isActiveDragSource || isOverlay
440
509
  ? "border-blue-500"
441
510
  : "border-border",
@@ -450,15 +519,41 @@ const SlideThumbnailCard = ({
450
519
  <div
451
520
  className="flex p-6 box-border pointer-events-none mo-slide-content overflow-hidden"
452
521
  style={{
453
- transform: `scale(${THUMBNAIL_SCALE})`,
522
+ transform: `scale(${scale})`,
454
523
  transformOrigin: "top left",
455
- width: THUMBNAIL_WIDTH / THUMBNAIL_SCALE,
456
- height: THUMBNAIL_HEIGHT / THUMBNAIL_SCALE,
524
+ width: width / scale,
525
+ height: height / scale,
457
526
  }}
458
527
  >
459
528
  <Slide cellId={cell.id} status={cell.status} output={cell.output} />
460
529
  </div>
461
530
  )}
531
+ {isSkipped && (
532
+ <div
533
+ className="absolute inset-0 bg-muted/60 pointer-events-none"
534
+ aria-hidden={true}
535
+ />
536
+ )}
537
+ {visual && (
538
+ <Tooltip
539
+ content={
540
+ <span className="text-xs opacity-80">{visual.description}</span>
541
+ }
542
+ >
543
+ <span
544
+ className={cn(
545
+ "absolute top-1 right-1 flex items-center gap-1 rounded-full border p-0.5 font-medium leading-none shadow-xs cursor-help",
546
+ isSkipped
547
+ ? "bg-background/90 border-border text-muted-foreground"
548
+ : "bg-background/95 border-border text-foreground/80",
549
+ )}
550
+ aria-label={visual.label}
551
+ >
552
+ <visual.Icon className="h-3.5 w-3.5" />
553
+ {/* <span>{visual.label}</span> */}
554
+ </span>
555
+ </Tooltip>
556
+ )}
462
557
  </div>
463
558
  );
464
559
  };
@@ -527,6 +622,32 @@ function asCellId(id: UniqueIdentifier): CellId | null {
527
622
  return typeof id === "string" ? (id as CellId) : null;
528
623
  }
529
624
 
625
+ /**
626
+ * Resolves the effective slide type for a cell. Falls back to `"skip"` if the
627
+ * caller-supplied `skippedIds` set marks the cell as skipped, which keeps this
628
+ * component working when only the legacy `skippedIds` prop is provided.
629
+ * Returns `undefined` for the default `"slide"` type so callers can skip
630
+ * rendering any badge.
631
+ */
632
+ function resolveSlideType({
633
+ cellId,
634
+ slideTypes,
635
+ skippedIds,
636
+ }: {
637
+ cellId: CellId;
638
+ slideTypes: ReadonlyMap<CellId, SlideType> | undefined;
639
+ skippedIds: ReadonlySet<CellId> | undefined;
640
+ }): SlideType | undefined {
641
+ const type = slideTypes?.get(cellId);
642
+ if (type && type !== "slide") {
643
+ return type;
644
+ }
645
+ if (skippedIds?.has(cellId)) {
646
+ return "skip";
647
+ }
648
+ return undefined;
649
+ }
650
+
530
651
  export const exportedForTesting = {
531
652
  useVisibleCellIds,
532
653
  projectDropTarget,