@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/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" };
@@ -0,0 +1,2 @@
1
+ import type { LlmConfig } from "./types.js";
2
+ export declare const richtextConfig: LlmConfig;
@@ -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"}