@vibes.diy/prompts 14.3.83 → 14.3.84
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/llms/access.md +61 -17
- package/llms/index.d.ts +4 -1
- package/llms/index.js +9 -0
- package/llms/index.js.map +1 -1
- package/llms/motion.d.ts +2 -0
- package/llms/motion.js +47 -0
- package/llms/motion.js.map +1 -0
- package/llms/motion.md +601 -0
- package/llms/physics.md +2 -1
- package/llms/richtext.d.ts +2 -0
- package/llms/richtext.js +47 -0
- package/llms/richtext.js.map +1 -0
- package/llms/richtext.md +612 -0
- package/llms/strudel.md +20 -6
- package/llms/worlds.d.ts +2 -0
- package/llms/worlds.js +45 -0
- package/llms/worlds.js.map +1 -0
- package/llms/worlds.md +826 -0
- package/package.json +5 -4
- package/restyle.js +3 -2
- package/restyle.js.map +1 -1
package/llms/motion.md
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
# Motion — things that move when you scroll, tap and wait
|
|
2
|
+
|
|
3
|
+
When someone asks for a page that tells a story as you scroll, a launch or
|
|
4
|
+
landing page whose sections reveal as they arrive, a bar that fills with how
|
|
5
|
+
far you have read, a number that counts up, a headline that arrives in a
|
|
6
|
+
choreographed sequence, cards that spring in, buttons that squash when
|
|
7
|
+
pressed, a parallax hero, or any ask to make a page "feel alive" with motion,
|
|
8
|
+
this is the recipe. Motion (motion.dev, MIT) is the engine: its vanilla
|
|
9
|
+
`animate`, `scroll`, `inView`, `stagger`, `press` and `hover` functions
|
|
10
|
+
animate real DOM elements through refs, and the app keeps a small helper
|
|
11
|
+
module around them that handles the three things every animated page owes
|
|
12
|
+
its reader: it arrives after first paint, it respects reduced motion, and it
|
|
13
|
+
stops when the component goes away.
|
|
14
|
+
|
|
15
|
+
The sibling boundary: a list whose items fade and rise as they enter uses
|
|
16
|
+
the house flourish, the `starting:` Tailwind classes, with no engine at all;
|
|
17
|
+
a canvas that redraws every frame is `p5`, `worlds` or `physics`; pouf
|
|
18
|
+
components carry their own motion (a `Card` with `motion="lift"`, the
|
|
19
|
+
toasts, the sheets). Motion is the right call when something on the page
|
|
20
|
+
responds to scroll, to time, or to touch.
|
|
21
|
+
|
|
22
|
+
The worked examples below were built and measured for this doc (Chromium,
|
|
23
|
+
2026-10-07, `motion@14.0.0` from esm.sh): a launch page told by scrolling,
|
|
24
|
+
and a kudos board whose notes spring in and squash when pressed.
|
|
25
|
+
|
|
26
|
+
## Project shape
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
App.jsx the page: refs on the moving parts, one effect that sets motion up and returns its cleanup
|
|
30
|
+
lib/motion.js the loader and the helpers: reveal, staggerIn, scrollProgress, countUp, sequence, move, pressSquash
|
|
31
|
+
access.js who may post to a shared board (when the app has one)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## The engine arrives at first use
|
|
35
|
+
|
|
36
|
+
**Import the engine with its full pinned esm.sh URL inside a memoized
|
|
37
|
+
module-level loader, and let every helper await it.** The import is a
|
|
38
|
+
body-position `await import("https://esm.sh/motion@14.0.0")`: the platform
|
|
39
|
+
rewrites bare specifiers only in the top-of-file import region, so a lazy
|
|
40
|
+
import carries the complete versioned URL. Laziness is the point: first paint
|
|
41
|
+
and the server render owe nothing to the engine (measured: about 1.4 s to
|
|
42
|
+
arrive cold), the page is readable before it lands, and the helpers attach
|
|
43
|
+
motion to a page that already exists. The vanilla entry is the one to use;
|
|
44
|
+
it carries no React (measured: the module's `motion` export is undefined),
|
|
45
|
+
so the app's one React instance stays the only one and hooks keep working.
|
|
46
|
+
`motion/react` and `framer-motion` are a second React waiting to happen in
|
|
47
|
+
this runtime; the vanilla functions do everything a page needs.
|
|
48
|
+
|
|
49
|
+
**Every helper returns a stop function at once**, before the engine has
|
|
50
|
+
arrived, so an effect can collect them and return one cleanup. A stop called
|
|
51
|
+
before the engine lands cancels the setup (measured: a `move` stopped during
|
|
52
|
+
the load moved nothing). `motionScope()` is the collector.
|
|
53
|
+
|
|
54
|
+
**Every helper reads the reduced-motion setting when it runs** and lands on
|
|
55
|
+
the end state instead of animating. Pouf's own reduced-motion rule is scoped
|
|
56
|
+
to pouf's elements, so an animation the app authors gets no safety net from
|
|
57
|
+
it; the helpers are the net (measured with the media feature emulated: lists
|
|
58
|
+
land visible, reveals leave everything visible, the count shows its final
|
|
59
|
+
number, the press does nothing, scroll progress still reports).
|
|
60
|
+
|
|
61
|
+
### `lib/motion.js`
|
|
62
|
+
```js app=story
|
|
63
|
+
// lib/motion.js — the app's motion helpers on top of Motion's vanilla API.
|
|
64
|
+
// The engine arrives by a body-position import of its pinned URL the first time a helper runs,
|
|
65
|
+
// so first paint and the server render owe it nothing. Every helper returns a stop function at
|
|
66
|
+
// once (before the engine has even loaded), so an effect collects them and returns one cleanup;
|
|
67
|
+
// every helper reads the reduced-motion setting when it runs and lands on the end state instead.
|
|
68
|
+
|
|
69
|
+
const ENGINE_URL = "https://esm.sh/motion@14.0.0";
|
|
70
|
+
let enginePromise;
|
|
71
|
+
|
|
72
|
+
/** One fetch of the engine, however many helpers ask for it. */
|
|
73
|
+
export function loadMotion() {
|
|
74
|
+
enginePromise ??= import(ENGINE_URL);
|
|
75
|
+
return enginePromise;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** True when the person asked their system for less motion. Read at call time, not import time. */
|
|
79
|
+
export function prefersReducedMotion() {
|
|
80
|
+
return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Collect stop functions from helpers; `cleanup()` runs them all, from an effect's return. */
|
|
84
|
+
export function motionScope() {
|
|
85
|
+
const stops = new Set();
|
|
86
|
+
return {
|
|
87
|
+
add(stop) {
|
|
88
|
+
stops.add(stop);
|
|
89
|
+
return stop;
|
|
90
|
+
},
|
|
91
|
+
cleanup() {
|
|
92
|
+
for (const stop of stops) stop();
|
|
93
|
+
stops.clear();
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Wrap an async setup: returns a stop at once; the setup's own stop runs when it has resolved. */
|
|
99
|
+
function later(setup) {
|
|
100
|
+
let stopped = false;
|
|
101
|
+
let stopInner = null;
|
|
102
|
+
loadMotion().then((m) => {
|
|
103
|
+
if (stopped) return;
|
|
104
|
+
stopInner = setup(m) || null;
|
|
105
|
+
});
|
|
106
|
+
return () => {
|
|
107
|
+
if (stopped) return; // a stop called twice runs the inner stop once
|
|
108
|
+
stopped = true;
|
|
109
|
+
const stop = stopInner;
|
|
110
|
+
stopInner = null;
|
|
111
|
+
if (stop) stop();
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const elementsOf = (target) => (typeof target === "string" ? [...document.querySelectorAll(target)] : [target].flat());
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Move elements to `keyframes`; under reduced motion, land there at once. Returns a stop that
|
|
119
|
+
* cancels the animation (the element returns to where it was).
|
|
120
|
+
*/
|
|
121
|
+
export function move(target, keyframes, options = {}) {
|
|
122
|
+
return later((m) => {
|
|
123
|
+
const opts = prefersReducedMotion() ? { ...options, duration: 0, delay: 0 } : options;
|
|
124
|
+
const controls = m.animate(target, keyframes, opts);
|
|
125
|
+
return () => controls.cancel();
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Play a sequence of [target, keyframes, options] segments (`at` sets timing). Same stop contract. */
|
|
130
|
+
export function sequence(segments) {
|
|
131
|
+
return later((m) => {
|
|
132
|
+
if (prefersReducedMotion()) {
|
|
133
|
+
for (const [target, keyframes] of segments) m.animate(target, keyframes, { duration: 0 });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const controls = m.animate(segments);
|
|
137
|
+
return () => controls.cancel();
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Reveal elements as they enter the viewport, once each. Elements already on screen when this
|
|
143
|
+
* runs stay as they are (no flash); the ones below the fold are hidden and rise in when seen.
|
|
144
|
+
*/
|
|
145
|
+
export function reveal(selector, { y = 16, duration = 0.5, amount = 0.3 } = {}) {
|
|
146
|
+
return later((m) => {
|
|
147
|
+
if (prefersReducedMotion()) return;
|
|
148
|
+
const stops = [];
|
|
149
|
+
for (const el of elementsOf(selector)) {
|
|
150
|
+
if (el.getBoundingClientRect().top < window.innerHeight) continue;
|
|
151
|
+
el.style.opacity = "0";
|
|
152
|
+
stops.push(m.inView(el, () => void m.animate(el, { opacity: [0, 1], y: [y, 0] }, { duration, ease: "easeOut" }), { amount }));
|
|
153
|
+
}
|
|
154
|
+
return () => stops.forEach((stop) => stop());
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Stagger a list in: each child rises after the previous. */
|
|
159
|
+
export function staggerIn(selector, { gap = 0.06, y = 12, duration = 0.4, from = "first" } = {}) {
|
|
160
|
+
return later((m) => {
|
|
161
|
+
const opts = prefersReducedMotion() ? { duration: 0 } : { duration, delay: m.stagger(gap, { from }), ease: "easeOut" };
|
|
162
|
+
const controls = m.animate(selector, { opacity: [0, 1], y: [y, 0] }, opts);
|
|
163
|
+
return () => controls.cancel();
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Report how far `target` has scrolled through the viewport: `onProgress(p)` with p in 0..1 between
|
|
169
|
+
* the offsets (`["start end", "end start"]`: from the target's top meeting the bottom of the
|
|
170
|
+
* screen to its bottom leaving the top). Progress is a measurement, so it runs under reduced
|
|
171
|
+
* motion too; what the app does with it decides the motion.
|
|
172
|
+
*/
|
|
173
|
+
export function scrollProgress(target, onProgress, { offset = ["start end", "end start"], container } = {}) {
|
|
174
|
+
return later((m) => m.scroll((p) => onProgress(p), { target, offset, container }));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Count a number up in an element's text, formatted by `format`. Under reduced motion, show the end. */
|
|
178
|
+
export function countUp(el, to, { duration = 1.2, format = (n) => Math.round(n).toLocaleString() } = {}) {
|
|
179
|
+
return later((m) => {
|
|
180
|
+
if (prefersReducedMotion()) {
|
|
181
|
+
el.textContent = format(to);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const controls = m.animate(0, to, { duration, ease: "easeOut", onUpdate: (n) => (el.textContent = format(n)) });
|
|
185
|
+
return () => controls.stop();
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Run `fn` once when `target` enters the viewport (for a count-up or a late reveal). Returns the stop. */
|
|
190
|
+
export function onEnter(target, fn, { amount = 0.5 } = {}) {
|
|
191
|
+
return later((m) =>
|
|
192
|
+
m.inView(target, () => {
|
|
193
|
+
fn();
|
|
194
|
+
}, { amount })
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A press squash with a spring back; works for mouse, touch and keyboard through Motion's `press`. */
|
|
199
|
+
export function pressSquash(target, { scale = 0.94 } = {}) {
|
|
200
|
+
return later((m) =>
|
|
201
|
+
m.press(target, (el) => {
|
|
202
|
+
if (prefersReducedMotion()) return;
|
|
203
|
+
m.animate(el, { scale }, { duration: 0.1 });
|
|
204
|
+
return () => m.animate(el, { scale: 1 }, { type: "spring", stiffness: 500, damping: 18 });
|
|
205
|
+
})
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## A page told by scrolling
|
|
211
|
+
|
|
212
|
+
### `App.jsx`
|
|
213
|
+
```jsx app=story
|
|
214
|
+
import React, { useEffect, useRef } from "react";
|
|
215
|
+
import { Button, Card, Heading, Row, Spacer, Stack, Text } from "@vibes.diy/pouf";
|
|
216
|
+
import { countUp, motionScope, onEnter, pressSquash, reveal, scrollProgress, sequence, staggerIn } from "./lib/motion.js";
|
|
217
|
+
|
|
218
|
+
const CHAPTERS = [
|
|
219
|
+
{ title: "The idea", body: "A show about the people who fix things nobody else will touch." },
|
|
220
|
+
{ title: "The first season", body: "Twelve conversations, recorded in workshops, kitchens and one lighthouse." },
|
|
221
|
+
{ title: "What comes next", body: "Season two opens the mailbag. Send us the thing you cannot fix." },
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
export default function App() {
|
|
225
|
+
const storyRef = useRef(null);
|
|
226
|
+
const barRef = useRef(null);
|
|
227
|
+
const listenersRef = useRef(null);
|
|
228
|
+
|
|
229
|
+
// One effect sets every piece of motion up and returns one cleanup. The engine arrives after
|
|
230
|
+
// first paint; the page reads fine before it does and with it stopped.
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
const scope = motionScope();
|
|
233
|
+
// The hero is above the fold, so its sequence moves position and scale a little and leaves
|
|
234
|
+
// opacity alone: the headline is readable before, during and after.
|
|
235
|
+
scope.add(sequence([
|
|
236
|
+
["#headline", { y: [10, 0] }, { duration: 0.5 }],
|
|
237
|
+
["#tagline", { y: [6, 0] }, { at: "-0.2", duration: 0.4 }],
|
|
238
|
+
["#listen", { scale: [0.97, 1] }, { at: "-0.1", type: "spring", bounce: 0.3 }],
|
|
239
|
+
]));
|
|
240
|
+
scope.add(reveal("[data-reveal]"));
|
|
241
|
+
scope.add(staggerIn("#chapters li", { gap: 0.08 }));
|
|
242
|
+
scope.add(scrollProgress(storyRef.current, (p) => {
|
|
243
|
+
if (barRef.current) barRef.current.style.transform = `scaleX(${p})`;
|
|
244
|
+
}));
|
|
245
|
+
// The number sits below the story, so it counts when the reader reaches it, once.
|
|
246
|
+
scope.add(onEnter(listenersRef.current, () => scope.add(countUp(listenersRef.current, 48210))));
|
|
247
|
+
scope.add(pressSquash("#listen"));
|
|
248
|
+
return scope.cleanup;
|
|
249
|
+
}, []);
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<main className="min-h-screen bg-background text-foreground">
|
|
253
|
+
<div className="fixed top-0 left-0 right-0 h-1 bg-muted">
|
|
254
|
+
<div ref={barRef} className="h-full w-full bg-primary origin-left" />
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
<section className="min-h-screen flex items-center px-6 md:px-12">
|
|
258
|
+
<Stack gap={4}>
|
|
259
|
+
<Heading id="headline" level={1}>Fix It Anyway</Heading>
|
|
260
|
+
<Text id="tagline" muted>A podcast about stubborn problems and the people who outlast them.</Text>
|
|
261
|
+
<Row gap={2}>
|
|
262
|
+
<Button id="listen" size="lg">Listen to the trailer</Button>
|
|
263
|
+
</Row>
|
|
264
|
+
</Stack>
|
|
265
|
+
</section>
|
|
266
|
+
|
|
267
|
+
<section ref={storyRef} className="px-6 md:px-12 py-24">
|
|
268
|
+
<Stack gap={12}>
|
|
269
|
+
{CHAPTERS.map((chapter) => (
|
|
270
|
+
<Card key={chapter.title} data-reveal variant="tight">
|
|
271
|
+
<Stack gap={2}>
|
|
272
|
+
<Heading level={2}>{chapter.title}</Heading>
|
|
273
|
+
<Text>{chapter.body}</Text>
|
|
274
|
+
</Stack>
|
|
275
|
+
</Card>
|
|
276
|
+
))}
|
|
277
|
+
</Stack>
|
|
278
|
+
</section>
|
|
279
|
+
|
|
280
|
+
<section className="px-6 md:px-12 py-24">
|
|
281
|
+
<Row gap={6} align="center" wrap>
|
|
282
|
+
<Stack gap={1}>
|
|
283
|
+
<Heading level={2}><span ref={listenersRef}>0</span></Heading>
|
|
284
|
+
<Text muted>listeners so far</Text>
|
|
285
|
+
</Stack>
|
|
286
|
+
<Spacer />
|
|
287
|
+
<ul id="chapters" className="grid gap-2">
|
|
288
|
+
{CHAPTERS.map((chapter) => (
|
|
289
|
+
<li key={chapter.title}><Text size="sm">{chapter.title}</Text></li>
|
|
290
|
+
))}
|
|
291
|
+
</ul>
|
|
292
|
+
</Row>
|
|
293
|
+
</section>
|
|
294
|
+
</main>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
The JSX stays class-only: positions, sizes and colours are Tailwind classes
|
|
300
|
+
and pouf props, and Motion writes transforms and opacity onto the elements
|
|
301
|
+
through refs and selectors. The one place a style is set in the app's own
|
|
302
|
+
code is the progress bar's `transform` inside the scroll callback, through
|
|
303
|
+
its ref, which is what a scroll binding is for.
|
|
304
|
+
|
|
305
|
+
## The three obligations, in order
|
|
306
|
+
|
|
307
|
+
**Visible first.** Nothing above the fold starts hidden. `reveal` leaves any
|
|
308
|
+
element already on screen alone and hides only the ones below the fold, so
|
|
309
|
+
the page reads before the engine arrives, reads with it stopped, and the
|
|
310
|
+
publish screenshot (taken soon after load) shows the hero rather than a
|
|
311
|
+
blank. A headline sequence above the fold moves position or scale a little
|
|
312
|
+
and leaves opacity alone, so the words are readable before the engine
|
|
313
|
+
arrives, during the sequence, and after it lands where the element already
|
|
314
|
+
sat; an entrance from opacity zero belongs below the fold, where `reveal`
|
|
315
|
+
hides nothing the reader can see yet. A count-up below the fold starts when
|
|
316
|
+
its element enters (`onEnter`), so the reader sees it count rather than its
|
|
317
|
+
end.
|
|
318
|
+
|
|
319
|
+
**Reduced motion is a setting, read when the animation starts.** The helpers
|
|
320
|
+
call `matchMedia("(prefers-reduced-motion: reduce)")` inside `later`, at the
|
|
321
|
+
moment the engine is in hand, rather than once at import time, so a person
|
|
322
|
+
who toggles the setting while the page is open gets the next animation
|
|
323
|
+
right. Under the setting: `move`, `sequence` and `staggerIn` land at once,
|
|
324
|
+
`reveal` leaves everything visible, `countUp` prints the final number,
|
|
325
|
+
`pressSquash` does nothing, and `scrollProgress` keeps reporting because a
|
|
326
|
+
progress measurement is not motion.
|
|
327
|
+
|
|
328
|
+
**Stop everything in the effect's cleanup.** A scroll binding that outlives
|
|
329
|
+
its component keeps writing to a detached element; an `inView` observer
|
|
330
|
+
keeps a reference to a node React dropped; a press listener on a button that
|
|
331
|
+
re-rendered answers the old one. `motionScope` collects the stops and the
|
|
332
|
+
effect returns `scope.cleanup` (measured: after cleanup, scrolling reports
|
|
333
|
+
nothing and a press squashes nothing).
|
|
334
|
+
|
|
335
|
+
## Scroll
|
|
336
|
+
|
|
337
|
+
`scrollProgress(target, onProgress, { offset, container })` reports 0..1 as
|
|
338
|
+
`target` moves through the viewport. The offsets are Motion's
|
|
339
|
+
`"target-point container-point"` pairs: `["start end", "end start"]` runs
|
|
340
|
+
from the target's top meeting the screen's bottom to its bottom leaving the
|
|
341
|
+
top, so a section drives a bar for exactly as long as it is on screen;
|
|
342
|
+
`["start start", "end end"]` tracks a whole page. Spend the value on
|
|
343
|
+
`transform` and `opacity` (composited, cheap); Motion rides the browser's
|
|
344
|
+
`ScrollTimeline` where it exists, so a scroll-bound transform stays smooth on
|
|
345
|
+
a phone. Measured: a 1500 px section reports 0.53 at mid-scroll and stops
|
|
346
|
+
reporting after cleanup.
|
|
347
|
+
|
|
348
|
+
A scroll-driven animation (an element that rotates or fades with the
|
|
349
|
+
scroll) is `m.scroll(m.animate(el, { rotate: 180 }), { target })` inside a
|
|
350
|
+
helper of the same shape as `scrollProgress`; the progress form is the
|
|
351
|
+
starter because most pages want a number they can spend on several things.
|
|
352
|
+
|
|
353
|
+
## Sequences and springs
|
|
354
|
+
|
|
355
|
+
`sequence([[target, keyframes, options], …])` plays segments in order; `at`
|
|
356
|
+
retimes a segment: `"<"` starts with the previous one, `"+0.5"` half a
|
|
357
|
+
second after it ends, `"-0.2"` two tenths before. A spring is an option on
|
|
358
|
+
any segment or `move`: `{ type: "spring", stiffness: 500, damping: 18 }`
|
|
359
|
+
for a snap, `{ type: "spring", bounce: 0.3 }` for a soft landing. Keyframe
|
|
360
|
+
arrays run through every value (`{ x: [0, 40, 0] }`), and `repeat: Infinity`
|
|
361
|
+
with `repeatType: "reverse"` makes a breathing idle; keep an idle to one
|
|
362
|
+
small element, since nothing should move while someone is reading.
|
|
363
|
+
|
|
364
|
+
## Gestures
|
|
365
|
+
|
|
366
|
+
`pressSquash(target)` is Motion's `press`: it fires for mouse, touch and the
|
|
367
|
+
keyboard, hands back the release through the callback's return, and is the
|
|
368
|
+
one gesture most pages need. `hover(target, onEnter)` has the same shape and
|
|
369
|
+
returns a stop. Both are set up in the same effect and stopped in the same
|
|
370
|
+
cleanup.
|
|
371
|
+
|
|
372
|
+
## A board whose notes spring in
|
|
373
|
+
|
|
374
|
+
### `lib/motion.js`
|
|
375
|
+
```js app=kudos
|
|
376
|
+
// The same helper module as the story page; kept once per app.
|
|
377
|
+
const ENGINE_URL = "https://esm.sh/motion@14.0.0";
|
|
378
|
+
let enginePromise;
|
|
379
|
+
export function loadMotion() {
|
|
380
|
+
enginePromise ??= import(ENGINE_URL);
|
|
381
|
+
return enginePromise;
|
|
382
|
+
}
|
|
383
|
+
export function prefersReducedMotion() {
|
|
384
|
+
return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
385
|
+
}
|
|
386
|
+
export function motionScope() {
|
|
387
|
+
const stops = new Set();
|
|
388
|
+
return {
|
|
389
|
+
add(stop) {
|
|
390
|
+
stops.add(stop);
|
|
391
|
+
return stop;
|
|
392
|
+
},
|
|
393
|
+
cleanup() {
|
|
394
|
+
for (const stop of stops) stop();
|
|
395
|
+
stops.clear();
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
function later(setup) {
|
|
400
|
+
let stopped = false;
|
|
401
|
+
let stopInner = null;
|
|
402
|
+
loadMotion().then((m) => {
|
|
403
|
+
if (stopped) return;
|
|
404
|
+
stopInner = setup(m) || null;
|
|
405
|
+
});
|
|
406
|
+
return () => {
|
|
407
|
+
if (stopped) return; // a stop called twice runs the inner stop once
|
|
408
|
+
stopped = true;
|
|
409
|
+
const stop = stopInner;
|
|
410
|
+
stopInner = null;
|
|
411
|
+
if (stop) stop();
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
export function move(target, keyframes, options = {}) {
|
|
415
|
+
return later((m) => {
|
|
416
|
+
const opts = prefersReducedMotion() ? { ...options, duration: 0, delay: 0 } : options;
|
|
417
|
+
const controls = m.animate(target, keyframes, opts);
|
|
418
|
+
return () => controls.cancel();
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
export function pressSquash(target, { scale = 0.94 } = {}) {
|
|
422
|
+
return later((m) =>
|
|
423
|
+
m.press(target, (el) => {
|
|
424
|
+
if (prefersReducedMotion()) return;
|
|
425
|
+
m.animate(el, { scale }, { duration: 0.1 });
|
|
426
|
+
return () => m.animate(el, { scale: 1 }, { type: "spring", stiffness: 500, damping: 18 });
|
|
427
|
+
})
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
### `App.jsx`
|
|
433
|
+
```jsx app=kudos
|
|
434
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
435
|
+
import { useFireproof } from "use-fireproof";
|
|
436
|
+
import { useVibe, useViewer } from "use-vibes";
|
|
437
|
+
import { Button, Card, Field, Heading, Input, Row, Skeleton, Spacer, Stack, Text } from "@vibes.diy/pouf";
|
|
438
|
+
import { motionScope, move, pressSquash } from "./lib/motion.js";
|
|
439
|
+
|
|
440
|
+
export default function App() {
|
|
441
|
+
const { database, useLiveQuery } = useFireproof("kudos");
|
|
442
|
+
const { me, can, ready } = useVibe("kudos");
|
|
443
|
+
const { requestLogin, ViewerTag } = useViewer();
|
|
444
|
+
const { docs } = useLiveQuery("type", { key: "kudo" });
|
|
445
|
+
const kudos = useMemo(() => [...docs].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)), [docs]);
|
|
446
|
+
const [to, setTo] = useState("");
|
|
447
|
+
const [text, setText] = useState("");
|
|
448
|
+
const newestRef = useRef(null);
|
|
449
|
+
const seenRef = useRef(null); // null until the first populated snapshot, which is the baseline
|
|
450
|
+
const newestId = kudos[0]?._id ?? null;
|
|
451
|
+
|
|
452
|
+
// The send button squashes on press; set up once, stopped on unmount.
|
|
453
|
+
useEffect(() => {
|
|
454
|
+
const scope = motionScope();
|
|
455
|
+
scope.add(pressSquash("#send"));
|
|
456
|
+
return scope.cleanup;
|
|
457
|
+
}, []);
|
|
458
|
+
|
|
459
|
+
// A note that was not on the board before springs in; the ones already there stay put. The first
|
|
460
|
+
// populated snapshot is the baseline (a loaded board stands still), and the effect keys on the
|
|
461
|
+
// newest id so a reorder or an edit elsewhere cancels nothing.
|
|
462
|
+
useEffect(() => {
|
|
463
|
+
if (!newestId) return;
|
|
464
|
+
if (seenRef.current === null) {
|
|
465
|
+
seenRef.current = new Set(kudos.map((k) => k._id));
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (seenRef.current.has(newestId) || !newestRef.current) return;
|
|
469
|
+
const scope = motionScope();
|
|
470
|
+
scope.add(move(newestRef.current, { opacity: [0, 1], scale: [0.9, 1], y: [8, 0] }, { type: "spring", bounce: 0.35 }));
|
|
471
|
+
return scope.cleanup;
|
|
472
|
+
}, [newestId]);
|
|
473
|
+
|
|
474
|
+
// Every id in every snapshot is remembered, after the effect above has looked.
|
|
475
|
+
useEffect(() => {
|
|
476
|
+
if (seenRef.current) for (const k of kudos) seenRef.current.add(k._id);
|
|
477
|
+
}, [kudos]);
|
|
478
|
+
|
|
479
|
+
const draft = { type: "kudo", to, text, authorHandle: me?.userHandle };
|
|
480
|
+
const canPost = Boolean(ready && me && to.trim() && text.trim() && can.create(draft).ok);
|
|
481
|
+
|
|
482
|
+
async function send() {
|
|
483
|
+
if (!canPost) return;
|
|
484
|
+
await database.put({ ...draft, _id: crypto.randomUUID(), createdAt: Date.now() });
|
|
485
|
+
setTo("");
|
|
486
|
+
setText("");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return (
|
|
490
|
+
<main className="min-h-screen bg-background text-foreground p-4 md:p-8">
|
|
491
|
+
<Stack gap={4}>
|
|
492
|
+
<Heading level={1}>Kudos</Heading>
|
|
493
|
+
{!ready && <div aria-busy="true"><Skeleton variant="row" /></div>}
|
|
494
|
+
{ready && !me && <Row gap={2} align="center" wrap><Text muted>Sign in to leave a note.</Text><Button size="sm" variant="quiet" onClick={requestLogin}>Sign in</Button></Row>}
|
|
495
|
+
{ready && me && (
|
|
496
|
+
<Card variant="tight">
|
|
497
|
+
<Row gap={2} align="top" wrap>
|
|
498
|
+
<Field label="To">{(id, d) => <Input id={id} describedBy={d} value={to} onChange={setTo} placeholder="a teammate" />}</Field>
|
|
499
|
+
<Field label="For">{(id, d) => <Input id={id} describedBy={d} value={text} onChange={setText} placeholder="the thing they did" />}</Field>
|
|
500
|
+
<Button id="send" onClick={send} disabled={!canPost}>Send kudos</Button>
|
|
501
|
+
</Row>
|
|
502
|
+
</Card>
|
|
503
|
+
)}
|
|
504
|
+
<div className="grid gap-3 md:grid-cols-2">
|
|
505
|
+
{kudos.map((k, i) => (
|
|
506
|
+
<div key={k._id} ref={i === 0 ? newestRef : null}>
|
|
507
|
+
<Card variant="tight" motion="lift">
|
|
508
|
+
<Stack gap={1}>
|
|
509
|
+
<Heading level={3}>{k.to}</Heading>
|
|
510
|
+
<Text>{k.text}</Text>
|
|
511
|
+
<Row gap={2} align="center" wrap>
|
|
512
|
+
{k.authorHandle && <ViewerTag userHandle={k.authorHandle} />}
|
|
513
|
+
<Spacer />
|
|
514
|
+
{can.delete(k).ok && <Button size="sm" variant="quiet" tone="down" onClick={() => database.del(k._id)}>Remove</Button>}
|
|
515
|
+
</Row>
|
|
516
|
+
</Stack>
|
|
517
|
+
</Card>
|
|
518
|
+
</div>
|
|
519
|
+
))}
|
|
520
|
+
</div>
|
|
521
|
+
</Stack>
|
|
522
|
+
</main>
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
### `access.js`
|
|
528
|
+
```js app=kudos
|
|
529
|
+
// kudo: a note on the shared board. Its author edits and removes it; everyone reads the board.
|
|
530
|
+
export function kudos(doc, oldDoc, user, ctx) {
|
|
531
|
+
if (!user) throw { forbidden: "sign in to leave a note" };
|
|
532
|
+
if (doc.type === "kudo" || (doc._deleted && oldDoc && oldDoc.type === "kudo")) {
|
|
533
|
+
const author = oldDoc ? oldDoc.authorHandle : doc.authorHandle;
|
|
534
|
+
if (author !== user.userHandle) throw { forbidden: "notes are signed as yourself" };
|
|
535
|
+
if (oldDoc && !doc._deleted && doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "a note keeps its author" };
|
|
536
|
+
return { channels: ["board"], grant: { public: ["board"] } };
|
|
537
|
+
}
|
|
538
|
+
throw { forbidden: "unknown document type" };
|
|
539
|
+
}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
The newest note is the only one that moves: the first populated snapshot
|
|
543
|
+
is the baseline and animates nothing, every later snapshot's ids are
|
|
544
|
+
remembered, and only a top note nobody has seen springs in, so a board that
|
|
545
|
+
loaded with twenty notes stands still and a reorder moves nothing. (A board
|
|
546
|
+
that is empty when it opens treats its first note as the baseline too; the
|
|
547
|
+
second note is the first to spring.) Pouf's `Card motion="lift"` carries
|
|
548
|
+
the hover, so the app writes none of its own.
|
|
549
|
+
|
|
550
|
+
## Mobile
|
|
551
|
+
|
|
552
|
+
A press is Motion's `press`, which answers touch as well as mouse, so a
|
|
553
|
+
squash works under a thumb. Scroll-bound values spend on `transform` and
|
|
554
|
+
`opacity` only, which a phone composites without repainting. A reveal that
|
|
555
|
+
waits for `amount: 0.3` of the element to be visible feels right on a
|
|
556
|
+
narrow screen where sections arrive one at a time; `amount: "all"` waits too
|
|
557
|
+
long. Keep entrances under half a second and idles to one small element,
|
|
558
|
+
since the publish screenshot and a reader's eye both land soon after paint.
|
|
559
|
+
|
|
560
|
+
## Shapes a motion vibe takes
|
|
561
|
+
|
|
562
|
+
- **A launch or story page** — a headline sequence, sections that reveal,
|
|
563
|
+
a scroll progress bar, a counting number, a springy call to action. The
|
|
564
|
+
first example.
|
|
565
|
+
- **A board or feed with life** — the newest item springs in, buttons
|
|
566
|
+
squash, nothing else moves. The second example.
|
|
567
|
+
- **A scroll-told explainer** — a diagram whose parts arrive as the reader
|
|
568
|
+
reaches each paragraph: one `reveal` per part with `amount: 0.5`, and a
|
|
569
|
+
`scrollProgress` on the whole article driving a position indicator.
|
|
570
|
+
- **A parallax hero** — `m.scroll(m.animate(image, { y: 120 }), { target: hero })`
|
|
571
|
+
so the picture drifts slower than the words; keep the drift under a
|
|
572
|
+
quarter of the hero's height.
|
|
573
|
+
- **A number dashboard** — `countUp` on each stat as its card reveals: call
|
|
574
|
+
`countUp` inside the `inView` callback rather than on mount.
|
|
575
|
+
- **A choreographed intro** — one `sequence` with `at` offsets, under two
|
|
576
|
+
seconds, that lands every element where it already sat.
|
|
577
|
+
|
|
578
|
+
## Gotchas
|
|
579
|
+
|
|
580
|
+
- **The hooks break after adding motion** — `motion/react` or
|
|
581
|
+
`framer-motion` brought a second React. The vanilla entry carries none.
|
|
582
|
+
- **The page is blank until the engine loads** — above-the-fold elements
|
|
583
|
+
started hidden. `reveal` skips what is on screen; sequences start from
|
|
584
|
+
the element's visible state a moment after paint.
|
|
585
|
+
- **`animate-in`, `fade-in-0` and their siblings compile to nothing** —
|
|
586
|
+
they are plugin classes the runtime's Tailwind does not load. Motion
|
|
587
|
+
through refs, or the `starting:` classes for a list entry.
|
|
588
|
+
- **An inline `style={{ animationDelay }}` in the JSX** — the house keeps
|
|
589
|
+
JSX class-only. A delay is `stagger` on a helper, or a `delay-*` class on
|
|
590
|
+
a `starting:` entry.
|
|
591
|
+
- **Scrolling still moves something after navigating away** — a scroll
|
|
592
|
+
binding or observer with no stop. Every helper's stop goes into the
|
|
593
|
+
scope, and the effect returns `scope.cleanup`.
|
|
594
|
+
- **The whole board animates on every change** — the effect animated every
|
|
595
|
+
item. Keep the seen ids and move only the new one.
|
|
596
|
+
- **Reduced motion still animates** — the setting was read once at import,
|
|
597
|
+
or the app wrote its own `animate` call beside the helpers. Every
|
|
598
|
+
animation goes through a helper, and the helpers read the setting when
|
|
599
|
+
they run.
|
|
600
|
+
- **A press fires twice on a phone** — a `click` handler animates beside
|
|
601
|
+
`press`. One gesture helper per control.
|
package/llms/physics.md
CHANGED
|
@@ -169,8 +169,8 @@ export function createWorld(canvas, { width, height, gravity = 1, seed = 1 } = {
|
|
|
169
169
|
},
|
|
170
170
|
/** Every non-wall body out, the clock back to zero, the same seed: the next run replays. */
|
|
171
171
|
reset(pieces) {
|
|
172
|
+
// Bodies go; the walls and the mouse constraint stay attached across every reset.
|
|
172
173
|
for (const body of Composite.allBodies(engine.world)) if (body.label !== "wall") Composite.remove(engine.world, body);
|
|
173
|
-
Composite.add(engine.world, mouseConstraint);
|
|
174
174
|
Common._seed = seed;
|
|
175
175
|
engine.timing.timestamp = 0;
|
|
176
176
|
for (const piece of pieces) Composite.add(engine.world, bodyFromPiece(piece));
|
|
@@ -396,6 +396,7 @@ export function marbleRuns(doc, oldDoc, user, ctx) {
|
|
|
396
396
|
if (doc.type === "level" || (doc._deleted && oldDoc && oldDoc.type === "level")) {
|
|
397
397
|
const creator = oldDoc ? oldDoc.creatorHandle : doc.creatorHandle;
|
|
398
398
|
if (creator !== user.userHandle) throw { forbidden: "runs are kept as yourself" };
|
|
399
|
+
if (oldDoc && !doc._deleted && doc.creatorHandle !== oldDoc.creatorHandle) throw { forbidden: "a run keeps its maker" };
|
|
399
400
|
return { channels: ["levels"], grant: { public: ["levels"] } };
|
|
400
401
|
}
|
|
401
402
|
throw { forbidden: "unknown document type" };
|
package/llms/richtext.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export const richtextConfig = {
|
|
2
|
+
name: "richtext",
|
|
3
|
+
label: "Rich text (Tiptap)",
|
|
4
|
+
description: "Notebooks, journals, wikis, notes apps and document editors built on Tiptap: pages with headings, " +
|
|
5
|
+
"lists, checklists, links and images, a list of documents and one editor, a shared page a team " +
|
|
6
|
+
"edits with the last editor named, meeting notes with action items, a recipe book with real pages, " +
|
|
7
|
+
"a journal with one entry per day. Covers every Tiptap package pinned to one version by its esm.sh " +
|
|
8
|
+
"URL (one ProseMirror, no second React), an editor module with createEditor, load, flush, run and " +
|
|
9
|
+
"destroy, a saved note as its JSON plus plain text and a derived title, saving after typing pauses " +
|
|
10
|
+
"with flush on blur and switch, loading a document without a save echo (setContent with emitUpdate " +
|
|
11
|
+
"false), destroy in the effect's cleanup, the page styled from the host with arbitrary variants, " +
|
|
12
|
+
"toolbar commands and active states, and images as _files. " +
|
|
13
|
+
"notebook, notes, note taking, journal, diary, wiki, document editor, rich text editor, text editor, " +
|
|
14
|
+
"wysiwyg, writing app, markdown editor, meeting notes, knowledge base, tiptap, prosemirror",
|
|
15
|
+
cues: [
|
|
16
|
+
"notebook",
|
|
17
|
+
"notes app",
|
|
18
|
+
"note taking",
|
|
19
|
+
"note-taking",
|
|
20
|
+
"take notes",
|
|
21
|
+
"journal",
|
|
22
|
+
"diary",
|
|
23
|
+
"wiki",
|
|
24
|
+
"document editor",
|
|
25
|
+
"doc editor",
|
|
26
|
+
"rich text",
|
|
27
|
+
"rich-text",
|
|
28
|
+
"text editor",
|
|
29
|
+
"wysiwyg",
|
|
30
|
+
"writing app",
|
|
31
|
+
"writing space",
|
|
32
|
+
"markdown editor",
|
|
33
|
+
"meeting notes",
|
|
34
|
+
"knowledge base",
|
|
35
|
+
"formatting toolbar",
|
|
36
|
+
"bold and italic",
|
|
37
|
+
"headings and lists",
|
|
38
|
+
"checklist in",
|
|
39
|
+
"tiptap",
|
|
40
|
+
"prosemirror",
|
|
41
|
+
"quill",
|
|
42
|
+
"lexical",
|
|
43
|
+
"slate editor",
|
|
44
|
+
"editor.js",
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
//# sourceMappingURL=richtext.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"richtext.js","sourceRoot":"","sources":["../../jsr/llms/richtext.ts"],"names":[],"mappings":"AAKA,MAAM,CAAC,MAAM,cAAc,GAAc;IACvC,IAAI,EAAE,UAAU;IAChB,KAAK,EAAE,oBAAoB;IAC3B,WAAW,EACT,oGAAoG;QACpG,gGAAgG;QAChG,oGAAoG;QACpG,oGAAoG;QACpG,mGAAmG;QACnG,oGAAoG;QACpG,oGAAoG;QACpG,kGAAkG;QAClG,4DAA4D;QAC5D,sGAAsG;QACtG,2FAA2F;IAC7F,IAAI,EAAE;QACJ,UAAU;QACV,WAAW;QACX,aAAa;QACb,aAAa;QACb,YAAY;QACZ,SAAS;QACT,OAAO;QACP,MAAM;QACN,iBAAiB;QACjB,YAAY;QACZ,WAAW;QACX,WAAW;QACX,aAAa;QACb,SAAS;QACT,aAAa;QACb,eAAe;QACf,iBAAiB;QACjB,eAAe;QACf,gBAAgB;QAChB,oBAAoB;QACpB,iBAAiB;QACjB,oBAAoB;QACpB,cAAc;QACd,QAAQ;QACR,aAAa;QACb,OAAO;QACP,SAAS;QACT,cAAc;QACd,WAAW;KACZ;CACF,CAAC"}
|