Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/guides/apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ If you prefer a slideshow-like experience, you can use the slides layout. Enable

- A slide minimap on the left where you can drag and drop slides to rearrange them.
- A config sidebar on the right where you can configure the type of each slide.
- Edit code and run cells by clicking the Code toggle or using the keyboard shortcut `C`.
- Edit code and run cells by clicking the Code toggle or pressing `C`.
- Add speaker notes at the bottom of each slide and launch speaker view by pressing `S`.
- Powered by [reveal.js](https://revealjs.com/), so you can use most of its features like keyboard shortcuts, navigation, etc.

#### Notes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, it, expect } from "vitest";
import { computeSlideCellsInfo } from "../compute-slide-cells";
import type { SlideConfig, SlidesLayout } from "../types";
import type { CellId } from "@/core/cells/ids";
import { cellId } from "@/__tests__/branded";

interface TestCell {
id: CellId;
Expand All @@ -15,10 +16,10 @@ const DEFAULT_OUTPUT: TestCell["output"] = { data: "ok" };
const cell = (
id: string,
output: TestCell["output"] = DEFAULT_OUTPUT,
): TestCell => ({ id: id as CellId, output });
): TestCell => ({ id: cellId(id), output });

const layoutOf = (entries: Array<[string, SlideConfig]>): SlidesLayout => ({
cells: new Map(entries.map(([id, cfg]) => [id as CellId, cfg])),
cells: new Map(entries.map(([id, cfg]) => [cellId(id), cfg])),
deck: {},
});

Expand Down Expand Up @@ -121,7 +122,7 @@ describe("computeSlideCellsInfo", () => {
// Skipped cells are still "visible" deck cells β€” they just aren't rendered
// in reveal. The minimap relies on the full list plus skippedIds.
expect(result.cellsWithOutput.map((c) => c.id)).toEqual(["a", "b", "c"]);
expect(result.slideTypes.get("b" as CellId)).toBe("skip");
expect(result.slideTypes.get(cellId("b"))).toBe("skip");
});

it("ignores layout entries for cells that have no output", () => {
Expand All @@ -137,7 +138,7 @@ describe("computeSlideCellsInfo", () => {
);
expect(result.cellsWithOutput.map((c) => c.id)).toEqual(["a"]);
expect(result.skippedIds.size).toBe(0);
expect(result.slideTypes.has("b" as CellId)).toBe(false);
expect(result.slideTypes.has(cellId("b"))).toBe(false);
});

it("preserves the input order of cells in cellsWithOutput", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { describe, it, expect } from "vitest";
import { SlidesLayoutPlugin } from "../plugin";
import type { CellData } from "@/core/cells/types";
import type { CellId } from "@/core/cells/ids";
import { cellId } from "@/__tests__/branded";

function makeCell(id: string, code = "print('hi')"): CellData {
return {
id: id as CellId,
id: cellId(id),
name: id,
code,
edited: false,
Expand Down Expand Up @@ -209,6 +210,28 @@ const BACKWARDS_COMPAT_SNAPSHOTS: BackwardsCompatCase[] = [
cellEntries: [["a", { type: "slide" }]],
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
},
},
{
// `speakerNotes` was added to SlideConfig. The validator must
// know about it (so it isn't silently stripped), the deserializer must
// carry it through, and serialize β†’ deserialize must round-trip it.
label: "speakerNotes round-trips through validate + (de)serialize",
input: {
cells: [
{ type: "slide", speakerNotes: "intro" },
{ type: "fragment", speakerNotes: "" },
{ type: "fragment", speakerNotes: "multi\n\nline\n\nnotes" },
],
},
expected: {
deck: {},
cellIds: ["a", "b", "c"],
cellEntries: [
["a", { type: "slide", speakerNotes: "intro" }],
["b", { type: "fragment", speakerNotes: "" }],
["c", { type: "fragment", speakerNotes: "multi\n\nline\n\nnotes" }],
],
},
},
];

describe("SlidesLayoutPlugin backwards compatibility", () => {
Expand All @@ -223,20 +246,21 @@ describe("SlidesLayoutPlugin backwards compatibility", () => {
parsed.success,
`validator rejected: ${JSON.stringify(input)}`,
).toBe(true);
if (!parsed.success) {
return;
}

// 2. Deserialize must succeed and reflect the user-set fields.
const layout = SlidesLayoutPlugin.deserializeLayout(
// Use the raw input (not the validator output) because that is what
// `deserializeLayout` actually receives in production today.
// oxlint-disable-next-line typescript/no-explicit-any
input as any,
cells,
);
// 2. Deserializing the *validator output* (not the raw input) must
// preserve every field listed in `expected.cellEntries`. This is what
// catches a schema regression: if the validator silently strips a
// known field, the deserialized config won't carry it and the
// assertion below fails.
const layout = SlidesLayoutPlugin.deserializeLayout(parsed.data, cells);
if (expected.deck !== undefined) {
expect(layout.deck).toEqual(expected.deck);
}
for (const [cellId, expectedConfig] of expected.cellEntries ?? []) {
expect(layout.cells.get(cellId as CellId)).toMatchObject(
for (const [cellEntryId, expectedConfig] of expected.cellEntries ?? []) {
expect(layout.cells.get(cellId(cellEntryId))).toMatchObject(
expectedConfig as object,
);
}
Expand All @@ -251,8 +275,8 @@ describe("SlidesLayoutPlugin backwards compatibility", () => {
reserialized,
cells,
);
for (const [cellId, expectedConfig] of expected.cellEntries ?? []) {
expect(redeserialized.cells.get(cellId as CellId)).toMatchObject(
for (const [cellEntryId, expectedConfig] of expected.cellEntries ?? []) {
expect(redeserialized.cells.get(cellId(cellEntryId))).toMatchObject(
expectedConfig as object,
);
}
Expand Down
33 changes: 8 additions & 25 deletions frontend/src/components/editor/renderers/slides-layout/plugin.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
/* Copyright 2026 Marimo. All rights reserved. */

import { z } from "zod";
import type { CellId } from "@/core/cells/ids";
import { Logger } from "@/utils/Logger";
import type { ICellRendererPlugin } from "../types";
import { SlidesLayoutRenderer } from "./slides-layout";
import type {
SerializedSlidesLayout,
SlideConfig,
SlidesLayout,
import {
type SerializedSlidesLayout,
type SlideConfig,
type SlidesLayout,
SlidesLayoutSchema,
} from "./types";
import { Logger } from "@/utils/Logger";
import type { CellId } from "@/core/cells/ids";

/**
* Plugin definition for the slides layout.
Expand All @@ -20,24 +20,7 @@ export const SlidesLayoutPlugin: ICellRendererPlugin<
> = {
type: "slides",
name: "Slides",

// All fields are optional so layouts saved by older marimo versions will work
validator: z.object({
cells: z
.array(
z.object({
type: z.enum(["slide", "sub-slide", "fragment", "skip"]).optional(),
}),
)
.optional(),
deck: z
.object({
transition: z
.enum(["none", "fade", "slide", "convex", "concave", "zoom"])
.optional(),
})
.optional(),
}),
validator: SlidesLayoutSchema,

deserializeLayout: (serialized, cells): SlidesLayout => {
const serializedCells = serialized.cells ?? [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, { useMemo, useState } from "react";
import { useAtomValue } from "jotai";
import { numColumnsAtom } from "@/core/cells/cells";
import type { CellId } from "@/core/cells/ids";
import { kioskModeAtom } from "@/core/mode";
import type { ICellRendererProps } from "../types";
import type { SlidesLayout } from "./types";
import { computeSlideCellsInfo } from "./compute-slide-cells";
Expand All @@ -21,7 +22,10 @@ export const SlidesLayoutRenderer: React.FC<Props> = ({
cells,
mode,
}) => {
const isReading = mode === "read";
// Kiosk clients (e.g. reveal.js's speaker-view iframes) are read-only and
// shouldn't show authoring chrome, so we collapse to the read-mode layout.
const kioskMode = useAtomValue(kioskModeAtom);
const isReading = mode === "read" || kioskMode;
const numColumns = useAtomValue(numColumnsAtom);
const isMultiColumn = numColumns > 1;
const [activeCellId, setActiveCellId] = useState<CellId | null>(null);
Expand Down Expand Up @@ -52,14 +56,23 @@ export const SlidesLayoutRenderer: React.FC<Props> = ({
activeIndex={resolvedIndex}
onSlideChange={handleSlideChange}
configWidth={300}
mode={mode}
isEditable={mode !== "read"}
mode={isReading ? "read" : mode}
isEditable={!isReading}
/>
);

if (isReading) {
// Cap the deck height and derive width from height via aspect-video so it stays 16:9 without
// ballooning to the full viewport on wide screens.
// In kiosk mode (e.g. reveal.js's speaker-view iframes), anchor to the
// iframe viewport with `dvh`/`dvw` so the deck resizes with the popup
// window. The non-kiosk read mode keeps its 16:9 cap so the deck doesn't
// balloon to the full viewport on wide screens.
if (kioskMode) {
return (
<div className="flex h-dvh w-dvw overflow-hidden bg-background">
{slides}
</div>
);
}
return (
<div className="p-4 flex flex-1 items-center justify-center min-h-0">
<div className="h-full max-h-[95vh] aspect-video max-w-full flex">
Expand Down
70 changes: 39 additions & 31 deletions frontend/src/components/editor/renderers/slides-layout/types.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,49 @@
/* Copyright 2026 Marimo. All rights reserved. */
/* oxlint-disable typescript/no-empty-object-type */

import { z } from "zod";
import type { CellId } from "@/core/cells/ids";

const SlideTypeSchema = z.enum(["slide", "sub-slide", "fragment", "skip"]);
export type SlideType = z.infer<typeof SlideTypeSchema>;

const SlideConfigSchema = z.object({
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
type: SlideTypeSchema.optional(),
speakerNotes: z.string().optional(),
});
export type SlideConfig = z.infer<typeof SlideConfigSchema>;

const DeckTransitionSchema = z.enum([
"none",
"fade",
"slide",
"convex",
"concave",
"zoom",
]);
export type DeckTransition = z.infer<typeof DeckTransitionSchema>;

const DeckConfigSchema = z.object({
transition: DeckTransitionSchema.optional(),
});
export type DeckConfig = z.infer<typeof DeckConfigSchema>;

/**
* The serialized form of a slides layout.
* This must be backwards-compatible as it is stored on the user's disk.
* Schema for the serialized form of a slides layout.
*
* This must be backwards-compatible as it is stored on the user's disk β€”
* fields are optional so files saved before they existed (e.g. the bare `{}`
* emitted by earlier marimo versions) still deserialize cleanly.
*/
// oxlint-disable-next-line typescript/consistent-type-definitions
export type SerializedSlidesLayout = {
// Both fields are optional so files saved before these existed (e.g. the
// bare `{}` emitted by earlier marimo versions) still deserialize cleanly.
deck?: DeckConfig;
cells?: SlideConfig[];
};
export const SlidesLayoutSchema = z.object({
cells: z.array(SlideConfigSchema).optional(),
deck: DeckConfigSchema.optional(),
});
export type SerializedSlidesLayout = z.infer<typeof SlidesLayoutSchema>;

export interface SlidesLayout extends Omit<
SerializedSlidesLayout,
"cells" | "deck"
> {
// We map the cells to their IDs so that we can track them as they move around.
/**
* Runtime form of a slides layout.
*/
export interface SlidesLayout {
cells: Map<CellId, SlideConfig>;
deck: DeckConfig;
}

export type SlideType = "slide" | "sub-slide" | "fragment" | "skip";
export interface SlideConfig {
type?: SlideType;
}

export type DeckTransition =
| "none"
| "fade"
| "slide"
| "convex"
| "concave"
| "zoom";
export interface DeckConfig {
transition?: DeckTransition;
}
Loading
Loading