@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.
- package/dist/{build-tLrkKUHr.js → build-CCZDC8eF.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-PwUHqZ_X.js → config-C7sZtiY2.js} +45 -18
- package/dist/{config-CfMThYN9.d.ts → config-D1bANimZ.d.ts} +1 -1
- package/dist/{dev-DpCIRbhT.js → dev-kLS_4CAI.js} +1 -1
- package/dist/{en-BDnM5zKJ.js → en-hyGpmL1O.js} +1 -4
- package/dist/index.d.ts +22 -4
- package/dist/index.js +1 -1
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +4 -13
- package/dist/{preview-BSGlM6Se.js → preview-DUkOjOx8.js} +1 -1
- package/dist/{types-B-KrjgX8.d.ts → types-Bvk1pM70.d.ts} +1 -4
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/create-theme/SKILL.md +1 -1
- package/skills/slide-authoring/SKILL.md +169 -0
- package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
- package/src/app/components/inspector/comment-widget.tsx +16 -2
- package/src/app/components/inspector/inspect-overlay.tsx +132 -35
- package/src/app/components/inspector/inspector-panel.tsx +19 -256
- package/src/app/components/inspector/inspector-provider.tsx +102 -1
- package/src/app/components/panel/save-card.tsx +4 -4
- package/src/app/components/player.tsx +25 -25
- package/src/app/components/sidebar/folder-item.tsx +7 -2
- package/src/app/components/sidebar/sidebar.tsx +87 -16
- package/src/app/components/slide-transition-layer.tsx +154 -0
- package/src/app/components/style-panel/style-panel.tsx +3 -0
- package/src/app/lib/folders.ts +28 -0
- package/src/app/lib/inspector/fiber.test.ts +154 -0
- package/src/app/lib/inspector/fiber.ts +12 -1
- package/src/app/lib/sdk.ts +3 -1
- package/src/app/lib/transition.ts +23 -0
- package/src/app/lib/use-click-page-navigation.ts +52 -0
- package/src/app/lib/use-is-mobile.ts +21 -0
- package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
- package/src/app/routes/home-shell.tsx +8 -0
- package/src/app/routes/home.tsx +1 -1
- package/src/app/routes/slide.tsx +92 -60
- package/src/locale/en.ts +1 -5
- package/src/locale/ja.ts +1 -5
- package/src/locale/types.ts +1 -5
- package/src/locale/zh-cn.ts +1 -5
- package/src/locale/zh-tw.ts +1 -5
- package/src/app/components/click-nav-zones.tsx +0 -36
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-
|
|
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-
|
|
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-
|
|
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))
|
|
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
|
-
|
|
1166
|
-
if (!t$3.
|
|
1167
|
-
const
|
|
1168
|
-
if (!
|
|
1169
|
-
|
|
1170
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -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-
|
|
2
|
-
import { OpenSlideConfig } from "./config-
|
|
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-
|
|
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";
|
package/dist/locale/index.d.ts
CHANGED
package/dist/locale/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { en } from "../en-
|
|
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: "上傳中…",
|
|
@@ -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;
|
package/dist/vite/index.d.ts
CHANGED
package/dist/vite/index.js
CHANGED
package/package.json
CHANGED
|
@@ -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
|
|
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
|