diff --git a/.changeset/slide-transition-api.md b/.changeset/slide-transition-api.md new file mode 100644 index 00000000..4f83ea06 --- /dev/null +++ b/.changeset/slide-transition-api.md @@ -0,0 +1,6 @@ +--- +'@open-slide/core': minor +'@open-slide/cli': patch +--- + +Add SlideTransition API for declaring per-page page-transition animations, plus a transitions section in the bundled slide-authoring skill with tasteful few-shot examples. diff --git a/apps/demo/slides/slide-transitions-maximal/index.tsx b/apps/demo/slides/slide-transitions-maximal/index.tsx new file mode 100644 index 00000000..b633de1d --- /dev/null +++ b/apps/demo/slides/slide-transitions-maximal/index.tsx @@ -0,0 +1,558 @@ +import type { DesignSystem, Page, SlideMeta, SlideTransition } from '@open-slide/core'; +import type { CSSProperties } from 'react'; + +export const design: DesignSystem = { + palette: { bg: '#08080a', text: '#fafaf5', accent: '#ff2d4a' }, + fonts: { + display: '"Inter Tight", "Inter", -apple-system, BlinkMacSystemFont, system-ui, sans-serif', + body: '-apple-system, BlinkMacSystemFont, "Inter", system-ui, sans-serif', + }, + typeScale: { hero: 200, body: 32 }, + radius: 4, +}; + +const muted = 'rgba(250, 250, 245, 0.46)'; +const hairline = 'rgba(250, 250, 245, 0.12)'; + +const fill = { + width: '100%', + height: '100%', + fontFamily: 'var(--osd-font-body)', + background: 'var(--osd-bg)', + color: 'var(--osd-text)', +} as const; + +const EYEBROW: CSSProperties = { + fontSize: 18, + letterSpacing: '0.32em', + color: 'var(--osd-accent)', + textTransform: 'uppercase', + fontWeight: 600, +}; + +const FOOT: CSSProperties = { + fontSize: 17, + letterSpacing: '0.28em', + color: muted, + textTransform: 'uppercase', + fontVariantNumeric: 'tabular-nums', + fontWeight: 500, +}; + +const Cover: Page = () => ( +
+
+
open-slide · field notes · vol. ii
+
showcase
+
+
+

+ Maximal. +

+

+ Six effects you can’t draw in a binary slide format — every one in under twenty lines + of code, every one rendered by your browser, every frame still vector. +

+
+
+ 01 · iris + arrow keys ⇆ +
+
+); + +const Show = ({ + n, + label, + heading, + pull, + body, + glyph, +}: { + n: string; + label: string; + heading: string; + pull: string; + body: string; + glyph: React.ReactNode; +}) => ( +
+
+
{`§ ${n} · ${label}`}
+
effect
+
+
+
+
{glyph}
+

+ {heading} +

+
+
+

+ “{pull}” +

+

+ {body} +

+
+
+
+ + {n} · {label} + + transition · {label} +
+
+); + +const FlipGlyph = ( + + + + + +); + +const GlitchGlyph = ( +
+ + R/G/B + +
+); + +const WarpGlyph = ( + + {Array.from({ length: 7 }).map((_, i) => ( + + ))} + +); + +const ShearGlyph = ( + + + + + +); + +const PortalGlyph = ( + + + + + + +); + +const Flip: Page = () => ( + +); + +const Glitch: Page = () => ( + +); + +const Warp: Page = () => ( + +); + +const Sweep: Page = () => ( + +); + +const Closing: Page = () => ( +
+
+
§ 06 · portal
+
fin
+
+
+
{PortalGlyph}
+

+ All possible. +
+ None recommended + . +

+

+ Every effect in this deck is a few keyframes — clip-path, perspective, filter, skew, the + whole CSS animation surface, exposed verbatim. Use them when the moment earns it. Then head + back to On Tasteful Transitions and remember why you didn’t. +

+
+
+ 06 · portal + ← to revisit +
+
+); + +const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)'; +const EASE_IN = 'cubic-bezier(0.7, 0, 0.84, 0)'; + +// 1 · IRIS — clip-path circle collapses to a point, then expands. +// Round-trip dimensions: 80% radius covers a 16:9 reference box corner-to-corner. +Cover.transition = { + duration: 700, + exit: { + duration: 320, + easing: EASE_IN, + keyframes: [ + { clipPath: 'circle(80% at 50% 50%)', opacity: 1 }, + { clipPath: 'circle(0% at 50% 50%)', opacity: 1 }, + ], + }, + enter: { + duration: 520, + delay: 220, + easing: EASE_OUT, + keyframes: [ + { clipPath: 'circle(0% at 50% 50%)', opacity: 1 }, + { clipPath: 'circle(80% at 50% 50%)', opacity: 1 }, + ], + }, +}; + +// 2 · FLIP — perspective + rotateY. Genuine 3D, not a sprite sheet. +Flip.transition = { + duration: 760, + exit: { + duration: 380, + easing: EASE_IN, + keyframes: [ + { + opacity: 1, + transform: 'perspective(1600px) rotateY(0deg) translateZ(0)', + transformOrigin: '50% 50%', + }, + { + opacity: 0, + transform: 'perspective(1600px) rotateY(-22deg) translateZ(-260px)', + transformOrigin: '50% 50%', + }, + ], + }, + enter: { + duration: 520, + delay: 240, + easing: EASE_OUT, + keyframes: [ + { + opacity: 0, + transform: 'perspective(1600px) rotateY(22deg) translateZ(-260px)', + transformOrigin: '50% 50%', + }, + { + opacity: 1, + transform: 'perspective(1600px) rotateY(0deg) translateZ(0)', + transformOrigin: '50% 50%', + }, + ], + }, +}; + +// 3 · GLITCH — chromatic-aberration via stacked drop-shadows, stepped easing +// fakes a dropped framerate. The kind of motion a binary slide format can't even describe. +Glitch.transition = { + duration: 560, + exit: { + duration: 260, + easing: 'steps(5, end)', + keyframes: [ + { + opacity: 1, + filter: 'drop-shadow(0 0 0 transparent) drop-shadow(0 0 0 transparent)', + transform: 'translate(0, 0)', + }, + { + opacity: 0.85, + filter: 'drop-shadow(6px 0 0 #00ffff) drop-shadow(-6px 0 0 #ff0044)', + transform: 'translate(-3px, 1px)', + }, + { + opacity: 0.55, + filter: 'drop-shadow(-12px 0 0 #00ffff) drop-shadow(12px 0 0 #ff0044)', + transform: 'translate(4px, -2px)', + }, + { + opacity: 0.25, + filter: 'drop-shadow(16px 0 0 #00ffff) drop-shadow(-16px 0 0 #ff0044)', + transform: 'translate(-2px, 2px)', + }, + { + opacity: 0, + filter: 'drop-shadow(0 0 0 transparent) drop-shadow(0 0 0 transparent)', + transform: 'translate(0, 0)', + }, + ], + }, + enter: { + duration: 340, + delay: 220, + easing: 'steps(6, end)', + keyframes: [ + { + opacity: 0, + filter: 'drop-shadow(-16px 0 0 #00ffff) drop-shadow(16px 0 0 #ff0044)', + transform: 'translate(6px, -2px)', + }, + { + opacity: 0.4, + filter: 'drop-shadow(10px 0 0 #00ffff) drop-shadow(-10px 0 0 #ff0044)', + transform: 'translate(-4px, 2px)', + }, + { + opacity: 0.75, + filter: 'drop-shadow(-4px 0 0 #00ffff) drop-shadow(4px 0 0 #ff0044)', + transform: 'translate(2px, -1px)', + }, + { + opacity: 1, + filter: 'drop-shadow(0 0 0 transparent) drop-shadow(0 0 0 transparent)', + transform: 'translate(0, 0)', + }, + ], + }, +}; + +// 4 · WARP — blur burst + scale. Reads as velocity; renders as filter+transform. +// Module default; anything not overridden inherits this. +export const transition: SlideTransition = { + duration: 700, + exit: { + duration: 300, + easing: 'cubic-bezier(0.55, 0, 1, 0.45)', + keyframes: [ + { opacity: 1, transform: 'scale(1)', filter: 'blur(0) saturate(1)' }, + { + opacity: 0, + transform: 'scale(1.4)', + filter: 'blur(30px) saturate(1.6)', + }, + ], + }, + enter: { + duration: 480, + delay: 220, + easing: EASE_OUT, + keyframes: [ + { + opacity: 0, + transform: 'scale(0.6)', + filter: 'blur(36px) saturate(1.4)', + }, + { opacity: 1, transform: 'scale(1)', filter: 'blur(0) saturate(1)' }, + ], + }, +}; + +Warp.transition = transition; + +// 5 · SWEEP — skewX + translateX. The outgoing card swipes past the camera +// at a tilt; the new one arrives from the other side, also tilted, then squares up. +Sweep.transition = { + duration: 760, + exit: { + duration: 360, + easing: EASE_IN, + keyframes: [ + { opacity: 1, transform: 'translateX(0) skewX(0deg)' }, + { opacity: 0, transform: 'translateX(-118%) skewX(-14deg)' }, + ], + }, + enter: { + duration: 480, + delay: 240, + easing: EASE_OUT, + keyframes: [ + { opacity: 0, transform: 'translateX(118%) skewX(-14deg)' }, + { opacity: 1, transform: 'translateX(0) skewX(0deg)' }, + ], + }, +}; + +// 6 · PORTAL — rotate + scale collapse, mirrored on entry. The outgoing page +// spirals into a point; the inbound unfurls back out from the other direction. +Closing.transition = { + duration: 820, + exit: { + duration: 400, + easing: EASE_IN, + keyframes: [ + { opacity: 1, transform: 'rotate(0deg) scale(1)' }, + { opacity: 0, transform: 'rotate(-110deg) scale(0)' }, + ], + }, + enter: { + duration: 560, + delay: 260, + easing: EASE_OUT, + keyframes: [ + { opacity: 0, transform: 'rotate(110deg) scale(0)' }, + { opacity: 1, transform: 'rotate(0deg) scale(1)' }, + ], + }, +}; + +export const meta: SlideMeta = { + title: 'Maximal — Six Transitions', + createdAt: '2026-05-24T00:00:00.000Z', +}; + +export default [Cover, Flip, Glitch, Warp, Sweep, Closing] satisfies Page[]; diff --git a/apps/demo/slides/slide-transitions/index.tsx b/apps/demo/slides/slide-transitions/index.tsx new file mode 100644 index 00000000..ce51a8a6 --- /dev/null +++ b/apps/demo/slides/slide-transitions/index.tsx @@ -0,0 +1,434 @@ +import type { DesignSystem, Page, SlideMeta, SlideTransition } from '@open-slide/core'; +import type { CSSProperties } from 'react'; + +export const design: DesignSystem = { + palette: { bg: '#0c0c0d', text: '#f3f1ea', accent: '#d6d2c4' }, + fonts: { + display: 'ui-serif, Georgia, "Times New Roman", serif', + body: '-apple-system, BlinkMacSystemFont, "Inter", system-ui, sans-serif', + }, + typeScale: { hero: 168, body: 34 }, + radius: 6, +}; + +const muted = 'rgba(243, 241, 234, 0.42)'; +const hairline = 'rgba(243, 241, 234, 0.10)'; + +const fill = { + width: '100%', + height: '100%', + fontFamily: 'var(--osd-font-body)', + background: 'var(--osd-bg)', + color: 'var(--osd-text)', +} as const; + +const EYEBROW: CSSProperties = { + fontSize: 20, + letterSpacing: '0.28em', + color: 'var(--osd-accent)', + textTransform: 'uppercase', + fontWeight: 500, +}; + +const FOOT: CSSProperties = { + fontSize: 18, + letterSpacing: '0.22em', + color: muted, + textTransform: 'uppercase', + fontVariantNumeric: 'tabular-nums', +}; + +const Cover: Page = () => ( +
+
open-slide · field notes
+
+

+ On tasteful +
+ transitions. +

+

+ Six pages, six transitions, one quiet family of motion. +

+
+
+ 01 · settle + arrow keys ⇆ +
+
+); + +const Lesson = ({ + n, + label, + heading, + body, + pull, +}: { + n: string; + label: string; + heading: string; + body: string; + pull: string; +}) => ( +
+
{`§ ${n}`}
+
+

+ {heading} +

+
+

+ “{pull}” +

+

+ {body} +

+
+
+
+ + {n} · {label} + + transition · {label} +
+
+); + +const Family: Page = () => ( + +); + +const ShortDurations: Page = () => ( + +); + +const Pause: Page = () => ( +
+
§ intermission
+

+ Restraint. +

+

+ A chapter deserves a breath — exit, hold, then return. +

+
+ 04 · breath + transition · breath +
+
+); + +const SmallMagnitudes: Page = () => ( + +); + +const Closing: Page = () => ( +
+
fin
+
+

+ Good motion is +
+ invisible. +

+

+ Six different moves, none of which announce themselves. The reader perceives variety; the + eye still reads one consistent hand. +

+
+
+ 06 · fall + ← to revisit +
+
+); + +// Shared DNA across all six transitions: +// - Out-then-in with 80 ms overlap (exit starts immediately, enter delays). +// - Exit ~140-180 ms · ease-in. Enter ~200-280 ms · ease-out. +// - Opacity is always one of the animated properties. +// - Translate magnitude never exceeds 12px. Scale never exceeds 3%. +const EASE_OUT = 'cubic-bezier(0, 0, 0.2, 1)'; +const EASE_IN = 'cubic-bezier(0.4, 0, 1, 1)'; + +// 1 · SETTLE — cover-grade. Rise + soft blur falloff on enter. +Cover.transition = { + duration: 280, + easing: 'cubic-bezier(0.32, 0.72, 0, 1)', + 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)' }, + ], + }, +}; + +// 2 · DISSOLVE — pure opacity. Apple's safe default. Quietest possible. +Family.transition = { + 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 }], + }, +}; + +// 3 · RISE — the house quiet. 6 px of Y, exit-then-enter overlap. +// Exported as the module default so future pages inherit it. +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)' }, + ], + }, +}; + +// 4 · BREATH — section divider. Exit fully, hold 120 ms, then enter. +Pause.transition = { + 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)' }, + ], + }, +}; + +// 5 · BLOOM — scale only. 0.97 → 1, no translate. Materializes in place. +SmallMagnitudes.transition = { + 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)' }, + ], + }, +}; + +// 6 · FALL — mirrored Rise. Enters from above; the deck settles to a stop. +Closing.transition = { + 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 const meta: SlideMeta = { + title: 'On Tasteful Transitions', + createdAt: '2026-05-20T06:12:31.353Z', +}; + +export default [Cover, Family, ShortDurations, Pause, SmallMagnitudes, Closing] satisfies Page[]; diff --git a/packages/cli/template/.agents/skills/slide-authoring/SKILL.md b/packages/cli/template/.agents/skills/slide-authoring/SKILL.md index e6b472f7..e0edd272 100644 --- a/packages/cli/template/.agents/skills/slide-authoring/SKILL.md +++ b/packages/cli/template/.agents/skills/slide-authoring/SKILL.md @@ -297,6 +297,174 @@ const Footer = () => { `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 `