@open-slide/core 1.6.0 → 1.8.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.
Files changed (45) hide show
  1. package/dist/{build-tLrkKUHr.js → build-CCZDC8eF.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-PwUHqZ_X.js → config-C7sZtiY2.js} +45 -18
  4. package/dist/{config-CfMThYN9.d.ts → config-D1bANimZ.d.ts} +1 -1
  5. package/dist/{dev-DpCIRbhT.js → dev-kLS_4CAI.js} +1 -1
  6. package/dist/{en-BDnM5zKJ.js → en-hyGpmL1O.js} +1 -4
  7. package/dist/index.d.ts +22 -4
  8. package/dist/index.js +1 -1
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +4 -13
  11. package/dist/{preview-BSGlM6Se.js → preview-DUkOjOx8.js} +1 -1
  12. package/dist/{types-B-KrjgX8.d.ts → types-Bvk1pM70.d.ts} +1 -4
  13. package/dist/vite/index.d.ts +2 -2
  14. package/dist/vite/index.js +1 -1
  15. package/package.json +1 -1
  16. package/skills/create-theme/SKILL.md +1 -1
  17. package/skills/slide-authoring/SKILL.md +169 -0
  18. package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
  19. package/src/app/components/inspector/comment-widget.tsx +16 -2
  20. package/src/app/components/inspector/inspect-overlay.tsx +132 -35
  21. package/src/app/components/inspector/inspector-panel.tsx +19 -256
  22. package/src/app/components/inspector/inspector-provider.tsx +102 -1
  23. package/src/app/components/panel/save-card.tsx +4 -4
  24. package/src/app/components/player.tsx +25 -25
  25. package/src/app/components/sidebar/folder-item.tsx +7 -2
  26. package/src/app/components/sidebar/sidebar.tsx +87 -16
  27. package/src/app/components/slide-transition-layer.tsx +154 -0
  28. package/src/app/components/style-panel/style-panel.tsx +3 -0
  29. package/src/app/lib/folders.ts +28 -0
  30. package/src/app/lib/inspector/fiber.test.ts +154 -0
  31. package/src/app/lib/inspector/fiber.ts +12 -1
  32. package/src/app/lib/sdk.ts +3 -1
  33. package/src/app/lib/transition.ts +23 -0
  34. package/src/app/lib/use-click-page-navigation.ts +52 -0
  35. package/src/app/lib/use-is-mobile.ts +21 -0
  36. package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
  37. package/src/app/routes/home-shell.tsx +8 -0
  38. package/src/app/routes/home.tsx +1 -1
  39. package/src/app/routes/slide.tsx +92 -60
  40. package/src/locale/en.ts +1 -5
  41. package/src/locale/ja.ts +1 -5
  42. package/src/locale/types.ts +1 -5
  43. package/src/locale/zh-cn.ts +1 -5
  44. package/src/locale/zh-tw.ts +1 -5
  45. package/src/app/components/click-nav-zones.tsx +0 -36
@@ -1,5 +1,5 @@
1
1
  import "./design-cpzS8aud.js";
2
- import { createViteConfig } from "./config-PwUHqZ_X.js";
2
+ import { createViteConfig } from "./config-C7sZtiY2.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-DpCIRbhT.js");
60
+ const { dev } = await import("../dev-kLS_4CAI.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-tLrkKUHr.js");
64
+ const { build } = await import("../build-CCZDC8eF.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-BSGlM6Se.js");
68
+ const { preview } = await import("../preview-DUkOjOx8.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) => {
@@ -1157,21 +1157,20 @@ function findReferencedAssets(source, assetPaths) {
1157
1157
  if (!ast) return referenced;
1158
1158
  const wanted = new Set(assetPaths);
1159
1159
  const identToPath = new Map();
1160
+ const importLocals = new Set();
1160
1161
  for (const imp of findImports$1(ast)) {
1161
1162
  if (!imp.defaultIdent) continue;
1162
- if (wanted.has(imp.source)) identToPath.set(imp.defaultIdent, imp.source);
1163
+ if (!wanted.has(imp.source)) continue;
1164
+ identToPath.set(imp.defaultIdent, imp.source);
1165
+ for (const spec of imp.node.specifiers) if (t$3.isImportDefaultSpecifier(spec) && spec.local.name === imp.defaultIdent) importLocals.add(spec.local);
1163
1166
  }
1164
1167
  if (identToPath.size === 0) return referenced;
1165
- walkJsx(ast, (n) => {
1166
- if (!t$3.isJSXElement(n)) return;
1167
- const opening = n.openingElement;
1168
- if (!t$3.isJSXIdentifier(opening.name) || opening.name.name !== "img") return;
1169
- const src = findJsxAttr(opening, "src");
1170
- if (!src?.value || !t$3.isJSXExpressionContainer(src.value)) return;
1171
- const expr = src.value.expression;
1172
- if (!t$3.isIdentifier(expr)) return;
1173
- const p = identToPath.get(expr.name);
1174
- if (p) referenced.add(p);
1168
+ walkAll(ast, (n) => {
1169
+ if (!t$3.isIdentifier(n)) return;
1170
+ const p = identToPath.get(n.name);
1171
+ if (!p) return;
1172
+ if (importLocals.has(n)) return;
1173
+ referenced.add(p);
1175
1174
  });
1176
1175
  return referenced;
1177
1176
  }
@@ -2446,6 +2445,19 @@ function validateName(v) {
2446
2445
  if (trimmed.length < 1 || trimmed.length > 40) return null;
2447
2446
  return trimmed;
2448
2447
  }
2448
+ function validateReorder(v, current) {
2449
+ if (!Array.isArray(v) || v.length !== current.length) return null;
2450
+ const known = new Set(current.map((f) => f.id));
2451
+ const seen = new Set();
2452
+ const out = [];
2453
+ for (const id of v) {
2454
+ if (typeof id !== "string" || !FOLDER_ID_RE.test(id)) return null;
2455
+ if (!known.has(id) || seen.has(id)) return null;
2456
+ seen.add(id);
2457
+ out.push(id);
2458
+ }
2459
+ return out;
2460
+ }
2449
2461
  function validateIcon(v) {
2450
2462
  if (!v || typeof v !== "object") return null;
2451
2463
  const icon = v;
@@ -2513,6 +2525,18 @@ function registerFolderRoutes(server, ctx) {
2513
2525
  await writeManifest(ctx.manifestPath, manifest);
2514
2526
  return json$2(res, 200, { ok: true });
2515
2527
  }
2528
+ if (method === "PUT" && url.pathname === "/reorder") {
2529
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
2530
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2531
+ const body = await readBody$2(req);
2532
+ const manifest = await readManifest(ctx.manifestPath);
2533
+ const ids = validateReorder(body.ids, manifest.folders);
2534
+ if (!ids) return json$2(res, 400, { error: "invalid ids" });
2535
+ const byId = new Map(manifest.folders.map((f) => [f.id, f]));
2536
+ manifest.folders = ids.map((id) => byId.get(id));
2537
+ await writeManifest(ctx.manifestPath, manifest);
2538
+ return json$2(res, 200, { ok: true });
2539
+ }
2516
2540
  const idMatch = url.pathname.match(/^\/([^/]+)$/);
2517
2541
  if (idMatch) {
2518
2542
  const id = idMatch[1];
@@ -3334,19 +3358,22 @@ function injectLocTags(code) {
3334
3358
  for (const ins of insertions) next = next.slice(0, ins.offset) + ins.text + next.slice(ins.offset);
3335
3359
  return next;
3336
3360
  }
3361
+ function isSlideSourceFile(id, slidesRootPosix) {
3362
+ const filePath = id.split(/[?#]/)[0].replace(/\\/g, "/");
3363
+ if (!filePath.startsWith(`${slidesRootPosix}/`)) return false;
3364
+ if (!filePath.endsWith(".tsx")) return false;
3365
+ if (filePath.endsWith(".d.ts") || filePath.endsWith(".test.tsx")) return false;
3366
+ const rel = filePath.slice(slidesRootPosix.length + 1);
3367
+ return rel.includes("/");
3368
+ }
3337
3369
  function locTagsPlugin(opts) {
3338
- const slidesRoot = path.resolve(opts.userCwd, opts.slidesDir ?? "slides");
3370
+ const slidesRoot = path.resolve(opts.userCwd, opts.slidesDir ?? "slides").replace(/\\/g, "/");
3339
3371
  return {
3340
3372
  name: "open-slide:loc-tags",
3341
3373
  apply: "serve",
3342
3374
  enforce: "pre",
3343
3375
  transform(code, id) {
3344
- const filePath = id.split("?")[0];
3345
- if (!filePath.startsWith(slidesRoot + path.sep)) return null;
3346
- if (!filePath.endsWith(".tsx")) return null;
3347
- if (filePath.endsWith(".d.ts") || filePath.endsWith(".test.tsx")) return null;
3348
- const rel = filePath.slice(slidesRoot.length + path.sep.length);
3349
- if (!rel.includes(path.sep)) return null;
3376
+ if (!isSlideSourceFile(id, slidesRoot)) return null;
3350
3377
  const next = injectLocTags(code);
3351
3378
  if (next === null) return null;
3352
3379
  return {
@@ -1,4 +1,4 @@
1
- import { Locale } from "./types-B-KrjgX8.js";
1
+ import { Locale } from "./types-Bvk1pM70.js";
2
2
 
3
3
  //#region src/config.d.ts
4
4
  type OpenSlideBuildConfig = {
@@ -1,5 +1,5 @@
1
1
  import "./design-cpzS8aud.js";
2
- import { createViteConfig } from "./config-PwUHqZ_X.js";
2
+ import { createViteConfig } from "./config-C7sZtiY2.js";
3
3
  import { createServer, mergeConfig } from "vite";
4
4
 
5
5
  //#region src/cli/dev.ts
@@ -82,6 +82,7 @@ const en = {
82
82
  toastSlideMoveFailed: "Failed to move slide",
83
83
  toastFolderDeleted: "Deleted folder “{name}”",
84
84
  toastFolderDeleteFailed: "Failed to delete folder",
85
+ toastFolderReorderFailed: "Failed to reorder folders",
85
86
  pickIcon: "Pick icon"
86
87
  },
87
88
  slide: {
@@ -337,10 +338,6 @@ const en = {
337
338
  dark: "Dark",
338
339
  system: "System"
339
340
  },
340
- clickNav: {
341
- prevAria: "Previous page",
342
- nextAria: "Next page"
343
- },
344
341
  imagePlaceholder: {
345
342
  dropOverlay: "Drop image to use here",
346
343
  uploading: "Uploading…",
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { Locale, Plural } from "./types-B-KrjgX8.js";
2
- import { OpenSlideConfig } from "./config-CfMThYN9.js";
1
+ import { Locale, Plural } from "./types-Bvk1pM70.js";
2
+ import { OpenSlideConfig } from "./config-D1bANimZ.js";
3
3
  import { CSSProperties, ComponentType, HTMLAttributes } from "react";
4
4
  import * as react_jsx_runtime0 from "react/jsx-runtime";
5
5
 
@@ -52,9 +52,26 @@ declare function useSlidePageNumber(): {
52
52
  total: number;
53
53
  };
54
54
 
55
+ //#endregion
56
+ //#region src/app/lib/transition.d.ts
57
+ type TransitionPhase = {
58
+ keyframes: Keyframe[] | PropertyIndexedKeyframes;
59
+ easing?: string;
60
+ duration?: number;
61
+ delay?: number;
62
+ };
63
+ type SlideTransition = {
64
+ duration: number;
65
+ easing?: string;
66
+ enter?: TransitionPhase;
67
+ exit?: TransitionPhase;
68
+ };
69
+
55
70
  //#endregion
56
71
  //#region src/app/lib/sdk.d.ts
57
- type Page = ComponentType;
72
+ type Page = ComponentType & {
73
+ transition?: SlideTransition;
74
+ };
58
75
  type SlideMeta = {
59
76
  title?: string;
60
77
  theme?: string;
@@ -66,9 +83,10 @@ type SlideModule = {
66
83
  meta?: SlideMeta;
67
84
  design?: DesignSystem;
68
85
  notes?: (string | undefined)[];
86
+ transition?: SlideTransition;
69
87
  };
70
88
  declare const CANVAS_WIDTH = 1920;
71
89
  declare const CANVAS_HEIGHT = 1080;
72
90
 
73
91
  //#endregion
74
- export { CANVAS_HEIGHT, CANVAS_WIDTH, DesignFonts, DesignPalette, DesignSystem, DesignTypeScale, ImagePlaceholder, ImagePlaceholderProps, Locale, OpenSlideConfig, Page, Plural, SlideMeta, SlideModule, cssVarsToString, defaultDesign, designToCssVars, useSlidePageNumber };
92
+ export { CANVAS_HEIGHT, CANVAS_WIDTH, DesignFonts, DesignPalette, DesignSystem, DesignTypeScale, ImagePlaceholder, ImagePlaceholderProps, Locale, OpenSlideConfig, Page, Plural, SlideMeta, SlideModule, SlideTransition, TransitionPhase, cssVarsToString, defaultDesign, designToCssVars, useSlidePageNumber };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { en } from "./en-BDnM5zKJ.js";
1
+ import { en } from "./en-hyGpmL1O.js";
2
2
  import { cssVarsToString, defaultDesign, designToCssVars } from "./design-cpzS8aud.js";
3
3
  import { createContext, useContext, useRef, useState } from "react";
4
4
  import { toast } from "sonner";
@@ -1,4 +1,4 @@
1
- import { Locale, Plural } from "../types-B-KrjgX8.js";
1
+ import { Locale, Plural } from "../types-Bvk1pM70.js";
2
2
 
3
3
  //#region src/locale/en.d.ts
4
4
  declare const en: Locale;
@@ -1,4 +1,4 @@
1
- import { en } from "../en-BDnM5zKJ.js";
1
+ import { en } from "../en-hyGpmL1O.js";
2
2
 
3
3
  //#region src/locale/format.ts
4
4
  function format(template, vars) {
@@ -96,6 +96,7 @@ const ja = {
96
96
  toastSlideMoveFailed: "スライドの移動に失敗しました",
97
97
  toastFolderDeleted: "フォルダ「{name}」を削除しました",
98
98
  toastFolderDeleteFailed: "フォルダの削除に失敗しました",
99
+ toastFolderReorderFailed: "フォルダの並び替えに失敗しました",
99
100
  pickIcon: "アイコンを選択"
100
101
  },
101
102
  slide: {
@@ -351,10 +352,6 @@ const ja = {
351
352
  dark: "ダーク",
352
353
  system: "システム"
353
354
  },
354
- clickNav: {
355
- prevAria: "前のページ",
356
- nextAria: "次のページ"
357
- },
358
355
  imagePlaceholder: {
359
356
  dropOverlay: "ここにドロップして使用",
360
357
  uploading: "アップロード中…",
@@ -473,6 +470,7 @@ const zhCN = {
473
470
  toastSlideMoveFailed: "移动幻灯片失败",
474
471
  toastFolderDeleted: "已删除文件夹\"{name}\"",
475
472
  toastFolderDeleteFailed: "删除文件夹失败",
473
+ toastFolderReorderFailed: "文件夹排序失败",
476
474
  pickIcon: "选择图标"
477
475
  },
478
476
  slide: {
@@ -728,10 +726,6 @@ const zhCN = {
728
726
  dark: "深色",
729
727
  system: "系统"
730
728
  },
731
- clickNav: {
732
- prevAria: "上一页",
733
- nextAria: "下一页"
734
- },
735
729
  imagePlaceholder: {
736
730
  dropOverlay: "拖入图片以使用",
737
731
  uploading: "上传中…",
@@ -850,6 +844,7 @@ const zhTW = {
850
844
  toastSlideMoveFailed: "移動投影片失敗",
851
845
  toastFolderDeleted: "已刪除資料夾「{name}」",
852
846
  toastFolderDeleteFailed: "刪除資料夾失敗",
847
+ toastFolderReorderFailed: "資料夾排序失敗",
853
848
  pickIcon: "選擇圖示"
854
849
  },
855
850
  slide: {
@@ -1105,10 +1100,6 @@ const zhTW = {
1105
1100
  dark: "深色",
1106
1101
  system: "系統"
1107
1102
  },
1108
- clickNav: {
1109
- prevAria: "上一頁",
1110
- nextAria: "下一頁"
1111
- },
1112
1103
  imagePlaceholder: {
1113
1104
  dropOverlay: "拖入圖片以使用",
1114
1105
  uploading: "上傳中…",
@@ -1,5 +1,5 @@
1
1
  import "./design-cpzS8aud.js";
2
- import { createViteConfig } from "./config-PwUHqZ_X.js";
2
+ import { createViteConfig } from "./config-C7sZtiY2.js";
3
3
  import { mergeConfig, preview as preview$1 } from "vite";
4
4
 
5
5
  //#region src/cli/preview.ts
@@ -90,6 +90,7 @@ type Locale = {
90
90
  /** template: "Deleted folder “{name}”" */
91
91
  toastFolderDeleted: string;
92
92
  toastFolderDeleteFailed: string;
93
+ toastFolderReorderFailed: string;
93
94
  pickIcon: string;
94
95
  };
95
96
  slide: {
@@ -365,10 +366,6 @@ type Locale = {
365
366
  dark: string;
366
367
  system: string;
367
368
  };
368
- clickNav: {
369
- prevAria: string;
370
- nextAria: string;
371
- };
372
369
  imagePlaceholder: {
373
370
  dropOverlay: string;
374
371
  uploading: string;
@@ -1,5 +1,5 @@
1
- import "../types-B-KrjgX8.js";
2
- import { OpenSlideConfig } from "../config-CfMThYN9.js";
1
+ import "../types-Bvk1pM70.js";
2
+ import { OpenSlideConfig } from "../config-D1bANimZ.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-PwUHqZ_X.js";
2
+ import { createViteConfig } from "../config-C7sZtiY2.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.6.0",
3
+ "version": "1.8.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": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: create-theme
3
- description: Use this skill when the user wants to create, draft, author, or extract a slide theme in this open-slide repo. Triggers on phrases like "create a theme", "make a theme called X", "extract a theme from <slide>", "build a theme from these images". Produces two paired files under `themes/`: `<id>.md` (palette, typography, layout, fixed Title/Footer components, motion) and `<id>.demo.tsx` (a runnable demo slide that the dev-UI Themes panel previews). Do NOT use for editing real slides — only for authoring the theme bundle.
3
+ description: Use this skill when the user wants to create, draft, author, or extract a slide theme in this open-slide repo. Triggers on phrases like "create a theme", "make a theme called X", "extract a theme from <slide>", "build a theme from these images". Produces two paired files under `themes/` `<id>.md` (palette, typography, layout, fixed Title/Footer components, motion) and `<id>.demo.tsx` (a runnable demo slide that the dev-UI Themes panel previews). Do NOT use for editing real slides — only for authoring the theme bundle.
4
4
  ---
5
5
 
6
6
  # Create a slide theme
@@ -308,6 +308,174 @@ 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
+ ## Page transitions
312
+
313
+ 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.
314
+
315
+ `prefers-reduced-motion: reduce` is honored automatically. You don't write a fallback.
316
+
317
+ ### Contract
318
+
319
+ Module-level for the whole deck; per-page to override. 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.
320
+
321
+ ```tsx
322
+ import type { Page, SlideTransition } from '@open-slide/core';
323
+
324
+ const Cover: Page = () => <section>…</section>;
325
+ const Body: Page = () => <section>…</section>;
326
+
327
+ // Module-level default — every page inherits unless it overrides.
328
+ export const transition: SlideTransition = { /* … */ };
329
+
330
+ // Per-page override.
331
+ Cover.transition = { /* … */ };
332
+
333
+ export default [Cover, Body];
334
+ ```
335
+
336
+ ```ts
337
+ type TransitionPhase = {
338
+ keyframes: Keyframe[] | PropertyIndexedKeyframes; // WAAPI keyframes
339
+ duration?: number; // ms (falls back to top-level duration)
340
+ easing?: string; // CSS easing
341
+ delay?: number; // ms — use to overlap exit + enter
342
+ };
343
+ type SlideTransition = {
344
+ duration: number; // top-level fallback
345
+ easing?: string; // top-level fallback
346
+ enter?: TransitionPhase; // runs on incoming page
347
+ exit?: TransitionPhase; // runs on outgoing page
348
+ };
349
+ ```
350
+
351
+ The framework also exposes `--osd-dir` (`1` forward, `-1` backward) and `data-osd-dir` on the wrapper, so a single keyframe can mirror direction without a JS callback.
352
+
353
+ ### Design principles (hold the line)
354
+
355
+ The single loudest signal of "made in PowerPoint" is six different transitions in one deck. Restraint is the rhythm.
356
+
357
+ - **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.
358
+ - **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; reserve for genuine state changes.
359
+ - **Magnitude ceiling: 12 px or 3% scale.** A 6 px Y-rise reads as "next thought." A 1920 px translateX reads as "different document." Premium tools move barely enough to register.
360
+ - **Opacity is always part of it.** Pure-transform transitions look stiff; pure-opacity transitions are the safest possible default.
361
+ - **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` (feels like a slideshow). Reserve symmetric `ease-in-out` for state-anchored morphs only.
362
+
363
+ ### Tasteful family — six members, one DNA
364
+
365
+ Use this set as a starting point. Pick one as the deck's house transition; optionally reserve a second for hero/cover slides and a third for genuine section breaks. The CSS-`calc` + `--osd-dir` trick lets a single definition mirror itself on backward navigation when needed.
366
+
367
+ ```tsx
368
+ const EASE_OUT = 'cubic-bezier(0, 0, 0.2, 1)';
369
+ const EASE_IN = 'cubic-bezier(0.4, 0, 1, 1)';
370
+
371
+ // RISE — house quiet. 6 px Y. Use as module default.
372
+ export const transition: SlideTransition = {
373
+ duration: 200,
374
+ exit: { duration: 140, easing: EASE_IN,
375
+ keyframes: [
376
+ { opacity: 1, transform: 'translateY(0)' },
377
+ { opacity: 0, transform: 'translateY(-4px)' },
378
+ ] },
379
+ enter: { duration: 200, delay: 80, easing: EASE_OUT,
380
+ keyframes: [
381
+ { opacity: 0, transform: 'translateY(6px)' },
382
+ { opacity: 1, transform: 'translateY(0)' },
383
+ ] },
384
+ };
385
+
386
+ // DISSOLVE — pure opacity. The quietest possible.
387
+ const dissolve: SlideTransition = {
388
+ duration: 240,
389
+ exit: { duration: 200, easing: EASE_IN,
390
+ keyframes: [{ opacity: 1 }, { opacity: 0 }] },
391
+ enter: { duration: 240, delay: 40, easing: EASE_OUT,
392
+ keyframes: [{ opacity: 0 }, { opacity: 1 }] },
393
+ };
394
+
395
+ // SETTLE — cover-grade. Rise + a hair of blur on enter only.
396
+ Cover.transition = {
397
+ duration: 280,
398
+ exit: { duration: 160, easing: EASE_IN,
399
+ keyframes: [
400
+ { opacity: 1, transform: 'translateY(0)' },
401
+ { opacity: 0, transform: 'translateY(-6px)' },
402
+ ] },
403
+ enter: { duration: 280, delay: 100, easing: EASE_OUT,
404
+ keyframes: [
405
+ { opacity: 0, transform: 'translateY(12px)', filter: 'blur(4px)' },
406
+ { opacity: 1, transform: 'translateY(0)', filter: 'blur(0)' },
407
+ ] },
408
+ };
409
+
410
+ // BLOOM — scale 0.97 → 1, no translate. Materializes in place.
411
+ const bloom: SlideTransition = {
412
+ duration: 240,
413
+ exit: { duration: 160, easing: EASE_IN,
414
+ keyframes: [
415
+ { opacity: 1, transform: 'scale(1)' },
416
+ { opacity: 0, transform: 'scale(1.01)' },
417
+ ] },
418
+ enter: { duration: 240, delay: 80, easing: EASE_OUT,
419
+ keyframes: [
420
+ { opacity: 0, transform: 'scale(0.97)' },
421
+ { opacity: 1, transform: 'scale(1)' },
422
+ ] },
423
+ };
424
+
425
+ // FALL — mirrored Rise. Incoming page comes down from above.
426
+ const fall: SlideTransition = {
427
+ duration: 200,
428
+ exit: { duration: 140, easing: EASE_IN,
429
+ keyframes: [
430
+ { opacity: 1, transform: 'translateY(0)' },
431
+ { opacity: 0, transform: 'translateY(4px)' },
432
+ ] },
433
+ enter: { duration: 200, delay: 80, easing: EASE_OUT,
434
+ keyframes: [
435
+ { opacity: 0, transform: 'translateY(-6px)' },
436
+ { opacity: 1, transform: 'translateY(0)' },
437
+ ] },
438
+ };
439
+
440
+ // BREATH — section break. Exit fully, hold 120 ms, then enter.
441
+ // Reserve for genuine chapter dividers; use at most 1–2× per deck.
442
+ const breath: SlideTransition = {
443
+ duration: 460,
444
+ exit: { duration: 180, easing: EASE_IN,
445
+ keyframes: [{ opacity: 1 }, { opacity: 0 }] },
446
+ enter: { duration: 240, delay: 300, easing: EASE_OUT,
447
+ keyframes: [
448
+ { opacity: 0, transform: 'translateY(8px)' },
449
+ { opacity: 1, transform: 'translateY(0)' },
450
+ ] },
451
+ };
452
+ ```
453
+
454
+ 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.
455
+
456
+ ### Direction-aware keyframes (use sparingly)
457
+
458
+ Most tasteful tools don't mirror on backward navigation. When you genuinely need to — e.g. a horizontal slide that should reverse — use `--osd-dir` inside `calc()`:
459
+
460
+ ```tsx
461
+ { transform: 'translateX(calc(var(--osd-dir, 1) * 8px))' },
462
+ { transform: 'translateX(0)' },
463
+ ```
464
+
465
+ If you find yourself reaching for this on every transition, you're probably over-designing. Forward = backward is the more refined default.
466
+
467
+ ### Transition anti-patterns
468
+
469
+ - ❌ Six different transitions across six pages — the single loudest "made in PowerPoint" tell.
470
+ - ❌ `translateX(100%)` slide-from-side — iOS modal / PowerPoint Push; not a slide change.
471
+ - ❌ Aggressive scale-pop (e.g. `0.85 → 1`) + blur — lightbox / photo-viewer vocabulary; implies zooming *into* something.
472
+ - ❌ `clip-path: inset(…)` reveals — After Effects vocabulary; theatrical.
473
+ - ❌ Parallel blur on both layers at once — visual mush; the eye can't fixate.
474
+ - ❌ Duration > 350 ms for a standard slide change — drags.
475
+ - ❌ Translate > 12 px or scale > 3% — reads as rupture, not continuity.
476
+ - ❌ `linear` easing — feels like a slideshow, not a product.
477
+ - ❌ Declaring a transition on every deck. **If you don't have a clear reason, omit it.** Snap-swap is fine.
478
+
311
479
  ## Repeated elements: component, not `map`
312
480
 
313
481
  When a page has visually repeated items — cards, logo rows, gallery tiles, list rows, step indicators — **define a small component and instantiate it once per item**. Do **not** render the group with `array.map` over a data array.
@@ -373,6 +541,7 @@ This applies whenever the *visual element* repeats, not whenever the *data* does
373
541
  - [ ] Visually repeated elements (cards, tiles, logo rows) are rendered as explicit `<Component />` instances, not via `array.map` over a data list.
374
542
  - [ ] All imported assets exist on disk — slide-local under `slides/<id>/assets/`, or global under `assets/` (imported via `@assets/...`).
375
543
  - [ ] 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.
544
+ - [ ] 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.
376
545
  - [ ] Nothing outside `slides/<id>/` was edited.
377
546
 
378
547
  ## Anti-patterns