` | 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)
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)
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 }) => (
);
const Start: Page = () => ;
const End: Page = () => ;
export const transition: SlideTransition = {
duration: 900,
enter: { keyframes: [{ opacity: 0.35 }, { opacity: 1 }] },
exit: { keyframes: [{ opacity: 1 }, { opacity: 0.35 }] },
sharedElements: {
duration: 900,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
},
};
export default [Start, End];
```
Use shared elements only for the same object in a new position or size. If the
content changes, keep that changing copy outside `unstable_SharedElement` and let it fade
as normal page content. When `sharedElements` is enabled, marked elements that
only exist on the outgoing page fade out automatically, and marked elements that
only exist on the incoming page fade in automatically. The runtime does not
guess similar objects; the `id` is the contract.
## Design principles [#design-principles]
The loudest signal of "made in PowerPoint" is six different transitions in one
deck. Restraint is the rhythm.
* **Pick one DNA, hold it across the deck.** Same duration band, same easing
pair, same out-then-in stagger. Variation lives only in *which property* gets
the small nudge — Y, X, opacity, scale, blur.
* **Duration: 140–280 ms.** Exit 140–180 ms, enter 200–280 ms, enter delayed
\~80 ms so they overlap but don't fight. Past 350 ms is video-editor
territory.
* **Magnitude ceiling: 12 px or 3% scale.** A 6 px Y-rise reads as "next
thought." A 1920 px translateX reads as "different document."
* **Opacity is always part of it.** Pure-transform transitions look stiff;
pure-opacity transitions are the safest possible default.
* **Easing: ease-in for exit, ease-out for enter.** `cubic-bezier(0.4, 0, 1, 1)`
going out, `cubic-bezier(0, 0, 0.2, 1)` coming in. Never `linear`.
## A tasteful family [#a-tasteful-family]
Six members, one DNA. Pick one as the deck's house transition; optionally
reserve a second for cover slides and a third for genuine section breaks.
```tsx
const EASE_OUT = 'cubic-bezier(0, 0, 0.2, 1)';
const EASE_IN = 'cubic-bezier(0.4, 0, 1, 1)';
// RISE — house quiet. 6 px Y. Good module default.
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)' },
] },
};
// DISSOLVE — pure opacity. The quietest possible.
const dissolve: SlideTransition = {
duration: 240,
exit: { duration: 200, easing: EASE_IN,
keyframes: [{ opacity: 1 }, { opacity: 0 }] },
enter: { duration: 240, delay: 40, easing: EASE_OUT,
keyframes: [{ opacity: 0 }, { opacity: 1 }] },
};
// SETTLE — cover-grade. Rise + a hair of blur on enter only.
const settle: SlideTransition = {
duration: 280,
exit: { duration: 160, easing: EASE_IN,
keyframes: [
{ opacity: 1, transform: 'translateY(0)' },
{ opacity: 0, transform: 'translateY(-6px)' },
] },
enter: { duration: 280, delay: 100, easing: EASE_OUT,
keyframes: [
{ opacity: 0, transform: 'translateY(12px)', filter: 'blur(4px)' },
{ opacity: 1, transform: 'translateY(0)', filter: 'blur(0)' },
] },
};
// BLOOM — scale 0.97 → 1, no translate. Materializes in place.
const bloom: SlideTransition = {
duration: 240,
exit: { duration: 160, easing: EASE_IN,
keyframes: [
{ opacity: 1, transform: 'scale(1)' },
{ opacity: 0, transform: 'scale(1.01)' },
] },
enter: { duration: 240, delay: 80, easing: EASE_OUT,
keyframes: [
{ opacity: 0, transform: 'scale(0.97)' },
{ opacity: 1, transform: 'scale(1)' },
] },
};
// FALL — mirrored Rise. Incoming page comes down from above.
const fall: 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)' },
] },
};
// BREATH — section break. Exit fully, hold 120 ms, then enter.
// Reserve for genuine chapter dividers; use 1–2× per deck at most.
const breath: SlideTransition = {
duration: 460,
exit: { duration: 180, easing: EASE_IN,
keyframes: [{ opacity: 1 }, { opacity: 0 }] },
enter: { duration: 240, delay: 300, easing: EASE_OUT,
keyframes: [
{ opacity: 0, transform: 'translateY(8px)' },
{ opacity: 1, transform: 'translateY(0)' },
] },
};
```
All six share the same DNA — they only differ in which property carries the
small nudge. The reader perceives variety; the eye still reads one consistent
hand.
# /apply-comments (/docs/skills/apply-comments)
`/apply-comments` closes the visual feedback loop. You drop comments through
the inspector; the skill resolves them into code edits.
For the full iteration loop see [Iterate](/docs/flow/iterate).
## What it does [#what-it-does]
1. Scans the workspace for `@slide-comment` markers.
2. For each marker, reads the surrounding code and the comment text.
3. Edits the file to satisfy the comment.
4. Removes the marker.
```text
› /apply-comments
✓ 3 markers · slides/q2-launch/index.tsx
✓ 1 marker · slides/intro/index.tsx
```
## Marker semantics [#marker-semantics]
A `@slide-comment` marker is a normal JSX/JS comment that the inspector
inserts next to the element you commented on. It's invisible at runtime —
the rendered output never changes. The marker carries:
* The user's note (the message you typed).
* A pointer to the element (so an agent reading the file knows what *this*
meant).
## When to use it [#when-to-use-it]
After a batch of inspector comments — typically right before saving the
deck for the day or right before a rehearsal. Refreshing the dev server
after `/apply-comments` shows the new state with the markers cleared.
If a comment is ambiguous, the agent will leave the marker in place and
explain why. Refine the comment text and re-run.
# /create-slide (/docs/skills/create-slide)
`/create-slide` is the entry-point skill for any new deck. From a one-line
prompt to a polished outline in a single skill call.
## What it does [#what-it-does]
1. **Scoping** — asks four questions: topic & aesthetic, page count, text
density, motion vs. static.
2. **Theme lookup** — if a theme is referenced, reads `themes/.md`
first so the deck inherits the design system. See
[Themes](/docs/core-feature/themes).
3. **ID & structure** — picks a kebab-case id, plans the page list.
4. **Authoring** — writes `slides//index.tsx` page by page, deferring
to [`/slide-authoring`](/docs/skills/slide-authoring) for the *how*.
## When to use it [#when-to-use-it]
For any new deck. Even if you plan to hand-author, kick off with
`/create-slide` to seed the folder, the page array, and the meta block.
```text
/create-slide for "Q2 launch — 3 chapters, mixed text density, subtle motion, dark aesthetic"
```
## What you get [#what-you-get]
A working `slides//index.tsx` with one component per page, a `meta`
export, and assets imported from `slides//assets/`. Drop into the dev
server and iterate from there — see [Iterate](/docs/flow/iterate).
# /create-theme (/docs/skills/create-theme)
`/create-theme` codifies a visual recipe into a reusable theme file.
Run it once your decks start sharing a visual language — turn the implicit
recipe into a file the agent can read.
## What it does [#what-it-does]
Writes a **theme bundle** under `themes/` — two paired files that share a
stem:
* `themes/.md` — palette, type stack, layout vocabulary, fixed
Title/Footer/Eyebrow components, motion, and voice notes that
`/create-slide` reads on its next run.
* `themes/.demo.tsx` — a runnable 2–3 page mini-slide that inlines
the same components from the markdown. The dev UI's **Themes** panel
imports it and renders it as the theme's live preview.
Both files are always produced together — a theme without its demo can't
preview, a demo without its markdown can't be picked by `/create-slide`.
The input can be:
* **An existing deck.** The skill scans `slides//index.tsx`, distils the
recurring tokens, and writes them out.
* **A brief.** Describe the system in chat (*"warm editorial, serif display,
high-contrast palette"*) and the skill bootstraps the bundle from
scratch.
* **Image references.** Pass screenshots or mood-board images and the
skill extracts palette, type, and layout cues from them.
## Output [#output]
A markdown recipe:
```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.
```
…paired with a preview module:
```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];
```
See [Themes](/docs/core-feature/themes) for how a theme is consumed.
## Using the theme [#using-the-theme]
Once the file exists, mention it by name on the next `/create-slide`:
```text
/create-slide for "Q3 board update — use the corporate theme"
```
The agent loads `themes/corporate.md` before writing the deck.
# /current-slide (/docs/skills/current-slide)
`/current-slide` is what makes deictic prompts work. *"This page"*, *"this
heading"*, *"the slide I'm on"* — the skill teaches the agent how to
resolve them to a concrete slide id, page index, and (when set) the JSX
element you've picked in the [inspector](/docs/core-feature/inspector).
## How it works [#how-it-works]
The dev server writes the user's current view to
`node_modules/.open-slide/current.json` on every navigation and selection
change:
* `slideId` — the deck folder name.
* `pageIndex` — which page of the array.
* `element` (optional) — source `file:line:col`, tag name, and a text
snippet of the JSX node you picked in the inspector.
The skill reads that file before asking *which slide?*
## When it fires [#when-it-fires]
You don't usually invoke it directly. `/slide-authoring` cross-references
it, so any "fix this" prompt routes through it automatically.
## Why it matters [#why-it-matters]
It collapses the most common round-trip in agent-driven editing:
```text
You: make this title smaller
Agent: which title?
You: the one on slide 3
```
With `/current-slide`, the agent already knows. The
[Iterate](/docs/flow/iterate) page covers how this folds into the broader
inspector / `/apply-comments` loop.
# Overview (/docs/skills/overview)
open-slide treats your coding agent as a first-class user. Decks are React,
agents are great at React, the gap between *"make slides about X"* and a
polished deck is mostly the runtime — which open-slide provides.
## What ships in the workspace [#what-ships-in-the-workspace]
`npx @open-slide/cli init` generates an `AGENTS.md` (and `CLAUDE.md`) at the
root with the framework's hard rules, plus a `skills/` folder with five
skills the agent invokes by name:
* [`/create-slide`](/docs/skills/create-slide)
* [`/slide-authoring`](/docs/skills/slide-authoring)
* [`/apply-comments`](/docs/skills/apply-comments)
* [`/create-theme`](/docs/skills/create-theme)
* [`/current-slide`](/docs/skills/current-slide)
The skills are kept inside the workspace (not the agent's global config) so
they version with the project. `open-slide sync:skills` re-syncs them from
the package if you upgrade.
## The hard rules (`AGENTS.md`) [#the-hard-rules-agentsmd]
Short, intentional, machine-readable:
* Slides go under `slides//`.
* Entry is `slides//index.tsx`.
* Assets sit in `slides//assets/`.
* Don't touch `package.json`, `open-slide.config.ts`, or other slides.
* Don't add dependencies. Use only React and standard web APIs.
These constraints protect the agent from itself: it can't accidentally
install a UI library, rewrite global config, or stomp on another deck.
## Bring your own agent [#bring-your-own-agent]
The framework is intentionally agent-agnostic. Any tool that can edit React
files in a workspace can author slides. The community confirms with: Claude
Code, Codex, Cursor, Gemini CLI, OpenCode, Windsurf, Zed.
To author cleanly, the agent needs to:
1. **Read the rules.** `AGENTS.md` (and `CLAUDE.md`) at the workspace root
define the file contract and constraints.
2. **Find the skills.** `/create-slide`, `/slide-authoring`,
`/apply-comments`, `/create-theme`, `/current-slide` live under `skills/`
(or wherever that agent looks for skills).
3. **Edit files.** That's it. There is no per-agent SDK.
If your agent of choice doesn't have a skills system, you can paste the
`slide-authoring` skill into context manually and ask the agent to follow
its rules.
### Mirroring `AGENTS.md` for Claude Code [#mirroring-agentsmd-for-claude-code]
`AGENTS.md` is the canonical rules file — it follows the cross-tool
convention that Codex, Cursor, Gemini CLI, OpenCode, Windsurf, and Zed
already look for. `CLAUDE.md` is generated alongside it so Claude Code
picks the rules up automatically. The two files are kept in sync by the
scaffolder; edit `AGENTS.md` and re-run `open-slide sync:skills` if you
want the mirror refreshed.
# /slide-authoring (/docs/skills/slide-authoring)
`/slide-authoring` is the canonical spec for everything inside
`slides//`. The agent reads it before writing — file contract, the
1920×1080 canvas, type scale, palette, layout vocabulary, the self-review
checklist, and the anti-pattern list. `/create-slide` defers to it for the
*how*.
You don't usually invoke `slide-authoring` directly. Other skills do.
## What it ships [#what-it-ships]
The defaults below are opinionated so an agent doesn't have to reinvent the
look of every deck. Override any of it per-deck, or codify your overrides
in a [theme](/docs/core-feature/themes).
### Type scale [#type-scale]
A 1920×1080 stage pairs well with bigger type than the web is used to. The
scale below is a reasonable default — adjust per deck.
| Role | Size (px) |
| -------------- | --------- |
| Display | 188 |
| H1 | 128 |
| H2 | 84 |
| Body | 28 – 36 |
| Caption / mono | 14 – 18 |
Letter-spacing should tighten as size grows (`-0.04em` on display, around
`-0.011em` on body).
### Palette [#palette]
A two-tone neutral plus an accent goes a long way. The default dark tokens:
```ts
const palette = {
ink: '#0a0a0c',
panel: '#111114',
text: '#f6f5f0',
textSoft: '#c9c7bd',
muted: '#7f7e78',
accent: '#7170ff',
};
```
For a recipe-driven design system, write a theme — see
[Themes](/docs/core-feature/themes).
### Layout patterns [#layout-patterns]
* **Hero.** One eyebrow, one headline, one supporting line, optional CTA.
* **Two-column.** 60/40 split. Left for prose, right for visual artefact.
* **Index.** A list of entries with monospace numbering.
* **Quote.** Big italic display, attribution in mono.
The agent applies these unless you tell it otherwise.