@revyme/runtime 0.0.1

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.
@@ -0,0 +1,58 @@
1
+ import { type ComponentType } from 'react';
2
+ export type CursorMode = 'follow' | 'replace';
3
+ export type CursorSide = 'top' | 'bottom' | 'left' | 'right';
4
+ export type CursorAlign = 'start' | 'center' | 'end';
5
+ export interface CursorTransition {
6
+ type?: 'spring' | 'tween' | 'instant';
7
+ stiffness?: number;
8
+ damping?: number;
9
+ mass?: number;
10
+ duration?: number;
11
+ ease?: string;
12
+ }
13
+ export interface CursorOpts<P = any> {
14
+ variant?: string;
15
+ mode?: CursorMode;
16
+ /**
17
+ * Which side of the mouse the cursor wrapper anchors to (Follow mode).
18
+ * Replace mode ignores side / align / offset — it auto-centers on the mouse.
19
+ */
20
+ side?: CursorSide;
21
+ /**
22
+ * Alignment along the perpendicular axis to `side`.
23
+ * top/bottom: start = left, center = horizontal center, end = right.
24
+ * left/right: start = top, center = vertical center, end = bottom.
25
+ */
26
+ align?: CursorAlign;
27
+ offsetX?: number;
28
+ offsetY?: number;
29
+ transition?: CursorTransition;
30
+ props?: Partial<P>;
31
+ /**
32
+ * Wrapper width / height applied to the cursor's outer motion.div. Useful
33
+ * for code components (canvases, sparks) that fill their parent — without
34
+ * this they'd render at their intrinsic size, which is often the whole
35
+ * viewport. Plain numbers are interpreted as px; pass a string ('100%',
36
+ * '4rem') to use other CSS units.
37
+ */
38
+ width?: number | string;
39
+ height?: number | string;
40
+ /**
41
+ * When true, fade/scale on enter and exit via AnimatePresence. Default
42
+ * false: appear and disappear instantly. The follow movement is always
43
+ * smoothed by the spring config above — `enterExit` only controls the
44
+ * mount/unmount transition.
45
+ */
46
+ enterExit?: boolean;
47
+ }
48
+ /**
49
+ * Spread the return value into an element to give it a component cursor.
50
+ * Returns onMouseEnter/onMouseLeave handlers that push/pop the global store.
51
+ *
52
+ * <button {...withCursor(Pointer, { mode: 'follow', transition: { type: 'spring', stiffness: 300 } })}>
53
+ */
54
+ export declare function withCursor<P>(Component: ComponentType<P>, opts?: CursorOpts<P>): {
55
+ onMouseEnter: () => void;
56
+ onMouseLeave: () => void;
57
+ };
58
+ export declare function CursorPortal(): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,4 @@
1
+ export { default as withResponsiveProps } from './withResponsiveProps';
2
+ export { withCursor, CursorPortal, type CursorMode, type CursorSide, type CursorAlign, type CursorTransition, type CursorOpts, } from './cursor-runtime';
3
+ export { useStaticCanvas } from './useStaticCanvas';
4
+ export { playSketchDraw, type SketchAnimOpts, type SketchAnimMode, type SketchAnimTrigger, type SketchAnimTransition, } from './sketch-draw';
package/dist/index.js ADDED
@@ -0,0 +1,378 @@
1
+ import { useEffect, useState, useSyncExternalStore } from "react";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import { AnimatePresence, motion, useMotionValue, useSpring } from "framer-motion";
4
+ import { getStroke } from "perfect-freehand";
5
+ //#region src/withResponsiveProps.tsx
6
+ /**
7
+ * HOC that reads a `data-responsive` JSON prop and merges per-viewport overrides.
8
+ * Usage: `export default withResponsiveProps(MyComponent)`
9
+ *
10
+ * On the canvas, CodeComponentHost injects `__canvasViewportWidth` to simulate viewport size.
11
+ * In production, uses `window.innerWidth`.
12
+ *
13
+ * data-responsive='{"768":{"fontSize":32},"375":{"fontSize":24}}'
14
+ * Breakpoints are max-width: if viewport <= 768, the 768 overrides apply.
15
+ */
16
+ function withResponsiveProps(Component) {
17
+ return function ResponsiveSpark(props) {
18
+ const canvasVpWidth = props.__canvasViewportWidth;
19
+ const [windowWidth, setWindowWidth] = useState(typeof window !== "undefined" ? window.innerWidth : 1440);
20
+ useEffect(() => {
21
+ if (canvasVpWidth !== void 0) return;
22
+ const handler = () => setWindowWidth(window.innerWidth);
23
+ window.addEventListener("resize", handler);
24
+ return () => window.removeEventListener("resize", handler);
25
+ }, [canvasVpWidth]);
26
+ const vpWidth = canvasVpWidth ?? windowWidth;
27
+ const responsiveStr = props["data-responsive"];
28
+ let mergedProps = { ...props };
29
+ if (responsiveStr) try {
30
+ const overrides = typeof responsiveStr === "string" ? JSON.parse(responsiveStr) : responsiveStr;
31
+ const sortedBp = [...Array.isArray(overrides._bp) ? overrides._bp : Object.keys(overrides).filter((k) => k !== "_bp").map(Number)].sort((a, b) => a - b);
32
+ let matchedBp;
33
+ for (let i = 0; i < sortedBp.length; i++) if (vpWidth > (i > 0 ? sortedBp[i - 1] : 0) && vpWidth <= sortedBp[i]) {
34
+ matchedBp = sortedBp[i];
35
+ break;
36
+ }
37
+ if (matchedBp !== void 0 && overrides[matchedBp]) mergedProps = {
38
+ ...mergedProps,
39
+ ...overrides[matchedBp]
40
+ };
41
+ } catch {}
42
+ delete mergedProps["data-responsive"];
43
+ delete mergedProps["__canvasViewportWidth"];
44
+ return /* @__PURE__ */ jsx(Component, { ...mergedProps });
45
+ };
46
+ }
47
+ //#endregion
48
+ //#region src/cursor-runtime.tsx
49
+ var _active = null;
50
+ var _listeners = /* @__PURE__ */ new Set();
51
+ var _nextKey = 0;
52
+ function _setActive(next) {
53
+ _active = next;
54
+ _listeners.forEach((l) => l());
55
+ }
56
+ function _subscribe(l) {
57
+ _listeners.add(l);
58
+ return () => {
59
+ _listeners.delete(l);
60
+ };
61
+ }
62
+ function _getActive() {
63
+ return _active;
64
+ }
65
+ /**
66
+ * Spread the return value into an element to give it a component cursor.
67
+ * Returns onMouseEnter/onMouseLeave handlers that push/pop the global store.
68
+ *
69
+ * <button {...withCursor(Pointer, { mode: 'follow', transition: { type: 'spring', stiffness: 300 } })}>
70
+ */
71
+ function withCursor(Component, opts = {}) {
72
+ return {
73
+ onMouseEnter: () => {
74
+ _setActive({
75
+ key: ++_nextKey,
76
+ Component,
77
+ opts
78
+ });
79
+ },
80
+ onMouseLeave: () => {
81
+ _setActive(null);
82
+ }
83
+ };
84
+ }
85
+ function _springConfig(t) {
86
+ if (!t || t.type === "instant") return {
87
+ stiffness: 1e3,
88
+ damping: 50,
89
+ mass: .1
90
+ };
91
+ if (t.type === "tween" && t.duration) return {
92
+ stiffness: Math.max(50, 400 / Math.max(.1, t.duration)),
93
+ damping: 30,
94
+ mass: 1
95
+ };
96
+ return {
97
+ stiffness: t.stiffness ?? 300,
98
+ damping: t.damping ?? 30,
99
+ mass: t.mass ?? 1
100
+ };
101
+ }
102
+ function CursorPortal() {
103
+ const cursor = useSyncExternalStore(_subscribe, _getActive, _getActive);
104
+ const x = useMotionValue(0);
105
+ const y = useMotionValue(0);
106
+ const sx = useSpring(x, _springConfig(cursor?.opts.transition));
107
+ const sy = useSpring(y, _springConfig(cursor?.opts.transition));
108
+ useEffect(() => {
109
+ const onMove = (e) => {
110
+ x.set(e.clientX + (cursor?.opts.offsetX ?? 0));
111
+ y.set(e.clientY + (cursor?.opts.offsetY ?? 0));
112
+ };
113
+ window.addEventListener("mousemove", onMove);
114
+ return () => window.removeEventListener("mousemove", onMove);
115
+ }, [
116
+ cursor,
117
+ x,
118
+ y
119
+ ]);
120
+ useEffect(() => {
121
+ if (cursor?.opts.mode === "replace") {
122
+ const prev = document.body.style.cursor;
123
+ document.body.style.cursor = "none";
124
+ return () => {
125
+ document.body.style.cursor = prev;
126
+ };
127
+ }
128
+ }, [cursor]);
129
+ const wrapW = typeof cursor?.opts.width === "number" ? cursor.opts.width + "px" : cursor?.opts.width;
130
+ const wrapH = typeof cursor?.opts.height === "number" ? cursor.opts.height + "px" : cursor?.opts.height;
131
+ const outerStyle = {
132
+ position: "fixed",
133
+ top: 0,
134
+ left: 0,
135
+ x: sx,
136
+ y: sy,
137
+ pointerEvents: "none",
138
+ zIndex: 9999
139
+ };
140
+ const innerStyle = {
141
+ width: wrapW,
142
+ height: wrapH,
143
+ transform: _innerTransform(cursor?.opts)
144
+ };
145
+ if (!cursor?.opts.enterExit) return cursor ? /* @__PURE__ */ jsx(motion.div, {
146
+ style: outerStyle,
147
+ children: /* @__PURE__ */ jsx("div", {
148
+ style: innerStyle,
149
+ children: /* @__PURE__ */ jsx(cursor.Component, { ...cursor.opts.props ?? {} })
150
+ })
151
+ }, cursor.key) : null;
152
+ return /* @__PURE__ */ jsx(AnimatePresence, { children: cursor && /* @__PURE__ */ jsx(motion.div, {
153
+ style: outerStyle,
154
+ initial: {
155
+ opacity: 0,
156
+ scale: .8
157
+ },
158
+ animate: {
159
+ opacity: 1,
160
+ scale: 1
161
+ },
162
+ exit: {
163
+ opacity: 0,
164
+ scale: .8
165
+ },
166
+ children: /* @__PURE__ */ jsx("div", {
167
+ style: innerStyle,
168
+ children: /* @__PURE__ */ jsx(cursor.Component, { ...cursor.opts.props ?? {} })
169
+ })
170
+ }, cursor.key) });
171
+ }
172
+ /**
173
+ * Build the inner-wrapper transform from side + align + mode. Pure CSS
174
+ * percentage translates so it works regardless of whether width/height are
175
+ * set explicitly. Replace mode auto-centers; Follow mode anchors a corner /
176
+ * edge / center based on the chosen side and alignment.
177
+ */
178
+ function _innerTransform(opts) {
179
+ if (!opts || opts.mode === "replace") return "translate(-50%, -50%)";
180
+ const side = opts.side ?? "bottom";
181
+ const align = opts.align ?? "center";
182
+ let tx = 0;
183
+ let ty = 0;
184
+ if (side === "top") ty = -100;
185
+ else if (side === "left") tx = -100;
186
+ if (side === "top" || side === "bottom") {
187
+ if (align === "center") tx = -50;
188
+ else if (align === "end") tx = -100;
189
+ } else if (align === "center") ty = -50;
190
+ else if (align === "end") ty = -100;
191
+ return "translate(" + tx + "%, " + ty + "%)";
192
+ }
193
+ //#endregion
194
+ //#region src/useStaticCanvas.ts
195
+ /**
196
+ * `useStaticCanvas()` — returns `true` when the component is being rendered
197
+ * inside the Revyme canvas editor, `false` in the live preview, published
198
+ * site, or any other consumer environment.
199
+ *
200
+ * Sparks / code components use this to skip GPU-expensive animation work
201
+ * (continuous rAF loops, big CSS blur layers, WebGL frames) on the editor
202
+ * canvas where the user only needs a representative still — paint once,
203
+ * stop. The full animated version still runs in preview and production.
204
+ *
205
+ * Mechanics: this default implementation always returns `false`. The canvas
206
+ * editor's spark loader (`code-component-runtime.ts` MODULE_MAP) overrides
207
+ * the export at compile time so it returns `true` in the canvas iframe and
208
+ * `false` in the spark editor's preview pane (which sets `previewMode`).
209
+ *
210
+ * Mirrors Framer's `useIsStaticRenderer` pattern.
211
+ */
212
+ function useStaticCanvas() {
213
+ return false;
214
+ }
215
+ //#endregion
216
+ //#region src/sketch-draw.ts
217
+ var DEFAULT_OPTS = {
218
+ trigger: "inView",
219
+ mode: "sequential",
220
+ durationScale: 1,
221
+ stagger: .5,
222
+ transition: {
223
+ type: "tween",
224
+ duration: 1,
225
+ ease: "easeOut"
226
+ },
227
+ brushSize: 8
228
+ };
229
+ function applyEase(t, transition) {
230
+ if (transition.type === "spring") {
231
+ const damping = transition.damping ?? 10;
232
+ const stiffness = transition.stiffness ?? 100;
233
+ const dampedT = 1 - Math.exp(-damping * t * .1);
234
+ const oscillation = Math.cos(t * Math.sqrt(stiffness) * .3);
235
+ return Math.min(1, dampedT * (1 - .1 * oscillation * (1 - t)));
236
+ }
237
+ switch (transition.ease ?? "easeOut") {
238
+ case "linear": return t;
239
+ case "easeIn": return t * t;
240
+ case "easeOut": return 1 - (1 - t) * (1 - t);
241
+ case "easeInOut": return t < .5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
242
+ case "circIn": return 1 - Math.sqrt(1 - t * t);
243
+ case "circOut": return Math.sqrt(1 - Math.pow(t - 1, 2));
244
+ case "backOut": {
245
+ const c1 = 1.70158;
246
+ return 1 + (c1 + 1) * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
247
+ }
248
+ default: return 1 - (1 - t) * (1 - t);
249
+ }
250
+ }
251
+ function parsePoints(raw) {
252
+ if (!raw) return [];
253
+ return raw.split(/\s+/).filter(Boolean).map((s) => {
254
+ const [x, y, p] = s.split(",");
255
+ return [
256
+ parseFloat(x) || 0,
257
+ parseFloat(y) || 0,
258
+ p != null ? parseFloat(p) : .5
259
+ ];
260
+ });
261
+ }
262
+ function outlineToD(outline) {
263
+ if (outline.length === 0) return "";
264
+ let d = `M ${outline[0][0].toFixed(2)} ${outline[0][1].toFixed(2)}`;
265
+ for (let i = 1; i < outline.length; i++) d += ` L ${outline[i][0].toFixed(2)} ${outline[i][1].toFixed(2)}`;
266
+ return d + " Z";
267
+ }
268
+ /**
269
+ * Play a sketch draw animation on the given wrapper SVG. Pass the
270
+ * options the generator emitted in source.
271
+ *
272
+ * Returns a cleanup function — wire as your useEffect's return value
273
+ * so re-mounts cancel an in-flight animation cleanly:
274
+ *
275
+ * useEffect(() => playSketchDraw(svgEl, opts), []);
276
+ *
277
+ * If `wrapperEl` is null or the wrapper has no path children with
278
+ * `data-points`, this is a no-op and returns a noop cleanup.
279
+ */
280
+ function playSketchDraw(wrapperEl, userOpts = {}) {
281
+ const noop = () => {};
282
+ if (!wrapperEl) return noop;
283
+ const opts = {
284
+ ...DEFAULT_OPTS,
285
+ ...userOpts,
286
+ transition: {
287
+ ...DEFAULT_OPTS.transition,
288
+ ...userOpts.transition
289
+ }
290
+ };
291
+ const paths = Array.from(wrapperEl.querySelectorAll("path[data-points]"));
292
+ if (paths.length === 0) return noop;
293
+ const finalDs = paths.map((p) => p.getAttribute("d") || "");
294
+ const pointsList = paths.map((p) => parsePoints(p.getAttribute("data-points") || ""));
295
+ paths.forEach((p) => p.setAttribute("d", ""));
296
+ const baseDur = (opts.transition.duration ?? 1) * 1e3 * opts.durationScale;
297
+ const maxPoints = pointsList.reduce((m, p) => Math.max(m, p.length), 1);
298
+ const perStrokeDur = pointsList.map((p) => baseDur * (p.length / maxPoints));
299
+ const startMs = [];
300
+ let cursor = 0;
301
+ for (let i = 0; i < paths.length; i++) if (opts.mode === "simultaneous") startMs.push(0);
302
+ else if (opts.mode === "staggered") {
303
+ const overlap = Math.max(0, Math.min(1, opts.stagger));
304
+ const start = i === 0 ? 0 : startMs[i - 1] + perStrokeDur[i - 1] * (1 - overlap);
305
+ startMs.push(start);
306
+ } else {
307
+ startMs.push(cursor);
308
+ cursor += perStrokeDur[i];
309
+ }
310
+ let cancelled = false;
311
+ let rafId = 0;
312
+ let started = false;
313
+ let cleanupTrigger = null;
314
+ let startTs = 0;
315
+ const tick = (now) => {
316
+ if (cancelled) return;
317
+ const elapsed = now - startTs;
318
+ let allDone = true;
319
+ for (let i = 0; i < paths.length; i++) {
320
+ const local = elapsed - startMs[i];
321
+ if (local < 0) {
322
+ allDone = false;
323
+ continue;
324
+ }
325
+ const t = Math.min(1, local / Math.max(1, perStrokeDur[i]));
326
+ if (t < 1) allDone = false;
327
+ let d;
328
+ if (t >= 1) d = finalDs[i];
329
+ else {
330
+ const eased = applyEase(t, opts.transition);
331
+ const sliceCount = Math.max(2, Math.floor(pointsList[i].length * eased));
332
+ const subset = pointsList[i].slice(0, sliceCount);
333
+ if (subset.length < 2) d = "";
334
+ else d = outlineToD(getStroke(subset, {
335
+ size: opts.brushSize,
336
+ thinning: .5,
337
+ smoothing: .5,
338
+ streamline: .5
339
+ }));
340
+ }
341
+ paths[i].setAttribute("d", d);
342
+ }
343
+ if (!allDone) rafId = requestAnimationFrame(tick);
344
+ };
345
+ const start = () => {
346
+ if (started) return;
347
+ started = true;
348
+ startTs = performance.now();
349
+ rafId = requestAnimationFrame(tick);
350
+ };
351
+ if (opts.trigger === "inView") {
352
+ const obs = new IntersectionObserver((entries) => {
353
+ if (entries.some((e) => e.isIntersecting)) {
354
+ start();
355
+ obs.disconnect();
356
+ }
357
+ }, { threshold: .2 });
358
+ obs.observe(wrapperEl);
359
+ cleanupTrigger = () => obs.disconnect();
360
+ } else if (opts.trigger === "hover") {
361
+ const onEnter = () => start();
362
+ wrapperEl.addEventListener("mouseenter", onEnter);
363
+ cleanupTrigger = () => wrapperEl.removeEventListener("mouseenter", onEnter);
364
+ } else if (opts.trigger === "tap") {
365
+ const onTap = () => start();
366
+ wrapperEl.addEventListener("click", onTap);
367
+ cleanupTrigger = () => wrapperEl.removeEventListener("click", onTap);
368
+ } else start();
369
+ return () => {
370
+ cancelled = true;
371
+ cancelAnimationFrame(rafId);
372
+ cleanupTrigger?.();
373
+ };
374
+ }
375
+ //#endregion
376
+ export { CursorPortal, playSketchDraw, useStaticCanvas, withCursor, withResponsiveProps };
377
+
378
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/withResponsiveProps.tsx","../src/cursor-runtime.tsx","../src/useStaticCanvas.ts","../src/sketch-draw.ts"],"sourcesContent":["'use client';\n\nimport { useState, useEffect, type ComponentType } from 'react';\n\n/**\n * HOC that reads a `data-responsive` JSON prop and merges per-viewport overrides.\n * Usage: `export default withResponsiveProps(MyComponent)`\n *\n * On the canvas, CodeComponentHost injects `__canvasViewportWidth` to simulate viewport size.\n * In production, uses `window.innerWidth`.\n *\n * data-responsive='{\"768\":{\"fontSize\":32},\"375\":{\"fontSize\":24}}'\n * Breakpoints are max-width: if viewport <= 768, the 768 overrides apply.\n */\nexport default function withResponsiveProps<P extends Record<string, any>>(\n Component: ComponentType<P>\n): ComponentType<P & { 'data-responsive'?: string; __canvasViewportWidth?: number }> {\n return function ResponsiveSpark(props: any) {\n const canvasVpWidth = props.__canvasViewportWidth as number | undefined;\n const [windowWidth, setWindowWidth] = useState(\n typeof window !== 'undefined' ? window.innerWidth : 1440\n );\n\n useEffect(() => {\n if (canvasVpWidth !== undefined) return;\n const handler = () => setWindowWidth(window.innerWidth);\n window.addEventListener('resize', handler);\n return () => window.removeEventListener('resize', handler);\n }, [canvasVpWidth]);\n\n const vpWidth = canvasVpWidth ?? windowWidth;\n const responsiveStr = props['data-responsive'];\n let mergedProps = { ...props };\n\n if (responsiveStr) {\n try {\n const overrides = typeof responsiveStr === 'string'\n ? JSON.parse(responsiveStr) : responsiveStr;\n // _bp contains all viewport breakpoint widths for range computation.\n // Each breakpoint's range is (prev_bp, bp]. Prevents cascade.\n const allBp = Array.isArray(overrides._bp)\n ? overrides._bp : Object.keys(overrides).filter(k => k !== '_bp').map(Number);\n const sortedBp = [...allBp].sort((a, b) => a - b);\n let matchedBp;\n for (let i = 0; i < sortedBp.length; i++) {\n const lower = i > 0 ? sortedBp[i - 1] : 0;\n if (vpWidth > lower && vpWidth <= sortedBp[i]) {\n matchedBp = sortedBp[i];\n break;\n }\n }\n if (matchedBp !== undefined && overrides[matchedBp]) {\n mergedProps = { ...mergedProps, ...overrides[matchedBp] };\n }\n } catch {}\n }\n\n delete mergedProps['data-responsive'];\n delete mergedProps['__canvasViewportWidth'];\n return <Component {...mergedProps} />;\n };\n}\n","'use client';\n\nimport { useEffect, useSyncExternalStore, type ComponentType } from 'react';\nimport { motion, AnimatePresence, useMotionValue, useSpring } from 'framer-motion';\n\nexport type CursorMode = 'follow' | 'replace';\nexport type CursorSide = 'top' | 'bottom' | 'left' | 'right';\nexport type CursorAlign = 'start' | 'center' | 'end';\n\nexport interface CursorTransition {\n type?: 'spring' | 'tween' | 'instant';\n stiffness?: number;\n damping?: number;\n mass?: number;\n duration?: number;\n ease?: string;\n}\n\nexport interface CursorOpts<P = any> {\n variant?: string;\n mode?: CursorMode;\n /**\n * Which side of the mouse the cursor wrapper anchors to (Follow mode).\n * Replace mode ignores side / align / offset — it auto-centers on the mouse.\n */\n side?: CursorSide;\n /**\n * Alignment along the perpendicular axis to `side`.\n * top/bottom: start = left, center = horizontal center, end = right.\n * left/right: start = top, center = vertical center, end = bottom.\n */\n align?: CursorAlign;\n offsetX?: number;\n offsetY?: number;\n transition?: CursorTransition;\n props?: Partial<P>;\n /**\n * Wrapper width / height applied to the cursor's outer motion.div. Useful\n * for code components (canvases, sparks) that fill their parent — without\n * this they'd render at their intrinsic size, which is often the whole\n * viewport. Plain numbers are interpreted as px; pass a string ('100%',\n * '4rem') to use other CSS units.\n */\n width?: number | string;\n height?: number | string;\n /**\n * When true, fade/scale on enter and exit via AnimatePresence. Default\n * false: appear and disappear instantly. The follow movement is always\n * smoothed by the spring config above — `enterExit` only controls the\n * mount/unmount transition.\n */\n enterExit?: boolean;\n}\n\ninterface ActiveCursor {\n key: number;\n Component: ComponentType<any>;\n opts: CursorOpts;\n}\n\n// ─── Global store (vanilla, no React) ───────────────────────────────────────\nlet _active: ActiveCursor | null = null;\nconst _listeners = new Set<() => void>();\nlet _nextKey = 0;\n\nfunction _setActive(next: ActiveCursor | null) {\n _active = next;\n _listeners.forEach((l) => l());\n}\n\nfunction _subscribe(l: () => void) {\n _listeners.add(l);\n return () => { _listeners.delete(l); };\n}\n\nfunction _getActive() {\n return _active;\n}\n\n/**\n * Spread the return value into an element to give it a component cursor.\n * Returns onMouseEnter/onMouseLeave handlers that push/pop the global store.\n *\n * <button {...withCursor(Pointer, { mode: 'follow', transition: { type: 'spring', stiffness: 300 } })}>\n */\nexport function withCursor<P>(Component: ComponentType<P>, opts: CursorOpts<P> = {}) {\n return {\n onMouseEnter: () => {\n _setActive({ key: ++_nextKey, Component: Component as ComponentType<any>, opts });\n },\n onMouseLeave: () => {\n _setActive(null);\n },\n };\n}\n\n// ─── Portal (mount once in LayoutClient) ────────────────────────────────────\n\nfunction _springConfig(t?: CursorTransition) {\n if (!t || t.type === 'instant') return { stiffness: 1000, damping: 50, mass: 0.1 };\n if (t.type === 'tween' && t.duration) {\n // Map a tween duration to roughly-equivalent spring values.\n const stiffness = Math.max(50, 400 / Math.max(0.1, t.duration));\n return { stiffness, damping: 30, mass: 1 };\n }\n return {\n stiffness: t.stiffness ?? 300,\n damping: t.damping ?? 30,\n mass: t.mass ?? 1,\n };\n}\n\nexport function CursorPortal() {\n const cursor = useSyncExternalStore(_subscribe, _getActive, _getActive);\n\n const x = useMotionValue(0);\n const y = useMotionValue(0);\n const sx = useSpring(x, _springConfig(cursor?.opts.transition));\n const sy = useSpring(y, _springConfig(cursor?.opts.transition));\n\n useEffect(() => {\n const onMove = (e: MouseEvent) => {\n x.set(e.clientX + (cursor?.opts.offsetX ?? 0));\n y.set(e.clientY + (cursor?.opts.offsetY ?? 0));\n };\n window.addEventListener('mousemove', onMove);\n return () => window.removeEventListener('mousemove', onMove);\n }, [cursor, x, y]);\n\n useEffect(() => {\n if (cursor?.opts.mode === 'replace') {\n const prev = document.body.style.cursor;\n document.body.style.cursor = 'none';\n return () => { document.body.style.cursor = prev; };\n }\n }, [cursor]);\n\n // Wrapper width/height — numbers become px, strings pass through. Falls\n // back to undefined so intrinsic sizing kicks in if the user hasn't set it.\n const wrapW = typeof cursor?.opts.width === 'number' ? cursor.opts.width + 'px' : cursor?.opts.width;\n const wrapH = typeof cursor?.opts.height === 'number' ? cursor.opts.height + 'px' : cursor?.opts.height;\n\n // The OUTER motion.div carries the spring x/y (mouse position). The INNER\n // div applies a percentage transform for side+align (or auto-center in\n // Replace mode). Splitting them avoids fighting with framer-motion's own\n // transform handling on the x/y motion values.\n const outerStyle = {\n position: 'fixed' as const,\n top: 0,\n left: 0,\n x: sx,\n y: sy,\n pointerEvents: 'none' as const,\n zIndex: 9999,\n };\n const innerTransform = _innerTransform(cursor?.opts);\n const innerStyle = {\n width: wrapW,\n height: wrapH,\n transform: innerTransform,\n };\n\n // Default: instant in/out (no AnimatePresence wrapping). Wrap only when\n // the active cursor opts in via `enterExit: true` — keeps mount/unmount\n // snappy by default and avoids the brief fade-out from the previous cursor\n // when hovering between adjacent elements.\n if (!cursor?.opts.enterExit) {\n return cursor ? (\n <motion.div key={cursor.key} style={outerStyle}>\n <div style={innerStyle}>\n <cursor.Component {...(cursor.opts.props ?? {})} />\n </div>\n </motion.div>\n ) : null;\n }\n\n return (\n <AnimatePresence>\n {cursor && (\n <motion.div\n key={cursor.key}\n style={outerStyle}\n initial={{ opacity: 0, scale: 0.8 }}\n animate={{ opacity: 1, scale: 1 }}\n exit={{ opacity: 0, scale: 0.8 }}\n >\n <div style={innerStyle}>\n <cursor.Component {...(cursor.opts.props ?? {})} />\n </div>\n </motion.div>\n )}\n </AnimatePresence>\n );\n}\n\n/**\n * Build the inner-wrapper transform from side + align + mode. Pure CSS\n * percentage translates so it works regardless of whether width/height are\n * set explicitly. Replace mode auto-centers; Follow mode anchors a corner /\n * edge / center based on the chosen side and alignment.\n */\nfunction _innerTransform(opts?: CursorOpts) {\n if (!opts || opts.mode === 'replace') return 'translate(-50%, -50%)';\n const side = opts.side ?? 'bottom';\n const align = opts.align ?? 'center';\n let tx = 0;\n let ty = 0;\n if (side === 'top') ty = -100;\n else if (side === 'left') tx = -100;\n // 'bottom' and 'right' default to 0 on the main axis.\n // Align controls the perpendicular axis.\n if (side === 'top' || side === 'bottom') {\n if (align === 'center') tx = -50;\n else if (align === 'end') tx = -100;\n } else {\n if (align === 'center') ty = -50;\n else if (align === 'end') ty = -100;\n }\n return 'translate(' + tx + '%, ' + ty + '%)';\n}\n","'use client';\n\n/**\n * `useStaticCanvas()` — returns `true` when the component is being rendered\n * inside the Revyme canvas editor, `false` in the live preview, published\n * site, or any other consumer environment.\n *\n * Sparks / code components use this to skip GPU-expensive animation work\n * (continuous rAF loops, big CSS blur layers, WebGL frames) on the editor\n * canvas where the user only needs a representative still — paint once,\n * stop. The full animated version still runs in preview and production.\n *\n * Mechanics: this default implementation always returns `false`. The canvas\n * editor's spark loader (`code-component-runtime.ts` MODULE_MAP) overrides\n * the export at compile time so it returns `true` in the canvas iframe and\n * `false` in the spark editor's preview pane (which sets `previewMode`).\n *\n * Mirrors Framer's `useIsStaticRenderer` pattern.\n */\nexport function useStaticCanvas(): boolean {\n return false;\n}\n","// sketch-draw.ts — Runtime player for Revyme sketch draw animations.\n//\n// Replays a brush-stroke sketch over time by feeding the original\n// pointer samples (persisted on each `<path>` as a `data-points`\n// attribute) back through perfect-freehand's `getStroke` at\n// progressively-increasing slice lengths. The result is the visible\n// equivalent of watching the user draw the sketch.\n//\n// Why a runtime function instead of an inline useEffect block in the\n// generated source: the orchestrator is ~80 LOC of imperative timing\n// + easing + RAF logic. Inlining it in every page that has a sketch\n// animation buries the page's actual logic. Living in\n// `@revyme/runtime` means the generated source is just one line:\n//\n// useEffect(() => playSketchDraw(el, opts), []);\n//\n// which reads the same way as `withResponsiveProps` / `withCursor`\n// already do for other generated patterns.\n\nimport { getStroke } from 'perfect-freehand';\n\nexport type SketchAnimMode = 'sequential' | 'staggered' | 'simultaneous';\nexport type SketchAnimTrigger = 'mount' | 'inView' | 'hover' | 'tap';\n\nexport interface SketchAnimTransition {\n type: 'tween' | 'spring';\n duration?: number;\n ease?: string;\n stiffness?: number;\n damping?: number;\n mass?: number;\n}\n\nexport interface SketchAnimOpts {\n trigger?: SketchAnimTrigger;\n mode?: SketchAnimMode;\n /** Multiplier on per-stroke duration. Per-stroke duration scales\n * with point count so a long stroke takes longer than a flick;\n * this dials the overall pace. */\n durationScale?: number;\n /** 0–1, only meaningful in staggered mode. 0 = fully sequential,\n * 1 = fully simultaneous. */\n stagger?: number;\n transition?: SketchAnimTransition;\n /** Brush size used for the intermediate-frame outline replay. The\n * final-frame `d` is restored from source so the end state is\n * pixel-exact regardless of this value. */\n brushSize?: number;\n}\n\nconst DEFAULT_OPTS: Required<Omit<SketchAnimOpts, 'transition'>> & { transition: SketchAnimTransition } = {\n trigger: 'inView',\n mode: 'sequential',\n durationScale: 1,\n stagger: 0.5,\n transition: { type: 'tween', duration: 1, ease: 'easeOut' },\n brushSize: 8,\n};\n\nfunction applyEase(t: number, transition: SketchAnimTransition): number {\n if (transition.type === 'spring') {\n const damping = transition.damping ?? 10;\n const stiffness = transition.stiffness ?? 100;\n const dampedT = 1 - Math.exp(-damping * t * 0.1);\n const oscillation = Math.cos(t * Math.sqrt(stiffness) * 0.3);\n return Math.min(1, dampedT * (1 - 0.1 * oscillation * (1 - t)));\n }\n switch (transition.ease ?? 'easeOut') {\n case 'linear': return t;\n case 'easeIn': return t * t;\n case 'easeOut': return 1 - (1 - t) * (1 - t);\n case 'easeInOut': return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;\n case 'circIn': return 1 - Math.sqrt(1 - t * t);\n case 'circOut': return Math.sqrt(1 - Math.pow(t - 1, 2));\n case 'backOut': {\n const c1 = 1.70158;\n const c3 = c1 + 1;\n return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);\n }\n default: return 1 - (1 - t) * (1 - t);\n }\n}\n\nfunction parsePoints(raw: string): number[][] {\n if (!raw) return [];\n return raw.split(/\\s+/).filter(Boolean).map(s => {\n const [x, y, p] = s.split(',');\n return [parseFloat(x) || 0, parseFloat(y) || 0, p != null ? parseFloat(p) : 0.5];\n });\n}\n\nfunction outlineToD(outline: number[][]): string {\n if (outline.length === 0) return '';\n let d = `M ${outline[0][0].toFixed(2)} ${outline[0][1].toFixed(2)}`;\n for (let i = 1; i < outline.length; i++) {\n d += ` L ${outline[i][0].toFixed(2)} ${outline[i][1].toFixed(2)}`;\n }\n return d + ' Z';\n}\n\n/**\n * Play a sketch draw animation on the given wrapper SVG. Pass the\n * options the generator emitted in source.\n *\n * Returns a cleanup function — wire as your useEffect's return value\n * so re-mounts cancel an in-flight animation cleanly:\n *\n * useEffect(() => playSketchDraw(svgEl, opts), []);\n *\n * If `wrapperEl` is null or the wrapper has no path children with\n * `data-points`, this is a no-op and returns a noop cleanup.\n */\nexport function playSketchDraw(\n wrapperEl: SVGSVGElement | null,\n userOpts: SketchAnimOpts = {},\n): () => void {\n const noop = () => {};\n if (!wrapperEl) return noop;\n const opts = { ...DEFAULT_OPTS, ...userOpts, transition: { ...DEFAULT_OPTS.transition, ...userOpts.transition } };\n\n const paths = Array.from(wrapperEl.querySelectorAll('path[data-points]')) as SVGPathElement[];\n if (paths.length === 0) return noop;\n\n // Snapshot the final d so the last frame is pixel-exact regardless\n // of the replay-with-default-brush approximation we use during\n // intermediate frames.\n const finalDs = paths.map(p => p.getAttribute('d') || '');\n const pointsList = paths.map(p => parsePoints(p.getAttribute('data-points') || ''));\n\n // Hide everything up front so the first frame doesn't flash.\n paths.forEach(p => p.setAttribute('d', ''));\n\n // Per-stroke duration — point count drives length so a long stroke\n // takes longer than a flick.\n const baseDur = (opts.transition.duration ?? 1) * 1000 * opts.durationScale;\n const maxPoints = pointsList.reduce((m, p) => Math.max(m, p.length), 1);\n const perStrokeDur = pointsList.map(p => baseDur * (p.length / maxPoints));\n const startMs: number[] = [];\n let cursor = 0;\n for (let i = 0; i < paths.length; i++) {\n if (opts.mode === 'simultaneous') {\n startMs.push(0);\n } else if (opts.mode === 'staggered') {\n const overlap = Math.max(0, Math.min(1, opts.stagger));\n const start = i === 0 ? 0 : startMs[i - 1] + perStrokeDur[i - 1] * (1 - overlap);\n startMs.push(start);\n } else {\n // sequential\n startMs.push(cursor);\n cursor += perStrokeDur[i];\n }\n }\n\n let cancelled = false;\n let rafId = 0;\n let started = false;\n let cleanupTrigger: (() => void) | null = null;\n let startTs = 0;\n\n const tick = (now: number) => {\n if (cancelled) return;\n const elapsed = now - startTs;\n let allDone = true;\n for (let i = 0; i < paths.length; i++) {\n const local = elapsed - startMs[i];\n if (local < 0) { allDone = false; continue; }\n const t = Math.min(1, local / Math.max(1, perStrokeDur[i]));\n if (t < 1) allDone = false;\n let d: string;\n if (t >= 1) {\n d = finalDs[i];\n } else {\n const eased = applyEase(t, opts.transition);\n const sliceCount = Math.max(2, Math.floor(pointsList[i].length * eased));\n const subset = pointsList[i].slice(0, sliceCount);\n if (subset.length < 2) {\n d = '';\n } else {\n const outline = getStroke(subset, {\n size: opts.brushSize, thinning: 0.5, smoothing: 0.5, streamline: 0.5,\n });\n d = outlineToD(outline);\n }\n }\n paths[i].setAttribute('d', d);\n }\n if (!allDone) rafId = requestAnimationFrame(tick);\n };\n\n const start = () => {\n if (started) return;\n started = true;\n startTs = performance.now();\n rafId = requestAnimationFrame(tick);\n };\n\n if (opts.trigger === 'inView') {\n const obs = new IntersectionObserver((entries) => {\n if (entries.some(e => e.isIntersecting)) {\n start();\n obs.disconnect();\n }\n }, { threshold: 0.2 });\n obs.observe(wrapperEl);\n cleanupTrigger = () => obs.disconnect();\n } else if (opts.trigger === 'hover') {\n const onEnter = () => start();\n wrapperEl.addEventListener('mouseenter', onEnter);\n cleanupTrigger = () => wrapperEl.removeEventListener('mouseenter', onEnter);\n } else if (opts.trigger === 'tap') {\n const onTap = () => start();\n wrapperEl.addEventListener('click', onTap);\n cleanupTrigger = () => wrapperEl.removeEventListener('click', onTap);\n } else {\n // mount\n start();\n }\n\n return () => {\n cancelled = true;\n cancelAnimationFrame(rafId);\n cleanupTrigger?.();\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,39 @@
1
+ export type SketchAnimMode = 'sequential' | 'staggered' | 'simultaneous';
2
+ export type SketchAnimTrigger = 'mount' | 'inView' | 'hover' | 'tap';
3
+ export interface SketchAnimTransition {
4
+ type: 'tween' | 'spring';
5
+ duration?: number;
6
+ ease?: string;
7
+ stiffness?: number;
8
+ damping?: number;
9
+ mass?: number;
10
+ }
11
+ export interface SketchAnimOpts {
12
+ trigger?: SketchAnimTrigger;
13
+ mode?: SketchAnimMode;
14
+ /** Multiplier on per-stroke duration. Per-stroke duration scales
15
+ * with point count so a long stroke takes longer than a flick;
16
+ * this dials the overall pace. */
17
+ durationScale?: number;
18
+ /** 0–1, only meaningful in staggered mode. 0 = fully sequential,
19
+ * 1 = fully simultaneous. */
20
+ stagger?: number;
21
+ transition?: SketchAnimTransition;
22
+ /** Brush size used for the intermediate-frame outline replay. The
23
+ * final-frame `d` is restored from source so the end state is
24
+ * pixel-exact regardless of this value. */
25
+ brushSize?: number;
26
+ }
27
+ /**
28
+ * Play a sketch draw animation on the given wrapper SVG. Pass the
29
+ * options the generator emitted in source.
30
+ *
31
+ * Returns a cleanup function — wire as your useEffect's return value
32
+ * so re-mounts cancel an in-flight animation cleanly:
33
+ *
34
+ * useEffect(() => playSketchDraw(svgEl, opts), []);
35
+ *
36
+ * If `wrapperEl` is null or the wrapper has no path children with
37
+ * `data-points`, this is a no-op and returns a noop cleanup.
38
+ */
39
+ export declare function playSketchDraw(wrapperEl: SVGSVGElement | null, userOpts?: SketchAnimOpts): () => void;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `useStaticCanvas()` — returns `true` when the component is being rendered
3
+ * inside the Revyme canvas editor, `false` in the live preview, published
4
+ * site, or any other consumer environment.
5
+ *
6
+ * Sparks / code components use this to skip GPU-expensive animation work
7
+ * (continuous rAF loops, big CSS blur layers, WebGL frames) on the editor
8
+ * canvas where the user only needs a representative still — paint once,
9
+ * stop. The full animated version still runs in preview and production.
10
+ *
11
+ * Mechanics: this default implementation always returns `false`. The canvas
12
+ * editor's spark loader (`code-component-runtime.ts` MODULE_MAP) overrides
13
+ * the export at compile time so it returns `true` in the canvas iframe and
14
+ * `false` in the spark editor's preview pane (which sets `previewMode`).
15
+ *
16
+ * Mirrors Framer's `useIsStaticRenderer` pattern.
17
+ */
18
+ export declare function useStaticCanvas(): boolean;
@@ -0,0 +1,15 @@
1
+ import { type ComponentType } from 'react';
2
+ /**
3
+ * HOC that reads a `data-responsive` JSON prop and merges per-viewport overrides.
4
+ * Usage: `export default withResponsiveProps(MyComponent)`
5
+ *
6
+ * On the canvas, CodeComponentHost injects `__canvasViewportWidth` to simulate viewport size.
7
+ * In production, uses `window.innerWidth`.
8
+ *
9
+ * data-responsive='{"768":{"fontSize":32},"375":{"fontSize":24}}'
10
+ * Breakpoints are max-width: if viewport <= 768, the 768 overrides apply.
11
+ */
12
+ export default function withResponsiveProps<P extends Record<string, any>>(Component: ComponentType<P>): ComponentType<P & {
13
+ 'data-responsive'?: string;
14
+ __canvasViewportWidth?: number;
15
+ }>;
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@revyme/runtime",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src"
17
+ ],
18
+ "scripts": {
19
+ "build": "vite build && tsc -p tsconfig.build.json --emitDeclarationOnly",
20
+ "dev": "vite build --watch"
21
+ },
22
+ "peerDependencies": {
23
+ "framer-motion": ">=11",
24
+ "perfect-freehand": ">=1.2",
25
+ "react": ">=18",
26
+ "react-dom": ">=18"
27
+ },
28
+ "peerDependenciesMeta": {
29
+ "perfect-freehand": {
30
+ "optional": true
31
+ }
32
+ },
33
+ "devDependencies": {
34
+ "@types/react": "^19.2.14",
35
+ "@types/react-dom": "^19.2.3",
36
+ "@vitejs/plugin-react": "^6.0.1",
37
+ "perfect-freehand": "^1.2.3",
38
+ "typescript": "^5.9.3",
39
+ "vite": "^8.0.1"
40
+ }
41
+ }
@@ -0,0 +1,220 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useSyncExternalStore, type ComponentType } from 'react';
4
+ import { motion, AnimatePresence, useMotionValue, useSpring } from 'framer-motion';
5
+
6
+ export type CursorMode = 'follow' | 'replace';
7
+ export type CursorSide = 'top' | 'bottom' | 'left' | 'right';
8
+ export type CursorAlign = 'start' | 'center' | 'end';
9
+
10
+ export interface CursorTransition {
11
+ type?: 'spring' | 'tween' | 'instant';
12
+ stiffness?: number;
13
+ damping?: number;
14
+ mass?: number;
15
+ duration?: number;
16
+ ease?: string;
17
+ }
18
+
19
+ export interface CursorOpts<P = any> {
20
+ variant?: string;
21
+ mode?: CursorMode;
22
+ /**
23
+ * Which side of the mouse the cursor wrapper anchors to (Follow mode).
24
+ * Replace mode ignores side / align / offset — it auto-centers on the mouse.
25
+ */
26
+ side?: CursorSide;
27
+ /**
28
+ * Alignment along the perpendicular axis to `side`.
29
+ * top/bottom: start = left, center = horizontal center, end = right.
30
+ * left/right: start = top, center = vertical center, end = bottom.
31
+ */
32
+ align?: CursorAlign;
33
+ offsetX?: number;
34
+ offsetY?: number;
35
+ transition?: CursorTransition;
36
+ props?: Partial<P>;
37
+ /**
38
+ * Wrapper width / height applied to the cursor's outer motion.div. Useful
39
+ * for code components (canvases, sparks) that fill their parent — without
40
+ * this they'd render at their intrinsic size, which is often the whole
41
+ * viewport. Plain numbers are interpreted as px; pass a string ('100%',
42
+ * '4rem') to use other CSS units.
43
+ */
44
+ width?: number | string;
45
+ height?: number | string;
46
+ /**
47
+ * When true, fade/scale on enter and exit via AnimatePresence. Default
48
+ * false: appear and disappear instantly. The follow movement is always
49
+ * smoothed by the spring config above — `enterExit` only controls the
50
+ * mount/unmount transition.
51
+ */
52
+ enterExit?: boolean;
53
+ }
54
+
55
+ interface ActiveCursor {
56
+ key: number;
57
+ Component: ComponentType<any>;
58
+ opts: CursorOpts;
59
+ }
60
+
61
+ // ─── Global store (vanilla, no React) ───────────────────────────────────────
62
+ let _active: ActiveCursor | null = null;
63
+ const _listeners = new Set<() => void>();
64
+ let _nextKey = 0;
65
+
66
+ function _setActive(next: ActiveCursor | null) {
67
+ _active = next;
68
+ _listeners.forEach((l) => l());
69
+ }
70
+
71
+ function _subscribe(l: () => void) {
72
+ _listeners.add(l);
73
+ return () => { _listeners.delete(l); };
74
+ }
75
+
76
+ function _getActive() {
77
+ return _active;
78
+ }
79
+
80
+ /**
81
+ * Spread the return value into an element to give it a component cursor.
82
+ * Returns onMouseEnter/onMouseLeave handlers that push/pop the global store.
83
+ *
84
+ * <button {...withCursor(Pointer, { mode: 'follow', transition: { type: 'spring', stiffness: 300 } })}>
85
+ */
86
+ export function withCursor<P>(Component: ComponentType<P>, opts: CursorOpts<P> = {}) {
87
+ return {
88
+ onMouseEnter: () => {
89
+ _setActive({ key: ++_nextKey, Component: Component as ComponentType<any>, opts });
90
+ },
91
+ onMouseLeave: () => {
92
+ _setActive(null);
93
+ },
94
+ };
95
+ }
96
+
97
+ // ─── Portal (mount once in LayoutClient) ────────────────────────────────────
98
+
99
+ function _springConfig(t?: CursorTransition) {
100
+ if (!t || t.type === 'instant') return { stiffness: 1000, damping: 50, mass: 0.1 };
101
+ if (t.type === 'tween' && t.duration) {
102
+ // Map a tween duration to roughly-equivalent spring values.
103
+ const stiffness = Math.max(50, 400 / Math.max(0.1, t.duration));
104
+ return { stiffness, damping: 30, mass: 1 };
105
+ }
106
+ return {
107
+ stiffness: t.stiffness ?? 300,
108
+ damping: t.damping ?? 30,
109
+ mass: t.mass ?? 1,
110
+ };
111
+ }
112
+
113
+ export function CursorPortal() {
114
+ const cursor = useSyncExternalStore(_subscribe, _getActive, _getActive);
115
+
116
+ const x = useMotionValue(0);
117
+ const y = useMotionValue(0);
118
+ const sx = useSpring(x, _springConfig(cursor?.opts.transition));
119
+ const sy = useSpring(y, _springConfig(cursor?.opts.transition));
120
+
121
+ useEffect(() => {
122
+ const onMove = (e: MouseEvent) => {
123
+ x.set(e.clientX + (cursor?.opts.offsetX ?? 0));
124
+ y.set(e.clientY + (cursor?.opts.offsetY ?? 0));
125
+ };
126
+ window.addEventListener('mousemove', onMove);
127
+ return () => window.removeEventListener('mousemove', onMove);
128
+ }, [cursor, x, y]);
129
+
130
+ useEffect(() => {
131
+ if (cursor?.opts.mode === 'replace') {
132
+ const prev = document.body.style.cursor;
133
+ document.body.style.cursor = 'none';
134
+ return () => { document.body.style.cursor = prev; };
135
+ }
136
+ }, [cursor]);
137
+
138
+ // Wrapper width/height — numbers become px, strings pass through. Falls
139
+ // back to undefined so intrinsic sizing kicks in if the user hasn't set it.
140
+ const wrapW = typeof cursor?.opts.width === 'number' ? cursor.opts.width + 'px' : cursor?.opts.width;
141
+ const wrapH = typeof cursor?.opts.height === 'number' ? cursor.opts.height + 'px' : cursor?.opts.height;
142
+
143
+ // The OUTER motion.div carries the spring x/y (mouse position). The INNER
144
+ // div applies a percentage transform for side+align (or auto-center in
145
+ // Replace mode). Splitting them avoids fighting with framer-motion's own
146
+ // transform handling on the x/y motion values.
147
+ const outerStyle = {
148
+ position: 'fixed' as const,
149
+ top: 0,
150
+ left: 0,
151
+ x: sx,
152
+ y: sy,
153
+ pointerEvents: 'none' as const,
154
+ zIndex: 9999,
155
+ };
156
+ const innerTransform = _innerTransform(cursor?.opts);
157
+ const innerStyle = {
158
+ width: wrapW,
159
+ height: wrapH,
160
+ transform: innerTransform,
161
+ };
162
+
163
+ // Default: instant in/out (no AnimatePresence wrapping). Wrap only when
164
+ // the active cursor opts in via `enterExit: true` — keeps mount/unmount
165
+ // snappy by default and avoids the brief fade-out from the previous cursor
166
+ // when hovering between adjacent elements.
167
+ if (!cursor?.opts.enterExit) {
168
+ return cursor ? (
169
+ <motion.div key={cursor.key} style={outerStyle}>
170
+ <div style={innerStyle}>
171
+ <cursor.Component {...(cursor.opts.props ?? {})} />
172
+ </div>
173
+ </motion.div>
174
+ ) : null;
175
+ }
176
+
177
+ return (
178
+ <AnimatePresence>
179
+ {cursor && (
180
+ <motion.div
181
+ key={cursor.key}
182
+ style={outerStyle}
183
+ initial={{ opacity: 0, scale: 0.8 }}
184
+ animate={{ opacity: 1, scale: 1 }}
185
+ exit={{ opacity: 0, scale: 0.8 }}
186
+ >
187
+ <div style={innerStyle}>
188
+ <cursor.Component {...(cursor.opts.props ?? {})} />
189
+ </div>
190
+ </motion.div>
191
+ )}
192
+ </AnimatePresence>
193
+ );
194
+ }
195
+
196
+ /**
197
+ * Build the inner-wrapper transform from side + align + mode. Pure CSS
198
+ * percentage translates so it works regardless of whether width/height are
199
+ * set explicitly. Replace mode auto-centers; Follow mode anchors a corner /
200
+ * edge / center based on the chosen side and alignment.
201
+ */
202
+ function _innerTransform(opts?: CursorOpts) {
203
+ if (!opts || opts.mode === 'replace') return 'translate(-50%, -50%)';
204
+ const side = opts.side ?? 'bottom';
205
+ const align = opts.align ?? 'center';
206
+ let tx = 0;
207
+ let ty = 0;
208
+ if (side === 'top') ty = -100;
209
+ else if (side === 'left') tx = -100;
210
+ // 'bottom' and 'right' default to 0 on the main axis.
211
+ // Align controls the perpendicular axis.
212
+ if (side === 'top' || side === 'bottom') {
213
+ if (align === 'center') tx = -50;
214
+ else if (align === 'end') tx = -100;
215
+ } else {
216
+ if (align === 'center') ty = -50;
217
+ else if (align === 'end') ty = -100;
218
+ }
219
+ return 'translate(' + tx + '%, ' + ty + '%)';
220
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ // @revyme/runtime — runtime utilities used by Revyme-generated React code.
2
+ //
3
+ // Lives outside `canvas-poc` so design components and code components in
4
+ // any Revyme project (and eventually any external React project, once
5
+ // published to npm) can import from a stable `@revyme/runtime` package
6
+ // name instead of a project-local `@/lib/...` alias.
7
+ //
8
+ // Inside Revyme's own monorepo: linked via `file:../runtime` in
9
+ // `canvas-poc/package.json`. No npm publish needed for in-house use.
10
+ // External consumers will install via `npm i @revyme/runtime` once we
11
+ // publish — same shape as `framer-motion`.
12
+
13
+ export { default as withResponsiveProps } from './withResponsiveProps';
14
+ export {
15
+ withCursor,
16
+ CursorPortal,
17
+ type CursorMode,
18
+ type CursorSide,
19
+ type CursorAlign,
20
+ type CursorTransition,
21
+ type CursorOpts,
22
+ } from './cursor-runtime';
23
+ export { useStaticCanvas } from './useStaticCanvas';
24
+ export {
25
+ playSketchDraw,
26
+ type SketchAnimOpts,
27
+ type SketchAnimMode,
28
+ type SketchAnimTrigger,
29
+ type SketchAnimTransition,
30
+ } from './sketch-draw';
@@ -0,0 +1,224 @@
1
+ // sketch-draw.ts — Runtime player for Revyme sketch draw animations.
2
+ //
3
+ // Replays a brush-stroke sketch over time by feeding the original
4
+ // pointer samples (persisted on each `<path>` as a `data-points`
5
+ // attribute) back through perfect-freehand's `getStroke` at
6
+ // progressively-increasing slice lengths. The result is the visible
7
+ // equivalent of watching the user draw the sketch.
8
+ //
9
+ // Why a runtime function instead of an inline useEffect block in the
10
+ // generated source: the orchestrator is ~80 LOC of imperative timing
11
+ // + easing + RAF logic. Inlining it in every page that has a sketch
12
+ // animation buries the page's actual logic. Living in
13
+ // `@revyme/runtime` means the generated source is just one line:
14
+ //
15
+ // useEffect(() => playSketchDraw(el, opts), []);
16
+ //
17
+ // which reads the same way as `withResponsiveProps` / `withCursor`
18
+ // already do for other generated patterns.
19
+
20
+ import { getStroke } from 'perfect-freehand';
21
+
22
+ export type SketchAnimMode = 'sequential' | 'staggered' | 'simultaneous';
23
+ export type SketchAnimTrigger = 'mount' | 'inView' | 'hover' | 'tap';
24
+
25
+ export interface SketchAnimTransition {
26
+ type: 'tween' | 'spring';
27
+ duration?: number;
28
+ ease?: string;
29
+ stiffness?: number;
30
+ damping?: number;
31
+ mass?: number;
32
+ }
33
+
34
+ export interface SketchAnimOpts {
35
+ trigger?: SketchAnimTrigger;
36
+ mode?: SketchAnimMode;
37
+ /** Multiplier on per-stroke duration. Per-stroke duration scales
38
+ * with point count so a long stroke takes longer than a flick;
39
+ * this dials the overall pace. */
40
+ durationScale?: number;
41
+ /** 0–1, only meaningful in staggered mode. 0 = fully sequential,
42
+ * 1 = fully simultaneous. */
43
+ stagger?: number;
44
+ transition?: SketchAnimTransition;
45
+ /** Brush size used for the intermediate-frame outline replay. The
46
+ * final-frame `d` is restored from source so the end state is
47
+ * pixel-exact regardless of this value. */
48
+ brushSize?: number;
49
+ }
50
+
51
+ const DEFAULT_OPTS: Required<Omit<SketchAnimOpts, 'transition'>> & { transition: SketchAnimTransition } = {
52
+ trigger: 'inView',
53
+ mode: 'sequential',
54
+ durationScale: 1,
55
+ stagger: 0.5,
56
+ transition: { type: 'tween', duration: 1, ease: 'easeOut' },
57
+ brushSize: 8,
58
+ };
59
+
60
+ function applyEase(t: number, transition: SketchAnimTransition): number {
61
+ if (transition.type === 'spring') {
62
+ const damping = transition.damping ?? 10;
63
+ const stiffness = transition.stiffness ?? 100;
64
+ const dampedT = 1 - Math.exp(-damping * t * 0.1);
65
+ const oscillation = Math.cos(t * Math.sqrt(stiffness) * 0.3);
66
+ return Math.min(1, dampedT * (1 - 0.1 * oscillation * (1 - t)));
67
+ }
68
+ switch (transition.ease ?? 'easeOut') {
69
+ case 'linear': return t;
70
+ case 'easeIn': return t * t;
71
+ case 'easeOut': return 1 - (1 - t) * (1 - t);
72
+ case 'easeInOut': return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
73
+ case 'circIn': return 1 - Math.sqrt(1 - t * t);
74
+ case 'circOut': return Math.sqrt(1 - Math.pow(t - 1, 2));
75
+ case 'backOut': {
76
+ const c1 = 1.70158;
77
+ const c3 = c1 + 1;
78
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
79
+ }
80
+ default: return 1 - (1 - t) * (1 - t);
81
+ }
82
+ }
83
+
84
+ function parsePoints(raw: string): number[][] {
85
+ if (!raw) return [];
86
+ return raw.split(/\s+/).filter(Boolean).map(s => {
87
+ const [x, y, p] = s.split(',');
88
+ return [parseFloat(x) || 0, parseFloat(y) || 0, p != null ? parseFloat(p) : 0.5];
89
+ });
90
+ }
91
+
92
+ function outlineToD(outline: number[][]): string {
93
+ if (outline.length === 0) return '';
94
+ let d = `M ${outline[0][0].toFixed(2)} ${outline[0][1].toFixed(2)}`;
95
+ for (let i = 1; i < outline.length; i++) {
96
+ d += ` L ${outline[i][0].toFixed(2)} ${outline[i][1].toFixed(2)}`;
97
+ }
98
+ return d + ' Z';
99
+ }
100
+
101
+ /**
102
+ * Play a sketch draw animation on the given wrapper SVG. Pass the
103
+ * options the generator emitted in source.
104
+ *
105
+ * Returns a cleanup function — wire as your useEffect's return value
106
+ * so re-mounts cancel an in-flight animation cleanly:
107
+ *
108
+ * useEffect(() => playSketchDraw(svgEl, opts), []);
109
+ *
110
+ * If `wrapperEl` is null or the wrapper has no path children with
111
+ * `data-points`, this is a no-op and returns a noop cleanup.
112
+ */
113
+ export function playSketchDraw(
114
+ wrapperEl: SVGSVGElement | null,
115
+ userOpts: SketchAnimOpts = {},
116
+ ): () => void {
117
+ const noop = () => {};
118
+ if (!wrapperEl) return noop;
119
+ const opts = { ...DEFAULT_OPTS, ...userOpts, transition: { ...DEFAULT_OPTS.transition, ...userOpts.transition } };
120
+
121
+ const paths = Array.from(wrapperEl.querySelectorAll('path[data-points]')) as SVGPathElement[];
122
+ if (paths.length === 0) return noop;
123
+
124
+ // Snapshot the final d so the last frame is pixel-exact regardless
125
+ // of the replay-with-default-brush approximation we use during
126
+ // intermediate frames.
127
+ const finalDs = paths.map(p => p.getAttribute('d') || '');
128
+ const pointsList = paths.map(p => parsePoints(p.getAttribute('data-points') || ''));
129
+
130
+ // Hide everything up front so the first frame doesn't flash.
131
+ paths.forEach(p => p.setAttribute('d', ''));
132
+
133
+ // Per-stroke duration — point count drives length so a long stroke
134
+ // takes longer than a flick.
135
+ const baseDur = (opts.transition.duration ?? 1) * 1000 * opts.durationScale;
136
+ const maxPoints = pointsList.reduce((m, p) => Math.max(m, p.length), 1);
137
+ const perStrokeDur = pointsList.map(p => baseDur * (p.length / maxPoints));
138
+ const startMs: number[] = [];
139
+ let cursor = 0;
140
+ for (let i = 0; i < paths.length; i++) {
141
+ if (opts.mode === 'simultaneous') {
142
+ startMs.push(0);
143
+ } else if (opts.mode === 'staggered') {
144
+ const overlap = Math.max(0, Math.min(1, opts.stagger));
145
+ const start = i === 0 ? 0 : startMs[i - 1] + perStrokeDur[i - 1] * (1 - overlap);
146
+ startMs.push(start);
147
+ } else {
148
+ // sequential
149
+ startMs.push(cursor);
150
+ cursor += perStrokeDur[i];
151
+ }
152
+ }
153
+
154
+ let cancelled = false;
155
+ let rafId = 0;
156
+ let started = false;
157
+ let cleanupTrigger: (() => void) | null = null;
158
+ let startTs = 0;
159
+
160
+ const tick = (now: number) => {
161
+ if (cancelled) return;
162
+ const elapsed = now - startTs;
163
+ let allDone = true;
164
+ for (let i = 0; i < paths.length; i++) {
165
+ const local = elapsed - startMs[i];
166
+ if (local < 0) { allDone = false; continue; }
167
+ const t = Math.min(1, local / Math.max(1, perStrokeDur[i]));
168
+ if (t < 1) allDone = false;
169
+ let d: string;
170
+ if (t >= 1) {
171
+ d = finalDs[i];
172
+ } else {
173
+ const eased = applyEase(t, opts.transition);
174
+ const sliceCount = Math.max(2, Math.floor(pointsList[i].length * eased));
175
+ const subset = pointsList[i].slice(0, sliceCount);
176
+ if (subset.length < 2) {
177
+ d = '';
178
+ } else {
179
+ const outline = getStroke(subset, {
180
+ size: opts.brushSize, thinning: 0.5, smoothing: 0.5, streamline: 0.5,
181
+ });
182
+ d = outlineToD(outline);
183
+ }
184
+ }
185
+ paths[i].setAttribute('d', d);
186
+ }
187
+ if (!allDone) rafId = requestAnimationFrame(tick);
188
+ };
189
+
190
+ const start = () => {
191
+ if (started) return;
192
+ started = true;
193
+ startTs = performance.now();
194
+ rafId = requestAnimationFrame(tick);
195
+ };
196
+
197
+ if (opts.trigger === 'inView') {
198
+ const obs = new IntersectionObserver((entries) => {
199
+ if (entries.some(e => e.isIntersecting)) {
200
+ start();
201
+ obs.disconnect();
202
+ }
203
+ }, { threshold: 0.2 });
204
+ obs.observe(wrapperEl);
205
+ cleanupTrigger = () => obs.disconnect();
206
+ } else if (opts.trigger === 'hover') {
207
+ const onEnter = () => start();
208
+ wrapperEl.addEventListener('mouseenter', onEnter);
209
+ cleanupTrigger = () => wrapperEl.removeEventListener('mouseenter', onEnter);
210
+ } else if (opts.trigger === 'tap') {
211
+ const onTap = () => start();
212
+ wrapperEl.addEventListener('click', onTap);
213
+ cleanupTrigger = () => wrapperEl.removeEventListener('click', onTap);
214
+ } else {
215
+ // mount
216
+ start();
217
+ }
218
+
219
+ return () => {
220
+ cancelled = true;
221
+ cancelAnimationFrame(rafId);
222
+ cleanupTrigger?.();
223
+ };
224
+ }
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * `useStaticCanvas()` — returns `true` when the component is being rendered
5
+ * inside the Revyme canvas editor, `false` in the live preview, published
6
+ * site, or any other consumer environment.
7
+ *
8
+ * Sparks / code components use this to skip GPU-expensive animation work
9
+ * (continuous rAF loops, big CSS blur layers, WebGL frames) on the editor
10
+ * canvas where the user only needs a representative still — paint once,
11
+ * stop. The full animated version still runs in preview and production.
12
+ *
13
+ * Mechanics: this default implementation always returns `false`. The canvas
14
+ * editor's spark loader (`code-component-runtime.ts` MODULE_MAP) overrides
15
+ * the export at compile time so it returns `true` in the canvas iframe and
16
+ * `false` in the spark editor's preview pane (which sets `previewMode`).
17
+ *
18
+ * Mirrors Framer's `useIsStaticRenderer` pattern.
19
+ */
20
+ export function useStaticCanvas(): boolean {
21
+ return false;
22
+ }
@@ -0,0 +1,62 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, type ComponentType } from 'react';
4
+
5
+ /**
6
+ * HOC that reads a `data-responsive` JSON prop and merges per-viewport overrides.
7
+ * Usage: `export default withResponsiveProps(MyComponent)`
8
+ *
9
+ * On the canvas, CodeComponentHost injects `__canvasViewportWidth` to simulate viewport size.
10
+ * In production, uses `window.innerWidth`.
11
+ *
12
+ * data-responsive='{"768":{"fontSize":32},"375":{"fontSize":24}}'
13
+ * Breakpoints are max-width: if viewport <= 768, the 768 overrides apply.
14
+ */
15
+ export default function withResponsiveProps<P extends Record<string, any>>(
16
+ Component: ComponentType<P>
17
+ ): ComponentType<P & { 'data-responsive'?: string; __canvasViewportWidth?: number }> {
18
+ return function ResponsiveSpark(props: any) {
19
+ const canvasVpWidth = props.__canvasViewportWidth as number | undefined;
20
+ const [windowWidth, setWindowWidth] = useState(
21
+ typeof window !== 'undefined' ? window.innerWidth : 1440
22
+ );
23
+
24
+ useEffect(() => {
25
+ if (canvasVpWidth !== undefined) return;
26
+ const handler = () => setWindowWidth(window.innerWidth);
27
+ window.addEventListener('resize', handler);
28
+ return () => window.removeEventListener('resize', handler);
29
+ }, [canvasVpWidth]);
30
+
31
+ const vpWidth = canvasVpWidth ?? windowWidth;
32
+ const responsiveStr = props['data-responsive'];
33
+ let mergedProps = { ...props };
34
+
35
+ if (responsiveStr) {
36
+ try {
37
+ const overrides = typeof responsiveStr === 'string'
38
+ ? JSON.parse(responsiveStr) : responsiveStr;
39
+ // _bp contains all viewport breakpoint widths for range computation.
40
+ // Each breakpoint's range is (prev_bp, bp]. Prevents cascade.
41
+ const allBp = Array.isArray(overrides._bp)
42
+ ? overrides._bp : Object.keys(overrides).filter(k => k !== '_bp').map(Number);
43
+ const sortedBp = [...allBp].sort((a, b) => a - b);
44
+ let matchedBp;
45
+ for (let i = 0; i < sortedBp.length; i++) {
46
+ const lower = i > 0 ? sortedBp[i - 1] : 0;
47
+ if (vpWidth > lower && vpWidth <= sortedBp[i]) {
48
+ matchedBp = sortedBp[i];
49
+ break;
50
+ }
51
+ }
52
+ if (matchedBp !== undefined && overrides[matchedBp]) {
53
+ mergedProps = { ...mergedProps, ...overrides[matchedBp] };
54
+ }
55
+ } catch {}
56
+ }
57
+
58
+ delete mergedProps['data-responsive'];
59
+ delete mergedProps['__canvasViewportWidth'];
60
+ return <Component {...mergedProps} />;
61
+ };
62
+ }