# Getting started (/docs/getting-started) ## Install [#install] npm pnpm yarn bun ```bash npx @open-slide/cli init my-deck cd my-deck npm run dev ``` ```bash pnpm dlx @open-slide/cli init my-deck cd my-deck pnpm run dev ``` ```bash yarn dlx @open-slide/cli init my-deck cd my-deck yarn dev ``` ```bash bun x @open-slide/cli init my-deck cd my-deck bun run dev ``` The scaffolder generates a minimal workspace: ```text my-deck/ ├── slides/ │ └── getting-started/ │ └── index.tsx # your first slide ├── themes/ # optional, shared design systems ├── open-slide.config.ts # framework config ├── CLAUDE.md # mirrors AGENTS.md ├── AGENTS.md # agent rules └── package.json ``` `npm run dev` boots the dev server on `http://localhost:5173` (or the next free port) with hot module reload, the inspector, and the assets panel. ## Author with an agent [#author-with-an-agent] Open the workspace in any agent-aware editor — Claude Code, Codex, Cursor, Windsurf, Zed, etc. From the agent prompt: ```text /create-slide for "Q2 launch — 3 chapters, mixed text density, subtle motion, dark aesthetic" ``` The skill walks the agent through scoping (topic & aesthetic, page count, density, motion), picks an id, plans the structure, and writes the pages under `slides//index.tsx`. ## Iterate visually [#iterate-visually] 1. Click any element in the browser preview. 2. Pick **Comment** and type a note — *"use the accent colour on this title"*, *"shrink to 88px"*, *"swap to assets/cover.png"*. 3. Save. Comments are persisted as `@slide-comment` markers in the source. 4. Run `/apply-comments` in your agent. It rewrites exactly the flagged regions and clears the markers. You can also edit text, font, weight, and colour directly from the inspector property panel — all edits buffer in memory until you press **Save**, so a batch lands as a single HMR write. ## Build & deploy [#build--deploy] npm pnpm yarn bun ```bash npm run build # static build to dist/ npm run preview # preview the static build locally ``` ```bash pnpm run build # static build to dist/ pnpm run preview # preview the static build locally ``` ```bash yarn build # static build to dist/ yarn preview # preview the static build locally ``` ```bash bun run build # static build to dist/ bun run preview # preview the static build locally ``` The output is a plain static site — deploy to Vercel, Cloudflare Pages, Zeabur, Netlify, or any static host. ## What's next [#whats-next] # Introduction (/docs) open-slide editor showing a deck with the inspector and assets panel docked alongside the canvas **open-slide** is a slide framework for the agent era. You describe a deck in natural language; your coding agent writes the React. open-slide handles the canvas, scaling, navigation, hot reload, present mode, and export so the agent can focus on content. Every page renders into a fixed **1920 × 1080** canvas. Slides are arbitrary React components — not a constrained DSL. npm pnpm yarn bun ```bash npx @open-slide/cli init ``` ```bash pnpm dlx @open-slide/cli init ``` ```bash yarn dlx @open-slide/cli init ``` ```bash bun x @open-slide/cli init ``` ## Why open-slide [#why-open-slide] Slides are visual code. Agents are great at writing code. open-slide is the missing runtime that turns *"make slides about X"* into a polished, presentable deck — without you ever leaving the chat. * **Agent-native authoring.** Ships with skills (`/create-slide`, `/slide-authoring`, `/apply-comments`, `/create-theme`) that any Claude Code, Codex, or Cursor session can call. * **In-browser inspector.** Click any element in the dev server, drop a `@slide-comment`, and your agent applies it on the next `/apply-comments`. * **Assets manager + svgl.** Drop in images, search 1500+ brand logos through the integrated [svgl](https://svgl.app/) catalogue. * **Professional present mode.** Fullscreen playback, presenter view, speaker notes, timer. * **Export to static HTML or PDF.** Self-contained output — share without a server. ## A slide is a file [#a-slide-is-a-file] A slide is a React component. The runtime renders one component at a time onto a fixed 1920×1080 canvas, scales it to fit the viewport, and hands you the navigation, hot reload, and present mode. Each deck lives under `slides//`. The entry is `slides//index.tsx`. Assets sit alongside in `slides//assets/`. ```tsx title="slides/getting-started/index.tsx" import type { Page, SlideMeta } from '@open-slide/core'; const Cover: Page = () => (

Hello, open-slide.

); export const meta: SlideMeta = { title: 'Getting started', theme: 'corporate' }; export default [Cover] satisfies Page[]; ``` Two rules: the default export is an array of components (one per page), and each component fills its container (`width: '100%'` / `height: '100%'`). No DSL, no markdown chain — if you can build it in React, it's a slide. ## The 1920 × 1080 canvas [#the-1920--1080-canvas] Every page renders into a fixed 1920×1080 box. The runtime scales that box uniformly to fit the viewport, so the same slide looks identical on a phone, a 4K monitor, and a beamer. Fixed canvas + uniform scaling means every pixel coordinate you write is the coordinate that ships — preview, stage, and exported PDF stay in lockstep. The constants are exported if you need them: ```ts import { CANVAS_WIDTH, CANVAS_HEIGHT } from '@open-slide/core'; // 1920 × 1080 ``` Inside a page, two layout patterns work well: **absolute coordinates** (`position: absolute` + pixel values — predictable, easy for an agent to write) and **flex/grid centring** for content that flows. Avoid responsive breakpoints; the canvas is fixed. Container queries (`containerType: 'inline-size'` + `cqw` units) are a useful middle ground for reusable widgets. ## What's next [#whats-next] # open-slide build (/docs/cli/build) npm pnpm yarn bun ```bash open-slide build # or npm run build ``` ```bash open-slide build # or pnpm run build ``` ```bash open-slide build # or yarn build ``` ```bash open-slide build # or bun run build ``` Builds every deck under `slides/` into a self-contained static site at `dist/`. The output has no server requirement — drop it on any static host. ## Flags [#flags] | Flag | Default | Description | | ----------------- | ------- | ------------------------------------------ | | `--out-dir ` | `dist` | Write the static build to a custom folder. | ## Output [#output] ```text dist/ ├── index.html ├── assets/ │ └── ...hashed files... └── ... ``` ## Trimming the public surface [#trimming-the-public-surface] If the build is for a public share rather than internal review, you can hide the slide browser and on-canvas UI in `open-slide.config.ts`: ```ts const config: OpenSlideConfig = { build: { showSlideBrowser: false, showSlideUi: false, allowHtmlDownload: false, }, }; ``` See [Export](/docs/core-feature/export) for the full set of build knobs. ## Deploying under a subpath [#deploying-under-a-subpath] If the build won't sit at the domain root (GitHub Pages project sites, a reverse-proxy folder, an intranet path), set `base` in `open-slide.config.ts` so asset URLs and client-side navigation resolve against the deployed path: ```ts const config: OpenSlideConfig = { base: '/my-slides/', }; ``` See [Config](/docs/reference/config) for details. # open-slide dev (/docs/cli/dev) npm pnpm yarn bun ```bash open-slide dev # or, via the generated script npm run dev ``` ```bash open-slide dev # or, via the generated script pnpm run dev ``` ```bash open-slide dev # or, via the generated script yarn dev ``` ```bash open-slide dev # or, via the generated script bun run dev ``` Boots a Vite-powered dev server on `http://localhost:5173` (or the next free port). Hot module reload is wired up for both the runtime and your slide files — saving an `index.tsx` updates the canvas without losing slide state. ## What you get [#what-you-get] * **Slide browser** — list of every deck under `slides/`. * **Canvas** — the active page, scaled to fit the viewport. * **Inspector** — click any element to edit it visually, drop a `@slide-comment`, or both. * **Assets panel** — drag, rename, replace, plus svgl logo search. * **Present mode** — fullscreen playback with presenter view. ## Flags [#flags] | Flag | Default | Description | | ------------------- | -------------- | -------------------------------------------------------- | | `-p, --port ` | from config | Override the listening port. | | `--host [host]` | localhost only | Expose the dev server on the network. Optional host pin. | | `--open` | off | Open the browser on start. | | `--no-skills-check` | off | Skip the built-in skills drift check. | The skills check also obeys the `OPEN_SLIDE_SKIP_SKILLS_CHECK=1` environment variable — useful in CI or container builds where stdin isn't a TTY. ## Configuration [#configuration] Override the port (and a few other knobs) in `open-slide.config.ts`: ```ts title="open-slide.config.ts" import type { OpenSlideConfig } from '@open-slide/core'; const config: OpenSlideConfig = { port: 5174, slidesDir: 'slides', }; export default config; ``` # open-slide init (/docs/cli/init) npm pnpm yarn bun ```bash npx @open-slide/cli init [directory] ``` ```bash pnpm dlx @open-slide/cli init [directory] ``` ```bash yarn dlx @open-slide/cli init [directory] ``` ```bash bun x @open-slide/cli init [directory] ``` If `directory` is omitted, the scaffolder uses the current folder. If it's provided and doesn't exist, it's created. ## Flags [#flags] | Flag | Default | Description | | ------------------- | ------------- | -------------------------------------------------------------------- | | `-f, --force` | off | Scaffold into the target even if it's not empty. | | `-n, --name ` | folder name | Override the generated `package.json` name. | | `--use-npm` | auto-detected | Install with npm. | | `--use-pnpm` | auto-detected | Install with pnpm. | | `--use-yarn` | auto-detected | Install with yarn. | | `--use-bun` | auto-detected | Install with bun. | | `--no-install` | off | Skip dependency installation. You'll have to run `install` yourself. | | `--no-git` | off | Skip `git init` and the initial commit. | | `--locale ` | prompted | Slide UI language. One of `en`, `zh-TW`, `zh-CN`, `ja`. | When no `--use-*` flag is passed, the scaffolder reads `npm_config_user_agent` (set by your launcher) to pick `pnpm`, `yarn`, or `bun`, and falls back to `npm` otherwise. Pass an explicit flag to force a specific manager — handy in CI or when `npx` was run under a different agent than the one you actually want to use. When `--locale` is omitted, an interactive run asks you to pick a language and writes the matching preset into `open-slide.config.ts` (see the [Locale reference](/docs/reference/locale)). Non-interactive runs default to `en`. ## What it generates [#what-it-generates] ```text my-deck/ ├── slides/ │ └── getting-started/ │ └── index.tsx # starter slide ├── themes/ # empty ├── open-slide.config.ts # framework config ├── CLAUDE.md # mirrors AGENTS.md ├── AGENTS.md # agent rules ├── package.json ├── tsconfig.json └── netlify.toml / vercel.json # one-click deploy configs ``` ## After init [#after-init] npm pnpm yarn bun ```bash cd my-deck npm install npm run dev ``` ```bash cd my-deck pnpm install pnpm run dev ``` ```bash cd my-deck yarn install yarn dev ``` ```bash cd my-deck bun install bun run dev ``` The dev server boots, opens the slide browser, and you're ready for your agent to take over. # Overview (/docs/cli/overview) open-slide ships two CLIs: * **`@open-slide/cli`** — the scaffolder. One command (`init`) to bootstrap a new workspace. * **`@open-slide/core`** — the runtime CLI. `dev`, `build`, `preview`, and `sync:skills` for an existing workspace. After `init`, `package.json` exposes the runtime CLI under standard scripts: ```json { "scripts": { "dev": "open-slide dev", "build": "open-slide build", "preview": "open-slide preview", "sync:skills": "open-slide sync:skills" } } ``` So in day-to-day use you'll just type `npm run dev`, `npm run build`, etc. # open-slide preview (/docs/cli/preview) npm pnpm yarn bun ```bash open-slide preview # or npm run preview ``` ```bash open-slide preview # or pnpm run preview ``` ```bash open-slide preview # or yarn preview ``` ```bash open-slide preview # or bun run preview ``` Serves `dist/` on a local port so you can rehearse the deck the way it will ship — same scaling, same routing, no dev-only chrome. Run `open-slide build` first; `preview` doesn't rebuild on its own. ## Flags [#flags] | Flag | Default | Description | | ------------------- | -------------- | ----------------------------------------------------- | | `-p, --port ` | from config | Override the listening port. | | `--host [host]` | localhost only | Expose the preview on the network. Optional host pin. | | `--open` | off | Open the browser on start. | # open-slide sync:skills (/docs/cli/sync-skills) npm pnpm yarn bun ```bash open-slide sync:skills # or npm run sync:skills ``` ```bash open-slide sync:skills # or pnpm run sync:skills ``` ```bash open-slide sync:skills # or yarn sync:skills ``` ```bash open-slide sync:skills # or bun run sync:skills ``` Copies the latest skill definitions from your installed `@open-slide/core` into the workspace under `.agents/skills/`, and refreshes the `.claude/skills/` symlinks so Claude Code picks them up too. Run this after upgrading `@open-slide/core` — that's how you pull in skill fixes and new skills (`/create-slide`, `/slide-authoring`, `/apply-comments`, `/create-theme`, …) without re-scaffolding. ## What it touches [#what-it-touches] ```text .agents/ └── skills/ └── / # canonical copy from @open-slide/core .claude/ └── skills/ └── # symlink to ../../.agents/skills/ ``` Each skill is hashed before copy, so unchanged skills are left alone and the symlink is the only thing refreshed. On platforms where symlinks fail (e.g. some Windows configurations), the command falls back to a recursive copy. ## Flags [#flags] | Flag | Default | Description | | ----------- | ------- | -------------------------------------------------------- | | `--dry-run` | off | Print the add / update / unchanged plan without writing. | ## Drift detection on `dev` [#drift-detection-on-dev] `open-slide dev` runs the same drift check at startup. If any skill is out of date it offers to sync interactively, or prints a one-line warning if stdin isn't a TTY. Skip the check with `--no-skills-check` (or `OPEN_SLIDE_SKIP_SKILLS_CHECK=1`) — see [open-slide dev](/docs/cli/dev). # Assets manager (/docs/core-feature/assets-manager) Open-slide assets panel listing slide files with the svgl logo search open Assets come in two scopes: * **Slide-local** — one-off files only used by a single deck. They live next to the deck that uses them and are imported with a relative path. * **Global** — files reused across decks or themes (company logos, presenter avatars, recurring icons). They live in a top-level `assets/` folder and are imported through the `@assets/*` Vite alias. ```text project-root/ ├── assets/ # global — shared across decks │ ├── logos/acme.svg │ └── avatars/jane.png └── slides/ └── q2-launch/ ├── index.tsx └── assets/ # slide-local — only this deck ├── cover.png ├── chart.svg └── intro.mp4 ``` ```tsx import cover from './assets/cover.png'; // slide-local import chart from './assets/chart.svg'; // slide-local import logo from '@assets/logos/acme.svg'; // global const Cover = () => ( <> ); ``` Both paths run through Vite's normal asset pipeline — files get hashed and emitted on `build` like any other import. ## In-browser asset manager [#in-browser-asset-manager] The dev server has an **Assets** panel with a **Slide / Global** scope toggle at the top: * **Slide** scope reads and writes `slides//assets/`. * **Global** scope reads and writes the project root `assets/` folder. Drag files in to upload them. Rename and replace from the same pane the inspector uses to swap an element's `src` — the Replace picker carries the same scope toggle, and the rewritten `import` uses `./assets/...` or `@assets/...` to match the source. The home sidebar also exposes the global manager directly: click **Assets** under Themes to open the locked-global view at `/assets`. ## svgl logo search [#svgl-logo-search] Need a brand logo? The Assets panel has an **svgl** search powered by [svgl.app](https://svgl.app/). Search, pick, drop — the SVG lands in whichever scope is active (slide-local or global) and you can `import` it immediately. ## Cross-bundler URL handling [#cross-bundler-url-handling] Vite (the dev server) returns asset imports as URL **strings**. Other bundlers return an object like `{ src, width, height }`. If you want a slide to render under both, normalise once: ```ts type AssetImport = string | { src: string }; const url = (a: AssetImport): string => (typeof a === 'string' ? a : a.src); ``` # Export (/docs/core-feature/export) `open-slide build` produces a plain static site under `dist/`. The output is zero-runtime — everything renders on the client, but no server is needed. ## Static HTML [#static-html] npm pnpm yarn bun ```bash npm run build npm run preview # serves dist/ for sanity-checking ``` ```bash pnpm run build pnpm run preview # serves dist/ for sanity-checking ``` ```bash yarn build yarn preview # serves dist/ for sanity-checking ``` ```bash bun run build bun run preview # serves dist/ for sanity-checking ``` `dist/` is portable. Drop it on Vercel, Cloudflare Pages, Netlify, Zeabur, GitHub Pages — anything that hosts files. ## PDF [#pdf] The dev server can print the active deck to PDF via the browser's native print pipeline. From the toolbar pick **Export → PDF**, or open the deck at `?print=1` and use Cmd/Ctrl-P. The runtime sets paper size to 1920×1080 landscape so each slide is exactly one page. ## What gets shipped [#what-gets-shipped] The build pipeline only includes: * The decks under `slides/`. * The assets each deck imports. * The runtime — viewer, scaler, present mode (configurable; see below). You can trim the surface area for a public share by toggling fields in `open-slide.config.ts`: ```ts import type { OpenSlideConfig } from '@open-slide/core'; const config: OpenSlideConfig = { build: { showSlideBrowser: false, // hide the deck index showSlideUi: false, // hide on-canvas UI (toolbar etc.) allowHtmlDownload: false, // hide the "download HTML" button }, }; export default config; ``` # Inspector (/docs/core-feature/inspector) Open-slide inspector with the property panel docked beside the canvas, editing a selected element's typography and colour The inspector is a dev-only overlay built into the open-slide runtime. Toggle it from the toolbar (or press `i`), then click any element on the slide. ## Two surfaces [#two-surfaces] The inspector exposes two ways to edit a slide: ### 01 · Visual editor [#01--visual-editor] Click an element. A property panel opens with editable inputs: * **Text** — replace inline. * **Typography** — font family, weight, size, letter-spacing. * **Colour** — text colour, background colour. * **Image src** — swap to any file in the deck's assets folder or the global `assets/` folder; the Replace picker has a **Slide / Global** toggle and rewrites the import accordingly (`./assets/...` or `@assets/...`). All edits buffer in memory until you press **Save** in the SaveBar. One save lands as a single HMR write to disk. ### 02 · Comments [#02--comments] Sometimes the change is easier to describe than to perform. Click an element, pick **Comment**, type a note: > use the accent colour on this title The inspector pins the note as an `@slide-comment` marker in the source. The marker travels with the element and is invisible at runtime. Run `/apply-comments` in your agent — see the [iterate flow](/docs/flow/iterate) — and the agent rewrites exactly the flagged regions, then clears the markers. ## Selection is visible to your agent [#selection-is-visible-to-your-agent] The inspector publishes the currently picked element — source line/column, tag, and text snippet — to `node_modules/.open-slide/current.json` whenever you click. Ask your agent *"make this bigger"* and the [`/current-slide`](/docs/skills/current-slide) skill reads that file to find the exact JSX node, no extra "which one?" round-trip. # Present mode (/docs/core-feature/present-mode) open-slide ships a present mode designed for a real stage, not just a browser tab. ## Fullscreen playback [#fullscreen-playback] From the dev server, hit **Present** (or press `f`). The current deck goes fullscreen with: * Arrow / space / `PgDn` to advance. * `←` / `↑` / `PgUp` to go back. * `Home` / `End` to jump to first / last. * `Esc` to exit. The canvas scales uniformly to fit the screen — no letterboxing maths on your part. ## Presenter mode [#presenter-mode] A second display mirror surfaces: * **Current slide** at the size you'll show it. * **Next slide** preview. * **Speaker notes** for the current page (read from a `notes` field on the slide module, if present). * **Timer** — elapsed time since you entered present mode. Use it on a laptop screen while the deck mirrors to the projector. ## Speaker notes [#speaker-notes] Add notes alongside the page array: ```tsx export const notes = { 0: 'Open with a smile. Mention the analyst quote from yesterday.', 3: 'Pause here for questions. The chart is a hand-off.', }; ``` # Themes (/docs/core-feature/themes) A theme is a **bundle of two paired files** under `themes/`: * `themes/.md` — agent-facing recipe: palette, typography, layout, fixed components, motion. `create-slide` reads this before authoring. * `themes/.demo.tsx` — a runnable mini-slide that demonstrates the theme. The dev UI's **Themes** panel imports it and renders it as the theme's live preview card. Both files share the same stem so the runtime can pair them automatically. ## Why themes [#why-themes] If you author more than two decks you'll want them to look related. A theme captures the *recipe* — palette, type stack, layout vocabulary, motion language — in one place that any deck can reference. The demo TSX makes that recipe visible: you can flip through theme previews in the dev UI before picking one. ## File layout [#file-layout] ```text themes/ ├── corporate.md ├── corporate.demo.tsx ├── editorial.md ├── editorial.demo.tsx ├── retro-print.md └── retro-print.demo.tsx ``` The markdown is free-form. A useful template: ```md title="themes/corporate.md" # Corporate ## Palette - ink: #0a0a0c - accent: #4a52b5 - mint: #1f8458 ## Typography - display: 'Söhne', system-ui - mono: 'JetBrains Mono' ## Voice - Tight, declarative, never coy. - One concept per slide. ``` The demo is a normal slide module — same shape as `slides//index.tsx`, just placed under `themes/` so the runtime treats it as preview-only (it does not appear in the slides list): ```tsx title="themes/corporate.demo.tsx" import type { Page } from '@open-slide/core'; const Cover: Page = () => (/* …inline Title / Footer using theme tokens… */); const Content: Page = () => (/* … */); export default [Cover, Content]; ``` Keep the demo's inline Title/Footer/Eyebrow components verbatim in sync with the snippets in the markdown — what authors see in the Themes panel should match what `create-slide` will paste into a real slide. ## Using a theme [#using-a-theme] When you call `/create-slide`, mention the theme by name: ```text /create-slide for "Q3 board update — use the corporate theme" ``` The agent loads `themes/corporate.md`, applies the palette and type stack, and writes pages that match. ## Extracting a theme [#extracting-a-theme] Already have a deck whose look you like? Use [`/create-theme`](/docs/skills/create-theme) to extract its design tokens into a reusable theme file. # Create a slide (/docs/flow/create-slide) open-slide is built around a tight loop: **init → create → iterate**. This page covers the *create* step. Once a deck exists, head to [Iterate](/docs/flow/iterate) for the feedback loop. ## With an agent [#with-an-agent] The recommended path — open your editor, ask: ```text /create-slide for "intro to vector databases — 6 pages, dense text, dark aesthetic, subtle motion" ``` `/create-slide` asks four scoping questions, picks a deck id, plans the structure, and writes pages under `slides//index.tsx`. See [`/create-slide`](/docs/skills/create-slide) for the full skill walk-through. ## By hand [#by-hand] Create a folder, add an `index.tsx`, default-export an array of components. ```tsx title="slides/intro/index.tsx" import type { Page, SlideMeta } from '@open-slide/core'; const Cover: Page = () => (
{/* anything you can render */}
); const Outline: Page = () =>
{/* ... */}
; export const meta: SlideMeta = { title: 'Intro', theme: 'corporate' }; export default [Cover, Outline] satisfies Page[]; ``` The dev server picks it up and adds it to the slide browser automatically. ## Conventions [#conventions] * IDs are kebab-case: `q2-launch`, `intro-to-rag`, `2026-roadmap`. * One folder per deck. Co-locate everything that deck needs. * Don't touch `package.json`, `open-slide.config.ts`, or other slides from inside a deck folder. # Iterate a slide (/docs/flow/iterate) Once a deck exists, iteration is the inner loop: ```text present → click to comment → /apply-comments → repeat ``` Most decks live in this loop. You're rarely typing CSS by hand — you're running the deck, spotting what's off, and pointing at it. ## Run the deck [#run-the-deck] Start the dev server and open the slide you want to work on. Use it the way you'd present it: arrow keys, fullscreen, the whole thing. Iteration is faster when you're reacting to the deck, not staring at code. ## Tweak directly [#tweak-directly] For changes that are faster to perform than to describe — re-wording a headline, nudging a colour, swapping an image — open the inspector (`i`), click the element, and edit it in place. All edits buffer until you press **Save**, so a batch lands as a single change. The [Inspector](/docs/core-feature/inspector) page covers the full panel. ## Comment when it's easier to say than do [#comment-when-its-easier-to-say-than-do] When the change is easier to describe than to perform — *"use the accent colour on this title"*, *"make this whole row breathe more"*, *"this needs a better visual"* — click the element, pick **Comment**, and type the note. You can drop a handful of comments through a single rehearsal pass without breaking flow. ## Apply with one command [#apply-with-one-command] When you're ready, ask your agent: ```text /apply-comments ``` The agent reads each comment, edits exactly that region, and clears the note. Refresh the dev server and you're looking at the next draft. If a comment is ambiguous, the agent leaves it alone and tells you why — refine the wording and run again. ## Talk to the agent about what you're seeing [#talk-to-the-agent-about-what-youre-seeing] You can also just ask. *"Shrink this title"*, *"swap the cover image"*, *"this slide feels cramped"* — the agent already knows which slide you're on and which element you've picked. No need to say *"slide 3, the H1 on the left"*. Combine the two modes freely: tweak what's obvious, comment what's not, and let the agent close the gap. # Page (/docs/primitive/page) `Page` is the base primitive in open-slide. A deck is just an ordered array of zero-prop React components, and each component is one page in the runtime, presenter view, overview grid, thumbnails, and exports. ```tsx title="slides/q2-launch/index.tsx" import { CANVAS_HEIGHT, CANVAS_WIDTH, type Page, type SlideMeta, useSlidePageNumber, } from '@open-slide/core'; const Cover: Page = () => { const { current, total } = useSlidePageNumber(); return (

{current} / {total}

Q2 launch

{CANVAS_WIDTH} × {CANVAS_HEIGHT}
); }; export const meta: SlideMeta = { title: 'Q2 launch', }; export default [Cover] satisfies Page[]; ``` ## Contract [#contract] ```ts type Page = ComponentType & { transition?: SlideTransition; }; ``` * A `Page` takes no props. Pull page context from hooks such as `useSlidePageNumber()`. * The module default export is the display order: `export default [Cover, Body] satisfies Page[]`. * The root element should fill the canvas with `width: '100%'` and `height: '100%'`. * Every page renders into a fixed `1920 × 1080` canvas. The runtime scales the whole canvas uniformly for preview, present mode, and export. ## How to Think About It [#how-to-think-about-it] Write pages as fixed-format compositions, not responsive web pages. Pixel coordinates are stable because the canvas size never changes. Use absolute positioning when exact placement matters, and use flex/grid when a section needs internal rhythm. The runtime may mount a page in more than one surface at the same time: current canvas, thumbnail rail, overview grid, theme preview, or export. Keep render output deterministic, avoid depending on the browser viewport, and guard browser-only side effects when a page also needs to export cleanly. ## Per-Page Overrides [#per-page-overrides] `Page` can carry a `transition` property. Use it only when one page needs a different animation than the module-level default. ```tsx import type { Page, SlideTransition } from '@open-slide/core'; const Cover: Page = () =>
; const coverTransition: SlideTransition = { duration: 240, enter: { keyframes: [ { opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'translateY(0)' }, ], }, }; Cover.transition = coverTransition; ``` See [Transition](/docs/primitive/transition) for the animation model. # SharedElement (/docs/primitive/shared-element) The shared element API is currently exported as `unstable_SharedElement`. Treat it as experimental and subject to change. `SharedElement` is the conceptual primitive for making one visual object move between two pages. In code today, import `unstable_SharedElement` and alias it to the component name you want to use. ```tsx title="slides/magic-move/index.tsx" import { unstable_SharedElement as SharedElement, type Page, type SlideTransition, } from '@open-slide/core'; const Token = ({ x, y, size }: { x: number; y: number; size: number }) => (
); const Start: Page = () => ; const End: Page = () => ; export const transition: SlideTransition = { duration: 700, sharedElements: { duration: 700, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', }, }; export default [Start, End] satisfies Page[]; ``` ## Matching Contract [#matching-contract] The `id` is the contract. When the outgoing and incoming pages both contain a shared element with the same `id`, the runtime measures both DOM nodes, hides the originals, animates a clone across the 1920 × 1080 canvas, then restores the incoming element. If a marked element exists only on the outgoing page, it fades out. If it exists only on the incoming page, it fades in. The runtime does not guess similar objects; unmatched ids are separate objects. ## What to Wrap [#what-to-wrap] Wrap the same visual object in a new position or size: a logo, diagram node, timeline marker, product card, badge, or highlight. Keep changing copy outside the shared element. If the content changes while moving, the audience reads it as one object turning into another, not continuity. `unstable_SharedElement` works best with a single DOM child. If there is exactly one DOM child, the runtime writes the `data-osd-shared-element` marker onto that child. Otherwise it wraps the content in a `div`. ## Transition Required [#transition-required] Shared elements only run when the selected transition enables `sharedElements`. ```ts export const transition: SlideTransition = { duration: 600, sharedElements: true, }; ``` Use the object form when you need separate timing from the page enter/exit animation. # Step (/docs/primitive/step) `Steps` and `Step` let one `Page` reveal content one beat at a time. The page index does not change while stepping; the navigation controller consumes forward/backward input until the page has no more local steps. ```tsx title="slides/product-story/index.tsx" import { Step, Steps, type Page } from '@open-slide/core'; const Problem: Page = () => (

The audience reads faster than you speak.

Showing every bullet at once invites pre-reading.

Reveal the setup, then the consequence, then the turn.

The final page state still reads as one complete composition.

); ``` ## Reveal Rules [#reveal-rules] * `Step` must be a direct child of `Steps`. A nested `Step`, or a `Step` without a `Steps` parent, renders fully revealed. * Non-`Step` children inside `Steps` render immediately. * Multiple `Steps` groups on one page compose in document order. The first group finishes before the second starts, and backward navigation unwinds in reverse. * `Step` fades in over `duration` milliseconds. The default is `180`, and reduced motion collapses it to an instant reveal. ```tsx

Always visible

First reveal Second reveal
``` ## Entry State [#entry-state] The starting state depends on how the audience lands on the page: | Entry | Initial step state | | ------------------------------ | ------------------------------------- | | Forward from the previous page | Empty, then builds one step at a time | | Backward from the next page | Fully revealed | | Overview / jump navigation | Fully revealed | Design the final composed state first, then choose which pieces deserve to be withheld. A thumbnail, overview jump, or backward navigation should not look like a broken blank page. ## When to Use It [#when-to-use-it] Use `Step` when order changes comprehension: a payoff list, a before/after, a diagram build, or a narrative reveal. Avoid wrapping every page by habit. A hero title, quote, chart, or image-led page often reads better when shown whole. # Transition (/docs/primitive/transition) `SlideTransition` describes what happens when the active `Page` changes. There is no default animation: pages snap unless the deck exports a transition or an incoming page defines its own override. ```tsx title="slides/q2-launch/index.tsx" import type { Page, SlideTransition } from '@open-slide/core'; const Cover: Page = () =>
; const Agenda: Page = () =>
; export const transition: SlideTransition = { duration: 220, exit: { duration: 140, easing: 'cubic-bezier(0.4, 0, 1, 1)', keyframes: [ { opacity: 1, transform: 'translateY(0)' }, { opacity: 0, transform: 'translateY(-4px)' }, ], }, enter: { duration: 220, delay: 80, easing: 'cubic-bezier(0, 0, 0.2, 1)', keyframes: [ { opacity: 0, transform: 'translateY(6px)' }, { opacity: 1, transform: 'translateY(0)' }, ], }, }; export default [Cover, Agenda] satisfies Page[]; ``` `prefers-reduced-motion: reduce` is honored automatically. ## Where It Lives [#where-it-lives] There are two declaration surfaces: * `export const transition` in the slide module sets the deck default. * `Page.transition = …` sets an override for one page. The incoming page wins. Navigating `Cover → Agenda` uses `Agenda.transition ?? module.transition`; the selected `exit` runs on `Cover`, and `enter` runs on `Agenda`. Navigating backward uses the page you are returning to. ```tsx const Cover: Page = () =>
; const Agenda: Page = () =>
; export const transition: SlideTransition = { duration: 220, enter: { keyframes: [{ opacity: 0 }, { opacity: 1 }], }, }; Agenda.transition = { duration: 320, enter: { keyframes: [ { opacity: 0, transform: 'scale(0.98)' }, { opacity: 1, transform: 'scale(1)' }, ], }, }; ``` ## Direction [#direction] During a page transition, the wrapper exposes: | Surface | Forward | Backward | | -------------- | --------- | ---------- | | `--osd-dir` | `1` | `-1` | | `data-osd-dir` | `forward` | `backward` | Use it when motion has a literal direction: ```tsx { transform: 'translateX(calc(var(--osd-dir, 1) * 8px))', } ``` Most decks should keep a single restrained motion family: short duration, subtle distance, opacity included, and no different transition vocabulary on every page. ## Shared Elements [#shared-elements] `SlideTransition.sharedElements` enables [SharedElement](/docs/primitive/shared-element) matching for objects that should visually continue across two pages. ```ts export const transition: SlideTransition = { duration: 600, sharedElements: { duration: 600, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', }, }; ``` For the full schema and a reusable family of examples, see [SlideTransition](/docs/reference/slide-transitions). # open-slide.config.ts (/docs/reference/config) The config lives at the workspace root and is fully optional — every field has a sensible default. ```ts title="open-slide.config.ts" import type { OpenSlideConfig } from '@open-slide/core'; const config: OpenSlideConfig = {}; export default config; ``` ## Schema [#schema] ```ts type OpenSlideBuildConfig = { /** Show the deck index (the slide browser) in the static build. */ showSlideBrowser?: boolean; /** Show on-canvas UI (toolbar, hints) in the static build. */ showSlideUi?: boolean; /** Show the "download as HTML" button in the static build. */ allowHtmlDownload?: boolean; }; type OpenSlideConfig = { /** Base public path for subpath hosting (leading + trailing slash). Default: '/'. */ base?: string; /** Folder that contains your decks. Default: 'slides'. */ slidesDir?: string; /** Folder that holds theme markdown + demo files. Default: 'themes'. */ themesDir?: string; /** Folder for global assets shared across decks. Default: 'assets'. */ assetsDir?: string; /** Dev server port. Default: first free port from 5173 upward. */ port?: number; /** UI language for the runtime (home, inspector, presenter, etc.). */ locale?: Locale; /** Build-time UI toggles. */ build?: OpenSlideBuildConfig; }; ``` ## Common recipes [#common-recipes] ### Public share [#public-share] ```ts const config: OpenSlideConfig = { build: { showSlideBrowser: false, showSlideUi: false, allowHtmlDownload: false, }, }; ``` ### Host under a subpath [#host-under-a-subpath] Set `base` to deploy the build under a sub-directory instead of the domain root — GitHub Pages project sites, a reverse-proxy folder, an intranet path. Use a leading and trailing slash: ```ts const config: OpenSlideConfig = { base: '/my-slides/', }; ``` The value is passed straight to Vite's `base` and to React Router's `basename`, so client-side navigation matches the deployed path. ### Multiple deck folders [#multiple-deck-folders] `slidesDir` is a single path today. If you want multiple roots, group them under one folder and use subfolder ids. ### Switch the UI language [#switch-the-ui-language] ```ts import { zhTW } from '@open-slide/core/locale'; const config: OpenSlideConfig = { locale: zhTW, }; ``` See [Locale](/docs/reference/locale) for the full list of presets and how to ship your own dictionary. # ImagePlaceholder (/docs/reference/image-placeholder) `ImagePlaceholder` is a small runtime component that renders a sized, dashed box with a hint label. Drop it into a slide whenever the layout calls for a real image — a product screenshot, a team photo, a customer dashboard — that you don't have on disk yet. ```tsx title="slides/q2-launch/index.tsx" import { ImagePlaceholder } from '@open-slide/core'; const Hero = () => ( ); export default [Hero]; ``` The user uploads the real file via the [Assets panel](/docs/core-feature/assets-manager), clicks the placeholder in the [inspector](/docs/core-feature/inspector), and picks **Replace…** — the JSX is rewritten to a real `` and the asset import is added at the top of the file. The replacement keeps the placeholder's `width` and `height` so the image lands in exactly the same slot. ## Props [#props] ```ts type ImagePlaceholderProps = { /** Description of the content that belongs here. Becomes the alt text * on the replaced . Required. */ hint: string; /** Box width in px. Omit to fill the parent. */ width?: number; /** Box height in px. Omit to fill the parent. */ height?: number; /** Extra inline styles, merged after the placeholder's own. */ style?: CSSProperties; className?: string; } & Omit, 'children' | 'style' | 'className'>; ``` When both `width` and `height` are set, the placeholder renders the dimensions under the hint so you can eyeball aspect ratios while drafting. ## Sizing [#sizing] Pick one of two modes: * **Fixed box** — pass `width` and `height` when the layout has a hard image slot (a hero card, a logo lockup at a known size). * **Fill the parent** — omit both when the placeholder sits inside a flex or grid cell that already controls its size. The component defaults to `width: 100%; height: 100%`. ```tsx // Fixed // Fill a flex/grid cell
``` The `hint` should describe the *content* the user has to supply ("Q3 revenue chart"), not its layout role ("hero image"). ## Replacement workflow [#replacement-workflow] The inspector's **Replace…** action is wired to a Vite-plugin op called `replace-placeholder-with-image`. Given an asset path, it: 1. Confirms the targeted JSX is an ``. 2. Adds `import from './assets/'` if the asset isn't already imported. 3. Rewrites the element to: ```tsx } alt="" style={{ width, height, objectFit: 'cover', objectPosition: '50% 50%' }} /> ``` The asset path must live under `./assets/` relative to the slide. If you skip the inspector and want to swap the placeholder by hand, the snippet above is exactly what to write. ## Cropping [#cropping] After replacement the image is a regular `` with `objectFit: 'cover'`, which means the asset's aspect ratio rarely matches the slot exactly. To reframe the visible region: * **Double-click** the image while the inspector is active, or * Click it once and choose **Crop** in the property panel. The crop dialog updates `objectPosition` (and, if you constrain the visible region, `objectFit`) on the same inline `style` block — no asset is mutated, so you can reopen the dialog later and adjust. ## When to use it [#when-to-use-it] Use a placeholder only when a *specific concrete* image is required by the deck's topic — a product-intro deck (one screenshot per feature), an offsite recap (team photo), a case study (customer logo). Do **not** use it for decoration, generic stock-photo filler, or anywhere a typographic or iconographic solution would do. Empty placeholders are friction the user has to clear; only spend that friction when the alternative is worse. See the **Image placeholders** section of the `slide-authoring` skill for the full editorial checklist. # Locale (/docs/reference/locale) The runtime ships with four built-in dictionaries. Switch between them from the language picker in the slide UI (next to the theme toggle) — your choice is remembered in the browser. The locale only affects the **runtime UI** that open-slide renders around your slides (the home screen, slide browser, inspector, asset panel, presenter mode, toasts, etc.). It does **not** translate slide content — that lives in your own React code. ## Picking a language [#picking-a-language] Open the language picker in the top-right of the home sidebar and choose one of the four built-in languages. The selection is saved locally and applies across the runtime UI. ## Presets [#presets] | Import | `id` | Language | | ------ | --------- | ----------------- | | `en` | `'en'` | English (default) | | `zhTW` | `'zh-TW'` | 繁體中文 | | `zhCN` | `'zh-CN'` | 简体中文 | | `ja` | `'ja'` | 日本語 | All presets are re-exported from the `@open-slide/core/locale` subpath: ```ts import { en, zhTW, zhCN, ja } from '@open-slide/core/locale'; ``` Until a language is picked in the UI, the runtime defaults to `en`. ## The deprecated `locale` config [#the-deprecated-locale-config] `config.locale` is **deprecated** — use the in-UI language picker instead. When set, it only seeds the initial language until the user picks one. ```ts title="open-slide.config.ts" import type { OpenSlideConfig } from '@open-slide/core'; import { zhTW } from '@open-slide/core/locale'; const config: OpenSlideConfig = { locale: zhTW, }; export default config; ``` ## Custom dictionaries [#custom-dictionaries] A locale is plain data — no functions, no JSX — so it serializes through the build cleanly. Provide your own object as long as it matches the `Locale` type from `@open-slide/core`. ```ts title="open-slide.config.ts" import type { OpenSlideConfig, Locale } from '@open-slide/core'; import { en } from '@open-slide/core/locale'; const fr: Locale = { ...en, id: 'en', // pick the closest built-in id common: { ...en.common, save: 'Enregistrer', cancel: 'Annuler', // …override the rest }, }; const config: OpenSlideConfig = { locale: fr }; export default config; ``` The `id` field today only has to be one of the built-in ids (`'en' | 'zh-TW' | 'zh-CN' | 'ja'`); it is reserved for future locale-aware formatting. The dictionary keys are what actually drive the UI. The full shape lives in `packages/core/src/locale/types.ts` — start from a preset and override the keys you need rather than building one from scratch. ## Templates and plurals [#templates-and-plurals] A few entries are templates with `{name}`-style placeholders, or plural forms (`{ one, other }`). They are flagged in the type definition with JSDoc, e.g.: ```ts /** template: "Loading {slideId}…" */ loadingSlide: string; /** templates: "{count} unsaved change" / "{count} unsaved changes" */ unsavedChanges: Plural; ``` The runtime expands these for you — your job is just to keep the same `{placeholder}` tokens (and both `one` and `other` forms) when translating. ## Notes [#notes] * Locale is read at module init. Changing it requires a dev-server reload or a fresh build. * There is no in-app language switcher today; pick a single locale per deployment. # Slide module exports (/docs/reference/slide-meta) ```tsx title="slides//index.tsx" import type { Page, SlideMeta } from '@open-slide/core'; const Cover: Page = () =>
{/* ... */}
; export const meta: SlideMeta = { title: 'Cover', theme: 'corporate', }; export const notes = { 0: 'Open with the analyst quote.', }; export default [Cover] satisfies Page[]; ``` ## Default export [#default-export] `Page[]` — the array of components, in display order. Each component fills its container; the runtime renders one at a time onto the 1920×1080 canvas. ```ts type Page = ComponentType; ``` ## `meta` (optional) [#meta-optional] ```ts type SlideMeta = { /** Display title for the deck — used in the slide browser. */ title?: string; /** Name of a theme under `themes/.md`. The agent reads it before authoring. */ theme?: string; }; ``` ## `notes` (optional) [#notes-optional] A map of page index → markdown string. Surfaced in [presenter mode](/docs/core-feature/present-mode). ```ts export const notes: Record = { 0: 'Open with a smile.', 3: 'Pause for questions.', }; ``` ## `transition` (optional) [#transition-optional] A module-level `SlideTransition` becomes the default animation between every page in the deck. Per-page overrides are assigned on the `Page` component itself. See [SlideTransition](/docs/reference/slide-transitions) for the full schema and a tasteful family of defaults. ```ts import type { SlideTransition } from '@open-slide/core'; export const transition: SlideTransition = { duration: 200, exit: { /* … */ }, enter: { /* … */ }, }; ``` ## Imports from `@open-slide/core` [#imports-from-open-slidecore] ```ts import { CANVAS_WIDTH, // 1920 CANVAS_HEIGHT, // 1080 ImagePlaceholder, // sized empty placeholder — see /docs/reference/image-placeholder unstable_SharedElement, // match an element across pages — see /docs/reference/slide-transitions } from '@open-slide/core'; import type { unstable_SharedElementProps, Page, SlideMeta, SlideModule, SlideTransition, // per-page / module-level animations — see /docs/reference/slide-transitions SharedElementTransition, TransitionPhase, ImagePlaceholderProps, } from '@open-slide/core'; ``` # SlideTransition (/docs/reference/slide-transitions) The framework can run an enter/exit animation between every page change. There is **no default** — pages snap unless you declare a `SlideTransition`. Snap-swap is a perfectly tasteful default; only opt in when motion adds something. ```tsx title="slides/q2-launch/index.tsx" import type { Page, SlideTransition } from '@open-slide/core'; const Cover: Page = () =>
; const Body: Page = () =>
; const EASE_OUT = 'cubic-bezier(0, 0, 0.2, 1)'; const EASE_IN = 'cubic-bezier(0.4, 0, 1, 1)'; export const transition: SlideTransition = { duration: 200, exit: { duration: 140, easing: EASE_IN, keyframes: [ { opacity: 1, transform: 'translateY(0)' }, { opacity: 0, transform: 'translateY(-4px)' }, ] }, enter: { duration: 200, delay: 80, easing: EASE_OUT, keyframes: [ { opacity: 0, transform: 'translateY(6px)' }, { opacity: 1, transform: 'translateY(0)' }, ] }, }; export default [Cover, Body]; ``` `prefers-reduced-motion: reduce` is honored automatically — you don't write a fallback. ## Schema [#schema] ```ts type TransitionPhase = { /** WAAPI keyframes — either an array of Keyframe objects * or a PropertyIndexedKeyframes object. */ keyframes: Keyframe[] | PropertyIndexedKeyframes; /** Phase duration in ms. Falls back to the top-level duration. */ duration?: number; /** CSS easing string. Falls back to the top-level easing. */ easing?: string; /** Delay in ms before the phase starts. * Use on enter to overlap with the outgoing exit. */ delay?: number; }; type SlideTransition = { /** Top-level duration fallback (ms). Required. */ duration: number; /** Top-level easing fallback. */ easing?: string; /** Plays on the incoming page. */ enter?: TransitionPhase; /** Plays on the outgoing page. */ exit?: TransitionPhase; /** Matches unstable_SharedElement pairs and fades unmatched shared elements. */ sharedElements?: boolean | SharedElementTransition; }; type SharedElementTransition = { /** Shared element duration in ms. Falls back to the top-level duration. */ duration?: number; /** CSS easing string. Falls back to the top-level easing. */ easing?: string; /** Delay in ms before shared elements start moving. */ delay?: number; }; ``` ## Where to declare it [#where-to-declare-it] Two surfaces, both optional: * **Module-level** — `export const transition` from the slide module. Every page in the deck inherits it. * **Per-page** — assign `Page.transition` directly. Overrides the module default for that page. ```tsx const Cover: Page = () =>
; const Body: Page = () =>
; // Module default — applies to both pages. export const transition: SlideTransition = { /* … */ }; // Per-page override — only Cover uses this. Cover.transition = { /* … */ }; export default [Cover, Body]; ``` ### Which one wins [#which-one-wins] The **incoming page wins**. Navigating A → B uses `pages[B].transition ?? module.transition`; its `exit` plays on A, its `enter` plays on B. Going back B → A uses A's transition instead. This keeps the "next thought" feeling attached to the slide you're heading toward, not the one you're leaving. ## Direction hook [#direction-hook] The framework writes two values on the transition wrapper so a single keyframe can mirror itself on backward navigation: | Surface | Forward | Backward | | -------------- | --------- | ---------- | | `--osd-dir` | `1` | `-1` | | `data-osd-dir` | `forward` | `backward` | Use `--osd-dir` inside `calc()` when you genuinely need to mirror motion: ```tsx { transform: 'translateX(calc(var(--osd-dir, 1) * 8px))' }, { transform: 'translateX(0)' }, ``` Most tasteful tools don't mirror on backward navigation — forward = backward reads as more refined. Reach for the direction hook only when the page's motion has a literal direction (a horizontal advance, a chapter sweep). ## Shared elements [#shared-elements] This feature is currently unstable and subject to change. It is not recommended for production. Try it out and share your feedback on [GitHub](https://github.com/1weiho/open-slide/issues). For Keynote-style "Magic Move" continuity, wrap the same visual object on two pages with `unstable_SharedElement` and give both instances the same `id`. The transition layer measures the outgoing and incoming DOM, hides the originals, animates a clone across the canvas, then restores the incoming element. ```tsx import { unstable_SharedElement as SharedElement, type Page, type SlideTransition, } from '@open-slide/core'; const Logo = ({ x, y, size }: { x: number; y: number; size: number }) => (