@open-slide/core 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,6 +41,18 @@ const openSlideConfig: OpenSlideConfig = {
41
41
  export default openSlideConfig;
42
42
  ```
43
43
 
44
+ ### Hosting under a subpath
45
+
46
+ Set `base` to deploy the built site under a sub-directory (intranet folders, GitHub Pages project sites, reverse proxies). Use a leading and trailing slash:
47
+
48
+ ```ts
49
+ const openSlideConfig: OpenSlideConfig = {
50
+ base: '/my-slides/',
51
+ };
52
+ ```
53
+
54
+ The value is passed straight to Vite's `base` and to React Router's `basename`, so client-side navigation matches the deployed path.
55
+
44
56
  ## Authoring slides
45
57
 
46
58
  Slides live under `slides/<kebab-case-id>/index.tsx` and default-export an array of `Page` components:
@@ -1,5 +1,5 @@
1
1
  import "./design-cpzS8aud.js";
2
- import { createViteConfig } from "./config-BAZeaz2P.js";
2
+ import { createViteConfig } from "./config-Bk2i4eJ1.js";
3
3
  import path from "node:path";
4
4
  import { build as build$1, mergeConfig } from "vite";
5
5
 
package/dist/cli/bin.js CHANGED
@@ -57,15 +57,15 @@ async function run(argv) {
57
57
  program.name("open-slide").description("Author slides — we handle the Vite/React stack.").version(version, "-v, --version", "print version").helpOption("-h, --help", "show help").showHelpAfterError(chalk.dim("(run `open-slide --help` for usage)"));
58
58
  program.command("dev").description("Start the dev server").addOption(new Option("-p, --port <port>", "port to listen on").argParser(parsePort)).addOption(new Option("--host [host]", "expose on the network (optional host)")).option("--open", "open the browser on start").option("--no-skills-check", "skip the built-in skills drift check").action(async (flags) => {
59
59
  if (flags.skillsCheck !== false) await runSkillsDriftCheck(resolveBuiltinSkillsDir());
60
- const { dev } = await import("../dev-BQkNTG_t.js");
60
+ const { dev } = await import("../dev-DplvRqZx.js");
61
61
  await dev(flags);
62
62
  });
63
63
  program.command("build").description("Build a static site").option("--out-dir <dir>", "output directory (defaults to `dist`)").action(async (flags) => {
64
- const { build } = await import("../build-ZM7IfDO-.js");
64
+ const { build } = await import("../build-CtmQSpg-.js");
65
65
  await build(flags);
66
66
  });
67
67
  program.command("preview").description("Preview the production build").addOption(new Option("-p, --port <port>", "port to listen on").argParser(parsePort)).addOption(new Option("--host [host]", "expose on the network (optional host)")).option("--open", "open the browser on start").action(async (flags) => {
68
- const { preview } = await import("../preview-D8hUtbRA.js");
68
+ const { preview } = await import("../preview-p4gcc8ip.js");
69
69
  await preview(flags);
70
70
  });
71
71
  program.command("sync:skills").description("Sync built-in skills from @open-slide/core into this workspace").option("--dry-run", "show what would change without writing").action(async (flags) => {
@@ -1,4 +1,4 @@
1
- import { Locale } from "./types-AalTbxMj.js";
1
+ import { Locale } from "./types-D_q_ylIe.js";
2
2
 
3
3
  //#region src/config.d.ts
4
4
  type OpenSlideBuildConfig = {
@@ -7,6 +7,7 @@ type OpenSlideBuildConfig = {
7
7
  allowHtmlDownload?: boolean;
8
8
  };
9
9
  type OpenSlideConfig = {
10
+ base?: string;
10
11
  slidesDir?: string;
11
12
  themesDir?: string;
12
13
  assetsDir?: string;
@@ -3681,7 +3681,7 @@ function parseCreatedAtMs(iso) {
3681
3681
  async function generateSlidesModule(files, slidesRoot, isDev) {
3682
3682
  const entries = await Promise.all(files.map(async (abs) => {
3683
3683
  const id = toId(abs, slidesRoot);
3684
- const importPath = isDev ? `/@fs/${abs.replace(/^\/+/, "")}` : abs;
3684
+ const importPath = isDev ? `@fs/${normalizePath(abs).replace(/^\/+/, "")}` : abs;
3685
3685
  const meta = await readSlideMeta(abs);
3686
3686
  return {
3687
3687
  id,
@@ -3713,7 +3713,7 @@ if (import.meta.hot) {
3713
3713
  }
3714
3714
  ` : "";
3715
3715
  const cases = entries.map((e) => {
3716
- const importExpr = isDev ? `import(/* @vite-ignore */ ${JSON.stringify(`${e.importPath}?t=`)} + slideImportTokens[${JSON.stringify(e.id)}])` : `import(${JSON.stringify(e.importPath)})`;
3716
+ const importExpr = isDev ? `import(/* @vite-ignore */ import.meta.env.BASE_URL + ${JSON.stringify(`${e.importPath}?t=`)} + slideImportTokens[${JSON.stringify(e.id)}])` : `import(${JSON.stringify(e.importPath)})`;
3717
3717
  return ` case ${JSON.stringify(e.id)}: return ${importExpr};`;
3718
3718
  }).join("\n");
3719
3719
  return `// virtual:open-slide/slides — generated
@@ -3933,8 +3933,9 @@ function generateThemesModule(themes, isDev) {
3933
3933
  const cases = themes.flatMap((t$5) => {
3934
3934
  const abs = t$5.demoAbs;
3935
3935
  if (!abs) return [];
3936
- const importPath = isDev ? `/@fs/${normalizePath(abs).replace(/^\/+/, "")}` : abs;
3937
- return [` case ${JSON.stringify(t$5.id)}: return import(${JSON.stringify(importPath)});`];
3936
+ const importPath = isDev ? `@fs/${normalizePath(abs).replace(/^\/+/, "")}` : abs;
3937
+ const importExpr = isDev ? `import(/* @vite-ignore */ import.meta.env.BASE_URL + ${JSON.stringify(importPath)})` : `import(${JSON.stringify(importPath)})`;
3938
+ return [` case ${JSON.stringify(t$5.id)}: return ${importExpr};`];
3938
3939
  }).join("\n");
3939
3940
  return `// virtual:open-slide/themes — generated
3940
3941
  export const themes = ${JSON.stringify(meta)};
@@ -4029,6 +4030,7 @@ async function createViteConfig(opts) {
4029
4030
  const themesAbs = path.resolve(userCwd, themesDir);
4030
4031
  const assetsAbs = path.resolve(userCwd, assetsDir);
4031
4032
  return {
4033
+ base: config.base ?? "/",
4032
4034
  root: APP_ROOT,
4033
4035
  configFile: false,
4034
4036
  envDir: userCwd,
@@ -1,5 +1,5 @@
1
1
  import "./design-cpzS8aud.js";
2
- import { createViteConfig } from "./config-BAZeaz2P.js";
2
+ import { createViteConfig } from "./config-Bk2i4eJ1.js";
3
3
  import { createServer, mergeConfig } from "vite";
4
4
 
5
5
  //#region src/cli/dev.ts
@@ -329,7 +329,9 @@ const en = {
329
329
  toastDeleted: "Deleted page {n}",
330
330
  toastDuplicateFailed: "Could not duplicate page",
331
331
  toastDeleteFailed: "Could not delete page",
332
- resizeRail: "Resize thumbnail rail"
332
+ resizeRail: "Resize thumbnail rail",
333
+ transitionIndicator: "Has slide transition",
334
+ stepsIndicator: "Has step-by-step reveals"
333
335
  },
334
336
  pdfToast: {
335
337
  title: "Exporting PDF",
@@ -719,7 +721,9 @@ const ja = {
719
721
  toastDeleted: "ページ {n} を削除しました",
720
722
  toastDuplicateFailed: "ページを複製できませんでした",
721
723
  toastDeleteFailed: "ページを削除できませんでした",
722
- resizeRail: "サムネイル幅を調整"
724
+ resizeRail: "サムネイル幅を調整",
725
+ transitionIndicator: "スライドトランジションあり",
726
+ stepsIndicator: "ステップ表示あり"
723
727
  },
724
728
  pdfToast: {
725
729
  title: "PDF を書き出し中",
@@ -1109,7 +1113,9 @@ const zhCN = {
1109
1113
  toastDeleted: "已删除第 {n} 页",
1110
1114
  toastDuplicateFailed: "无法复制页面",
1111
1115
  toastDeleteFailed: "无法删除页面",
1112
- resizeRail: "调整缩略图栏宽度"
1116
+ resizeRail: "调整缩略图栏宽度",
1117
+ transitionIndicator: "有换页转场",
1118
+ stepsIndicator: "有逐步揭示"
1113
1119
  },
1114
1120
  pdfToast: {
1115
1121
  title: "导出 PDF",
@@ -1499,7 +1505,9 @@ const zhTW = {
1499
1505
  toastDeleted: "已刪除第 {n} 頁",
1500
1506
  toastDuplicateFailed: "無法複製頁面",
1501
1507
  toastDeleteFailed: "無法刪除頁面",
1502
- resizeRail: "調整縮圖欄寬度"
1508
+ resizeRail: "調整縮圖欄寬度",
1509
+ transitionIndicator: "有換頁轉場",
1510
+ stepsIndicator: "有逐步揭示"
1503
1511
  },
1504
1512
  pdfToast: {
1505
1513
  title: "匯出 PDF",
package/dist/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
- import { Locale, Plural } from "./types-AalTbxMj.js";
2
- import { OpenSlideConfig } from "./config-D_5nlXFU.js";
3
- import { CSSProperties, ComponentType, HTMLAttributes } from "react";
1
+ import { Locale, Plural } from "./types-D_q_ylIe.js";
2
+ import { OpenSlideConfig } from "./config-14qk4fP8.js";
3
+ import { CSSProperties, ComponentType, HTMLAttributes, PropsWithChildren } from "react";
4
4
  import * as react_jsx_runtime1 from "react/jsx-runtime";
5
+ import * as react_jsx_runtime3 from "react/jsx-runtime";
6
+ import * as react_jsx_runtime4 from "react/jsx-runtime";
5
7
 
6
8
  //#region src/app/components/image-placeholder.d.ts
7
9
  type ImagePlaceholderProps = {
@@ -89,4 +91,22 @@ declare const CANVAS_WIDTH = 1920;
89
91
  declare const CANVAS_HEIGHT = 1080;
90
92
 
91
93
  //#endregion
92
- export { CANVAS_HEIGHT, CANVAS_WIDTH, DesignFonts, DesignPalette, DesignSystem, DesignTypeScale, ImagePlaceholder, ImagePlaceholderProps, Locale, OpenSlideConfig, Page, Plural, SlideMeta, SlideModule, SlideTransition, TransitionPhase, cssVarsToString, defaultDesign, designToCssVars, useSlidePageNumber };
94
+ //#region src/app/lib/step-context.d.ts
95
+ type StepsProps = PropsWithChildren;
96
+ declare function Steps({
97
+ children
98
+ }: StepsProps): react_jsx_runtime3.JSX.Element;
99
+ type StepProps = PropsWithChildren<{
100
+ duration?: number;
101
+ }>;
102
+ type InternalStepProps = StepProps & {
103
+ _revealed?: boolean;
104
+ };
105
+ declare function Step({
106
+ children,
107
+ duration,
108
+ _revealed
109
+ }: InternalStepProps): react_jsx_runtime4.JSX.Element;
110
+
111
+ //#endregion
112
+ export { CANVAS_HEIGHT, CANVAS_WIDTH, DesignFonts, DesignPalette, DesignSystem, DesignTypeScale, ImagePlaceholder, ImagePlaceholderProps, Locale, OpenSlideConfig, Page, Plural, SlideMeta, SlideModule, SlideTransition, Step, StepProps, Steps, StepsProps, TransitionPhase, cssVarsToString, defaultDesign, designToCssVars, useSlidePageNumber };
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
- import { en, ja, zhCN, zhTW } from "./format-CYOb2cAQ.js";
1
+ import { en, ja, zhCN, zhTW } from "./format-BvBmqbNW.js";
2
2
  import { cssVarsToString, defaultDesign, designToCssVars } from "./design-cpzS8aud.js";
3
- import { createContext, useContext, useRef, useState, useSyncExternalStore } from "react";
3
+ import { Children, cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
4
4
  import { toast } from "sonner";
5
5
  import config from "virtual:open-slide/config";
6
- import { jsx, jsxs } from "react/jsx-runtime";
6
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
7
7
 
8
8
  //#region src/app/lib/assets.ts
9
9
  async function listAssets(slideId) {
@@ -333,10 +333,10 @@ function PlaceholderIcon() {
333
333
 
334
334
  //#endregion
335
335
  //#region src/app/lib/page-context.tsx
336
- const GLOBAL_KEY = "__open_slide_page_context__";
337
- const g = globalThis;
338
- if (!g[GLOBAL_KEY]) g[GLOBAL_KEY] = createContext(null);
339
- const SlidePageContext = g[GLOBAL_KEY];
336
+ const GLOBAL_KEY$1 = "__open_slide_page_context__";
337
+ const g$1 = globalThis;
338
+ if (!g$1[GLOBAL_KEY$1]) g$1[GLOBAL_KEY$1] = createContext(null);
339
+ const SlidePageContext = g$1[GLOBAL_KEY$1];
340
340
  function useSlidePageNumber() {
341
341
  const ctx = useContext(SlidePageContext);
342
342
  if (!ctx) throw new Error("useSlidePageNumber must be called from a slide page rendered by @open-slide/core");
@@ -352,4 +352,96 @@ const CANVAS_WIDTH = 1920;
352
352
  const CANVAS_HEIGHT = 1080;
353
353
 
354
354
  //#endregion
355
- export { CANVAS_HEIGHT, CANVAS_WIDTH, ImagePlaceholder, cssVarsToString, defaultDesign, designToCssVars, useSlidePageNumber };
355
+ //#region src/app/lib/use-prefers-reduced-motion.ts
356
+ const QUERY = "(prefers-reduced-motion: reduce)";
357
+ function usePrefersReducedMotion() {
358
+ const [reduce, setReduce] = useState(() => {
359
+ if (typeof window === "undefined") return false;
360
+ return window.matchMedia(QUERY).matches;
361
+ });
362
+ useEffect(() => {
363
+ const mql = window.matchMedia(QUERY);
364
+ const onChange = (e) => setReduce(e.matches);
365
+ mql.addEventListener("change", onChange);
366
+ return () => mql.removeEventListener("change", onChange);
367
+ }, []);
368
+ return reduce;
369
+ }
370
+
371
+ //#endregion
372
+ //#region src/app/lib/step-context.tsx
373
+ const GLOBAL_KEY = "__open_slide_step_host_context__";
374
+ const g = globalThis;
375
+ if (!g[GLOBAL_KEY]) g[GLOBAL_KEY] = createContext(null);
376
+ const StepHostContext = g[GLOBAL_KEY];
377
+ function Steps({ children }) {
378
+ const host = useContext(StepHostContext);
379
+ const flat = Children.toArray(children);
380
+ const stepCount = flat.filter((c) => isValidElement(c) && c.type === Step).length;
381
+ const initial = host?.controlled ? 0 : host?.entryDirection === "forward" ? 0 : stepCount;
382
+ const revealedRef = useRef(initial);
383
+ const [revealed, setRevealed] = useState(initial);
384
+ const idRef = useRef({});
385
+ const applyRevealed = useCallback((n) => {
386
+ revealedRef.current = n;
387
+ setRevealed(n);
388
+ }, []);
389
+ useLayoutEffect(() => {
390
+ if (!host) return;
391
+ const id = idRef.current;
392
+ const ctrl = {
393
+ advance: () => {
394
+ if (revealedRef.current >= stepCount) return false;
395
+ applyRevealed(revealedRef.current + 1);
396
+ host.reportRevealed(id, revealedRef.current);
397
+ return true;
398
+ },
399
+ retreat: () => {
400
+ if (revealedRef.current <= 0) return false;
401
+ applyRevealed(revealedRef.current - 1);
402
+ host.reportRevealed(id, revealedRef.current);
403
+ return true;
404
+ }
405
+ };
406
+ return host.register({
407
+ id,
408
+ stepCount,
409
+ initialRevealed: revealedRef.current,
410
+ controller: ctrl,
411
+ setRevealed: applyRevealed
412
+ });
413
+ }, [
414
+ host,
415
+ stepCount,
416
+ applyRevealed
417
+ ]);
418
+ const effectiveRevealed = host ? revealed : stepCount;
419
+ let stepIdx = 0;
420
+ return /* @__PURE__ */ jsx(Fragment, { children: flat.map((child, key) => {
421
+ if (isValidElement(child) && child.type === Step) {
422
+ const idx = stepIdx++;
423
+ return cloneElement(child, {
424
+ key: child.key ?? key,
425
+ _revealed: idx < effectiveRevealed
426
+ });
427
+ }
428
+ return child;
429
+ }) });
430
+ }
431
+ function Step({ children, duration = 180, _revealed }) {
432
+ const reduceMotion = usePrefersReducedMotion();
433
+ const revealed = _revealed ?? true;
434
+ const ms = reduceMotion ? 0 : duration;
435
+ return /* @__PURE__ */ jsx("div", {
436
+ "data-osd-step": revealed ? "revealed" : "pending",
437
+ style: {
438
+ opacity: revealed ? 1 : 0,
439
+ visibility: revealed ? "visible" : "hidden",
440
+ transition: `opacity ${ms}ms cubic-bezier(0, 0, 0.2, 1)`
441
+ },
442
+ children
443
+ });
444
+ }
445
+
446
+ //#endregion
447
+ export { CANVAS_HEIGHT, CANVAS_WIDTH, ImagePlaceholder, Step, Steps, cssVarsToString, defaultDesign, designToCssVars, useSlidePageNumber };
@@ -1,4 +1,4 @@
1
- import { Locale, Plural } from "../types-AalTbxMj.js";
1
+ import { Locale, Plural } from "../types-D_q_ylIe.js";
2
2
 
3
3
  //#region src/locale/en.d.ts
4
4
  declare const en: Locale;
@@ -1,3 +1,3 @@
1
- import { en, format, ja, plural, zhCN, zhTW } from "../format-CYOb2cAQ.js";
1
+ import { en, format, ja, plural, zhCN, zhTW } from "../format-BvBmqbNW.js";
2
2
 
3
3
  export { en, format, ja, plural, zhCN, zhTW };
@@ -1,5 +1,5 @@
1
1
  import "./design-cpzS8aud.js";
2
- import { createViteConfig } from "./config-BAZeaz2P.js";
2
+ import { createViteConfig } from "./config-Bk2i4eJ1.js";
3
3
  import { mergeConfig, preview as preview$1 } from "vite";
4
4
 
5
5
  //#region src/cli/preview.ts
@@ -357,6 +357,8 @@ type Locale = {
357
357
  toastDuplicateFailed: string;
358
358
  toastDeleteFailed: string;
359
359
  resizeRail: string;
360
+ transitionIndicator: string;
361
+ stepsIndicator: string;
360
362
  };
361
363
  pdfToast: {
362
364
  title: string;
@@ -1,5 +1,5 @@
1
- import "../types-AalTbxMj.js";
2
- import { OpenSlideConfig } from "../config-D_5nlXFU.js";
1
+ import "../types-D_q_ylIe.js";
2
+ import { OpenSlideConfig } from "../config-14qk4fP8.js";
3
3
  import { InlineConfig } from "vite";
4
4
 
5
5
  //#region src/vite/config.d.ts
@@ -1,4 +1,4 @@
1
1
  import "../design-cpzS8aud.js";
2
- import { createViteConfig } from "../config-BAZeaz2P.js";
2
+ import { createViteConfig } from "../config-Bk2i4eJ1.js";
3
3
 
4
4
  export { createViteConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-slide/core",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "Runtime and CLI for open-slide — write slides in slides/, we handle the rest.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -308,6 +308,45 @@ const Footer = () => {
308
308
 
309
309
  `current` is 1-indexed (matches what readers see) and `total` is the slide's page count. The hook works in every render context (main viewer, thumbnails, overview grid, present mode, presenter window, HTML/PDF export) — the same `<Footer />` JSX is correct everywhere. Call the hook inside a component that's used **per page**; don't try to call it at module top level.
310
310
 
311
+ ## Stepped reveals (`<Steps>` / `<Step>`)
312
+
313
+ Reveal a page one beat at a time instead of showing everything at once. Wrap the deferred parts in `<Step>`, wrap the group in `<Steps>`. Each `→` reveals the next `<Step>`; `→` after the last one advances to the next page. `←` peels the last reveal back. Use it to stage attention — show framing first, then the consequence, then the turn — so the audience reads at the speaker's pace, not ahead.
314
+
315
+ `slides/build-on-reveal/` is the canonical worked example; study it before authoring a stepped page.
316
+
317
+ ```tsx
318
+ import { Step, Steps } from '@open-slide/core';
319
+
320
+ <Steps>
321
+ <Step><div style={BULLET_ROW}>An audience reads faster than a presenter speaks.</div></Step>
322
+ <Step><div style={BULLET_ROW}>Showing every bullet at once invites pre-reading.</div></Step>
323
+ <Step><div style={BULLET_ROW}>Revealing in time stages attention.</div></Step>
324
+ </Steps>
325
+ ```
326
+
327
+ ### Rules
328
+
329
+ - **`<Step>` must be a *direct* child of `<Steps>`.** A `<Step>` nested deeper (or used without a `<Steps>` parent) renders fully revealed and defers nothing.
330
+ - **Non-`Step` children render immediately.** Put a headline or intro paragraph *inside* `<Steps>` as a plain element and it shows from the start; only the `<Step>` blocks wait. This is the "headline always, body in turn" pattern:
331
+ ```tsx
332
+ <Steps>
333
+ <h2>Not everything has to wait.</h2>{/* visible immediately */}
334
+ <Step><p>First, set the stage…</p></Step>
335
+ <Step><p>Then, layer the consequence…</p></Step>
336
+ </Steps>
337
+ ```
338
+ - **Multiple `<Steps>` blocks on one page compose in document order.** The first block reveals all its steps before the second begins; `←` unwinds in reverse. Use this for two columns that build left-then-right, each column owning its own `<Steps>`:
339
+ ```tsx
340
+ <div style={COL}><Steps><Step>…</Step><Step>…</Step></Steps></div>{/* finishes first */}
341
+ <div style={COL}><Steps><Step>…</Step><Step>…</Step></Steps></div>{/* then this */}
342
+ ```
343
+ - **Entry direction decides the starting state — same content, two rhythms.** Entering forward (`→` from the previous page) starts empty and builds up. Jumping in via the overview grid, or arriving backward from a later page, shows the page **fully composed** with every step already revealed. Design the page to read well both ways: a thumbnail or overview jump should look complete, not blank.
344
+ - **`<Step>` fades in over `duration` ms (default 180).** Pass `<Step duration={...}>` to adjust. `prefers-reduced-motion: reduce` collapses it to an instant cut automatically — don't write a fallback.
345
+
346
+ ### When to reach for it
347
+
348
+ Use stepped reveals when the *order* of ideas is the point — a list whose payoff is the last item, a build-up to a conclusion, a before/after. Don't wrap every page's content in `<Step>` reflexively: a page the audience should take in at a glance (a hero title, a single quote, a diagram) is stronger shown whole. Reveals are timing, not decoration — same restraint as transitions.
349
+
311
350
  ## Page transitions
312
351
 
313
352
  The framework can run an enter/exit animation between every slide change. There's **no default** — pages snap unless you declare a `SlideTransition`. Snap-swap is a perfectly tasteful default; only opt in when motion adds something.
@@ -541,6 +580,7 @@ This applies whenever the *visual element* repeats, not whenever the *data* does
541
580
  - [ ] Visually repeated elements (cards, tiles, logo rows) are rendered as explicit `<Component />` instances, not via `array.map` over a data list.
542
581
  - [ ] All imported assets exist on disk — slide-local under `slides/<id>/assets/`, or global under `assets/` (imported via `@assets/...`).
543
582
  - [ ] Every `<ImagePlaceholder>` corresponds to a real image the user must supply — not decorative filler. If it could be replaced by typography or layout, it should be.
583
+ - [ ] If a page uses `<Steps>`/`<Step>`, every `<Step>` is a direct child of a `<Steps>`, and the page still reads as complete when jumped to via the overview grid (entering forward builds up; jumping in shows it fully revealed).
544
584
  - [ ] If a `SlideTransition` is declared, every page sits in one family — same duration band (140–280 ms), same easing pair, same out-then-in stagger, magnitude under 12 px / 3%. No six-different-vocabularies decks. When in doubt, omit transitions entirely.
545
585
  - [ ] Nothing outside `slides/<id>/` was edited.
546
586
 
@@ -561,3 +601,5 @@ This applies whenever the *visual element* repeats, not whenever the *data* does
561
601
  - ❌ Sprinkling `<ImagePlaceholder>` across pages "for visual interest". Placeholders are for content the user owns; they're not stock-photo slots.
562
602
  - ❌ Using a placeholder for an icon or decorative shape — those are typography/SVG problems, not asset problems.
563
603
  - ❌ Rendering visually repeated elements with `array.map(...)` over a data array. Define a component and instantiate it explicitly per item (`<Card />`, `<Card />`, `<Card />`) so the inspector can edit each independently — a shared `map` body mutates every instance at once.
604
+ - ❌ Wrapping every page's content in `<Step>` reflexively. Stepped reveals are for content whose *order* is the point; a glance-and-get-it page (hero title, single quote, diagram) is stronger shown whole.
605
+ - ❌ A `<Step>` that isn't a direct child of `<Steps>` (nested deeper, or with no `<Steps>` parent). It renders fully revealed and defers nothing — the reveal silently does nothing.
package/src/app/app.tsx CHANGED
@@ -11,7 +11,7 @@ import { ThemeDetailPage, ThemesGalleryPage } from './routes/themes';
11
11
 
12
12
  export function App() {
13
13
  return (
14
- <BrowserRouter>
14
+ <BrowserRouter basename={import.meta.env.BASE_URL}>
15
15
  <Routes>
16
16
  {config.build.showSlideBrowser ? (
17
17
  <Route element={<HomeShell />}>
@@ -4,6 +4,7 @@ import { useWheelPageNavigation } from '@/lib/use-wheel-page-navigation';
4
4
  import { cn } from '@/lib/utils';
5
5
  import type { DesignSystem } from '../lib/design';
6
6
  import type { Page } from '../lib/sdk';
7
+ import type { EntryDirection, StepAggregate, StepController } from '../lib/step-context';
7
8
  import type { SlideTransition } from '../lib/transition';
8
9
  import { usePrefersReducedMotion } from '../lib/use-prefers-reduced-motion';
9
10
  import { PresentBlackoutOverlay } from './present/blackout-overlay';
@@ -84,12 +85,37 @@ export function Player({
84
85
  const canPrev = index > 0;
85
86
  const canNext = index < pages.length - 1;
86
87
 
88
+ const stepControllerRef = useRef<StepController | null>(null);
89
+ const [entryDirection, setEntryDirection] = useState<EntryDirection>('jump');
90
+ const [stepAggregate, setStepAggregate] = useState<StepAggregate>({
91
+ revealed: 0,
92
+ stepCount: 0,
93
+ });
94
+ const handleAggregateChange = useCallback((a: StepAggregate) => {
95
+ setStepAggregate((cur) =>
96
+ cur.revealed === a.revealed && cur.stepCount === a.stepCount ? cur : a,
97
+ );
98
+ }, []);
99
+
100
+ // Every navigation funnels through here so entryDirection is settled
101
+ // synchronously, before the incoming page's <Steps> reads it on mount.
102
+ const handleIndexChange = useCallback(
103
+ (next: number) => {
104
+ const delta = next - index;
105
+ setEntryDirection(delta === 1 ? 'forward' : delta === -1 ? 'backward' : 'jump');
106
+ onIndexChange(next);
107
+ },
108
+ [index, onIndexChange],
109
+ );
110
+
87
111
  const goPrev = useCallback(() => {
88
- if (index > 0) onIndexChange(index - 1);
89
- }, [index, onIndexChange]);
112
+ if (stepControllerRef.current?.retreat()) return;
113
+ if (index > 0) handleIndexChange(index - 1);
114
+ }, [index, handleIndexChange]);
90
115
  const goNext = useCallback(() => {
91
- if (index < pages.length - 1) onIndexChange(index + 1);
92
- }, [index, pages.length, onIndexChange]);
116
+ if (stepControllerRef.current?.advance()) return;
117
+ if (index < pages.length - 1) handleIndexChange(index + 1);
118
+ }, [index, pages.length, handleIndexChange]);
93
119
 
94
120
  const overlayActive = controls && (overviewOpen || helpOpen);
95
121
 
@@ -147,8 +173,15 @@ export function Player({
147
173
  // and answers `request-state` pings so newly opened presenter windows
148
174
  // hydrate immediately.
149
175
  const presenterState = useMemo<PresenterState>(
150
- () => ({ index, pageCount: pages.length, blackout, startedAt }),
151
- [index, pages.length, blackout, startedAt],
176
+ () => ({
177
+ index,
178
+ pageCount: pages.length,
179
+ blackout,
180
+ startedAt,
181
+ stepIndex: stepAggregate.revealed,
182
+ stepCount: stepAggregate.stepCount,
183
+ }),
184
+ [index, pages.length, blackout, startedAt, stepAggregate],
152
185
  );
153
186
  const presenterStateRef = useRef(presenterState);
154
187
  presenterStateRef.current = presenterState;
@@ -158,14 +191,14 @@ export function Player({
158
191
  if (msg.type === 'next') goNext();
159
192
  else if (msg.type === 'prev') goPrev();
160
193
  else if (msg.type === 'goto') {
161
- onIndexChange(Math.max(0, Math.min(pages.length - 1, msg.index)));
194
+ handleIndexChange(Math.max(0, Math.min(pages.length - 1, msg.index)));
162
195
  } else if (msg.type === 'toggle-blackout') {
163
196
  setBlackout((cur) => (cur === msg.mode ? null : msg.mode));
164
197
  } else if (msg.type === 'request-state') {
165
198
  send({ type: 'state', state: presenterStateRef.current });
166
199
  }
167
200
  },
168
- [goNext, goPrev, onIndexChange, pages.length],
201
+ [goNext, goPrev, handleIndexChange, pages.length],
169
202
  );
170
203
 
171
204
  const channel = usePresenterChannel(slideId ?? '__none__', (msg) => {
@@ -231,12 +264,12 @@ export function Player({
231
264
  }
232
265
  if (e.key === 'Home') {
233
266
  setKeyboardDriven(true);
234
- onIndexChange(0);
267
+ handleIndexChange(0);
235
268
  return;
236
269
  }
237
270
  if (e.key === 'End') {
238
271
  setKeyboardDriven(true);
239
- onIndexChange(pages.length - 1);
272
+ handleIndexChange(pages.length - 1);
240
273
  return;
241
274
  }
242
275
 
@@ -277,7 +310,7 @@ export function Player({
277
310
  onExit,
278
311
  goNext,
279
312
  goPrev,
280
- onIndexChange,
313
+ handleIndexChange,
281
314
  pages.length,
282
315
  slideId,
283
316
  ]);
@@ -315,6 +348,9 @@ export function Player({
315
348
  total={pages.length}
316
349
  moduleTransition={transition}
317
350
  disabled={prefersReducedMotion}
351
+ stepControllerRef={stepControllerRef}
352
+ entryDirection={entryDirection}
353
+ onStepAggregateChange={handleAggregateChange}
318
354
  />
319
355
  </SlideCanvas>
320
356
 
@@ -322,7 +358,7 @@ export function Player({
322
358
  <div data-osd-chrome style={{ display: 'contents' }}>
323
359
  <PresentProgressBar index={index} total={pages.length} visible={chromeVisible} />
324
360
  <PresentBlackoutOverlay mode={blackout} />
325
- <PresentJumpInput pageCount={pages.length} onJump={onIndexChange} />
361
+ <PresentJumpInput pageCount={pages.length} onJump={handleIndexChange} />
326
362
  <PresentLaserPointer enabled={laser} />
327
363
  <PresentControlBar
328
364
  tooltipContainer={rootEl}
@@ -350,7 +386,7 @@ export function Player({
350
386
  open={overviewOpen}
351
387
  current={index}
352
388
  onClose={() => setOverviewOpen(false)}
353
- onSelect={onIndexChange}
389
+ onSelect={handleIndexChange}
354
390
  />
355
391
  <PresentHelpOverlay open={helpOpen} onOpenChange={setHelpOpen} container={rootEl} />
356
392
  </div>
@@ -361,6 +397,6 @@ export function Player({
361
397
 
362
398
  export function openPresenterWindow(slideId: string) {
363
399
  if (typeof window === 'undefined') return;
364
- const url = `/s/${encodeURIComponent(slideId)}/presenter`;
400
+ const url = `${import.meta.env.BASE_URL}s/${encodeURIComponent(slideId)}/presenter`;
365
401
  window.open(url, `open-slide-presenter-${slideId}`, 'popup,width=1280,height=800');
366
402
  }
@@ -5,6 +5,8 @@ export type PresenterState = {
5
5
  pageCount: number;
6
6
  blackout: 'black' | 'white' | null;
7
7
  startedAt: number; // epoch ms when present mode began
8
+ stepIndex: number;
9
+ stepCount: number;
8
10
  };
9
11
 
10
12
  export type PresenterCommand =
@@ -1,4 +1,4 @@
1
- import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from 'react';
1
+ import { type CSSProperties, type ReactNode, useLayoutEffect, useRef, useState } from 'react';
2
2
  import { cn } from '@/lib/utils';
3
3
  import { type DesignSystem, designToCssVars } from '../lib/design';
4
4
  import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
@@ -24,22 +24,27 @@ export function SlideCanvas({
24
24
  design,
25
25
  }: Props) {
26
26
  const containerRef = useRef<HTMLDivElement>(null);
27
- const [fitScale, setFitScale] = useState(1);
27
+ const [fitScale, setFitScale] = useState<number | null>(null);
28
28
 
29
- useEffect(() => {
29
+ useLayoutEffect(() => {
30
30
  if (scale !== undefined) return;
31
31
  const el = containerRef.current;
32
32
  if (!el) return;
33
- const ro = new ResizeObserver(() => {
33
+ const measure = () => {
34
34
  const { width, height } = el.getBoundingClientRect();
35
35
  if (width === 0 || height === 0) return;
36
36
  setFitScale(Math.min(width / CANVAS_WIDTH, height / CANVAS_HEIGHT));
37
- });
37
+ };
38
+ // Measure synchronously before paint so the fitted scale is applied on the
39
+ // first visible frame — otherwise the canvas flashes at full size.
40
+ measure();
41
+ const ro = new ResizeObserver(measure);
38
42
  ro.observe(el);
39
43
  return () => ro.disconnect();
40
44
  }, [scale]);
41
45
 
42
- const s = scale ?? fitScale;
46
+ const measured = scale ?? fitScale;
47
+ const s = measured ?? 1;
43
48
  const scaledW = CANVAS_WIDTH * s;
44
49
  const scaledH = CANVAS_HEIGHT * s;
45
50
 
@@ -55,6 +60,7 @@ export function SlideCanvas({
55
60
  style={{
56
61
  width: scaledW,
57
62
  height: scaledH,
63
+ visibility: measured === null ? 'hidden' : undefined,
58
64
  ...(center
59
65
  ? {
60
66
  position: 'absolute',