Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 3 additions & 2 deletions frontend/e2e-tests/slides.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ test("slides", async ({ page }) => {

await takeScreenshot(page, __filename);

// Reveal.js marks the active slide <section> with .present
const slides = slidesContainer.locator(".slides > section");
// Reveal.js marks the active slide <section> with .present. We match by
// `data-index-h` (which reveal sets on each slide section)
const slides = slidesContainer.locator("section[data-index-h]");
await expect(slides.first()).toHaveClass(/present/);

// Focus the deck so keyboard navigation works (embedded mode)
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 @@ -198,15 +199,53 @@ const BACKWARDS_COMPAT_SNAPSHOTS: BackwardsCompatCase[] = [
},
{
// Defensive: if a future version adds a new SlideConfig field and a user
// downgrades, we must not crash on unknown keys.
// downgrades, we must not crash on unknown keys β€” AND we must not silently
// drop them either. `notes` / `background` aren't in the current schema;
// they must still be present after validate + (de)serialize so a downgrade
// followed by a save doesn't erase the newer marimo's data.
label: "forward-compat: unknown SlideConfig fields present",
input: {
cells: [{ type: "slide", notes: "x", background: "#000" }],
},
expected: {
deck: {},
cellIds: ["a"],
cellEntries: [["a", { type: "slide" }]],
cellEntries: [["a", { type: "slide", notes: "x", background: "#000" }]],
},
},
{
// Same forward-compat guarantee for unknown deck-level fields (e.g. future
// Reveal options we haven't modeled yet).
label: "forward-compat: unknown deck fields present",
input: {
cells: [{}],
deck: { transition: "fade", controls: false, autoSlide: 5000 },
},
expected: {
deck: { transition: "fade", controls: false, autoSlide: 5000 },
cellIds: ["a"],
},
},
{
// `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" }],
],
},
},
];
Expand All @@ -223,20 +262,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 +291,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
71 changes: 40 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,50 @@
/* 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.looseObject({
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.looseObject({
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. Unknown
* keys are preserved (via `looseObject`) for the same reason.
*/
// 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.looseObject({
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