@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.
@@ -0,0 +1,612 @@
1
+ # Rich text — notebooks, journals, wikis and documents with Tiptap
2
+
3
+ When someone asks for a notebook, a journal, a wiki, a notes app, a document
4
+ editor, a place to write with headings, lists, checklists, links and images,
5
+ a shared page a team edits, meeting notes, a recipe book with real pages, or
6
+ any app whose heart is writing more than a sentence, this is the recipe.
7
+ Tiptap (MIT, on ProseMirror) is the editor: the app mounts it on a plain
8
+ element through a small helper module, keeps each document as Tiptap's JSON
9
+ in Fireproof, saves after typing pauses, loads a document without echoing it
10
+ back as a save, and styles the written text with Tailwind on the host.
11
+
12
+ The sibling boundary: a short field (a title, a comment, a caption) is
13
+ pouf's `Textarea` in a `Field`; a feed of posts is `pouf-feed`; a pipeline
14
+ of stories with stages is `pouf-editorial`. Rich text is the right call when
15
+ the person is writing a page.
16
+
17
+ The worked examples below were built and measured for this doc (Chromium,
18
+ 2026-10-07, Tiptap `3.31.4` from esm.sh): a notebook with a list of notes
19
+ and one editor, and a shared page a team edits together.
20
+
21
+ ## Project shape
22
+
23
+ ```
24
+ App.jsx the notebook: a list of documents, one editor, a toolbar, save state
25
+ lib/editor.js createEditor(host, …): the pinned imports, change events, load, flush, commands, destroy
26
+ access.js who may write which documents
27
+ ```
28
+
29
+ ## One ProseMirror, pinned
30
+
31
+ **Import every Tiptap package by its full esm.sh URL, and every Tiptap
32
+ package is pinned to the same version**, in `lib/editor.js` at the top of
33
+ the file. ProseMirror is unforgiving about copies: two versions of
34
+ `@tiptap/pm` in one editor throw on the first keyed plugin, and nothing in
35
+ the runtime keeps a family of packages on one version (each bare import
36
+ resolves to its own latest). Measured: with `@tiptap/core`, `starter-kit`,
37
+ `extension-placeholder`, `extension-task-list`, `extension-task-item`,
38
+ `extension-link` and `extension-image` all at `3.31.4`, the editor's state
39
+ and view are instances of the one `@tiptap/pm` the pinned URLs share, and
40
+ the whole set arrives in about 2 s cold. The core entry is the one to use:
41
+ `new Editor({ element })` mounts into a ref with no React of its own, so
42
+ the app's one React stays the only one. (`@tiptap/react` exists; the core
43
+ entry does the same job here with one fewer thing to keep in step.)
44
+
45
+ ### `lib/editor.js`
46
+ ```js app=notebook
47
+ // lib/editor.js — a Tiptap editor behind a small surface: create it on a host element, hear about
48
+ // changes as JSON plus plain text (debounced for saving), load a document without echoing it back
49
+ // as a change, run toolbar commands, and destroy it on unmount. One ProseMirror: every package is
50
+ // pinned to the same version so esm.sh serves one copy of @tiptap/pm.
51
+ import { Editor } from "https://esm.sh/@tiptap/core@3.31.4";
52
+ import StarterKit from "https://esm.sh/@tiptap/starter-kit@3.31.4";
53
+ import Placeholder from "https://esm.sh/@tiptap/extension-placeholder@3.31.4";
54
+ import TaskList from "https://esm.sh/@tiptap/extension-task-list@3.31.4";
55
+ import TaskItem from "https://esm.sh/@tiptap/extension-task-item@3.31.4";
56
+ import Link from "https://esm.sh/@tiptap/extension-link@3.31.4";
57
+ import Image from "https://esm.sh/@tiptap/extension-image@3.31.4";
58
+
59
+ export const EMPTY_DOC = { type: "doc", content: [{ type: "paragraph" }] };
60
+
61
+ /** The first heading's text, else the first words of the body: what a document list shows. */
62
+ export function titleOf(json, fallback = "Untitled") {
63
+ const blocks = json?.content ?? [];
64
+ const heading = blocks.find((b) => b.type === "heading");
65
+ const text = (node) => (node.content ?? []).map((n) => n.text ?? text(n)).join("");
66
+ const fromHeading = heading ? text(heading).trim() : "";
67
+ if (fromHeading) return fromHeading.slice(0, 80);
68
+ const first = blocks.map(text).find((t) => t.trim());
69
+ return first ? first.trim().slice(0, 80) : fallback;
70
+ }
71
+
72
+ /**
73
+ * createEditor(host, { content, placeholder, editable, onChange, debounceMs })
74
+ * `onChange(json, text)` fires after typing pauses for `debounceMs`; `load(json)` replaces the
75
+ * document without firing it, so a document arriving from the database lands as a load, with no save.
76
+ */
77
+ export function createEditor(host, { content = EMPTY_DOC, placeholder = "Start writing…", editable = true, onChange, debounceMs = 600 } = {}) {
78
+ let timer = 0;
79
+ let loading = false;
80
+ const editor = new Editor({
81
+ element: host,
82
+ editable,
83
+ content,
84
+ extensions: [
85
+ StarterKit,
86
+ Placeholder.configure({ placeholder }),
87
+ TaskList,
88
+ TaskItem.configure({ nested: true }),
89
+ Link.configure({ openOnClick: false, autolink: true }),
90
+ Image.configure({ inline: false }),
91
+ ],
92
+ onUpdate: ({ transaction }) => {
93
+ if (loading || !transaction.docChanged || !onChange) return;
94
+ clearTimeout(timer);
95
+ timer = setTimeout(() => {
96
+ timer = 0; // the pause has landed; flush has nothing left to send
97
+ onChange(editor.getJSON(), editor.getText());
98
+ }, debounceMs);
99
+ },
100
+ });
101
+ const surface = {
102
+ editor,
103
+ /** Replace the document (from the database); no change event follows. Declined while dirty unless forced. */
104
+ load(json, { force = false } = {}) {
105
+ if (JSON.stringify(json) === JSON.stringify(editor.getJSON())) return true;
106
+ if (timer && !force) return false; // words are waiting to save: the person's keystrokes win
107
+ clearTimeout(timer);
108
+ timer = 0;
109
+ loading = true;
110
+ editor.commands.setContent(json ?? EMPTY_DOC, { emitUpdate: false });
111
+ loading = false;
112
+ return true;
113
+ },
114
+ /** True while a pause's words are still waiting to be saved. */
115
+ isDirty() {
116
+ return timer !== 0;
117
+ },
118
+ /** Save now, ahead of the debounce (on blur, on switching documents, on unmount). */
119
+ flush() {
120
+ if (!timer) return;
121
+ clearTimeout(timer);
122
+ timer = 0;
123
+ onChange?.(editor.getJSON(), editor.getText());
124
+ },
125
+ setEditable(on) {
126
+ editor.setEditable(on);
127
+ },
128
+ focus() {
129
+ editor.commands.focus();
130
+ },
131
+ /** Toolbar commands by name, so buttons stay dumb. */
132
+ run(command, value) {
133
+ const c = editor.chain().focus();
134
+ ({
135
+ bold: () => c.toggleBold(),
136
+ italic: () => c.toggleItalic(),
137
+ heading: () => c.toggleHeading({ level: value ?? 2 }),
138
+ bullets: () => c.toggleBulletList(),
139
+ numbers: () => c.toggleOrderedList(),
140
+ tasks: () => c.toggleTaskList(),
141
+ quote: () => c.toggleBlockquote(),
142
+ code: () => c.toggleCodeBlock(),
143
+ link: () => (value ? c.setLink({ href: value }) : c.unsetLink()),
144
+ image: () => c.setImage({ src: value }),
145
+ undo: () => c.undo(),
146
+ redo: () => c.redo(),
147
+ })[command]?.().run();
148
+ },
149
+ isActive(name, attrs) {
150
+ return editor.isActive(name, attrs);
151
+ },
152
+ destroy() {
153
+ clearTimeout(timer);
154
+ editor.destroy();
155
+ },
156
+ };
157
+ return surface;
158
+ }
159
+ ```
160
+
161
+ ## The notebook
162
+
163
+ ### `App.jsx`
164
+ ```jsx app=notebook
165
+ import React, { useEffect, useMemo, useRef, useState } from "react";
166
+ import { useFireproof } from "use-fireproof";
167
+ import { useVibe, useViewer } from "use-vibes";
168
+ import { Badge, Button, Card, Heading, IconButton, Row, Skeleton, Spacer, Stack, Text } from "@vibes.diy/pouf";
169
+ import { EMPTY_DOC, createEditor, titleOf } from "./lib/editor.js";
170
+
171
+ // The written page is styled from the host: Tailwind reaches into the editor with arbitrary
172
+ // variants, so the app ships no stylesheet and the JSX stays class-only.
173
+ const PAGE =
174
+ "min-h-64 [&_.tiptap]:outline-none [&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mt-4 [&_h2]:text-xl [&_h2]:font-semibold [&_h2]:mt-3 " +
175
+ "[&_p]:my-2 [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6 [&_blockquote]:border-l-4 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground " +
176
+ "[&_a]:underline [&_a]:text-primary [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:rounded [&_img]:max-w-full [&_img]:rounded " +
177
+ "[&_ul[data-type=taskList]]:list-none [&_ul[data-type=taskList]]:pl-0 [&_li[data-type=taskItem]]:flex [&_li[data-type=taskItem]]:gap-2 " +
178
+ "[&_.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.is-editor-empty:first-child::before]:text-muted-foreground [&_.is-editor-empty:first-child::before]:float-left [&_.is-editor-empty:first-child::before]:pointer-events-none";
179
+
180
+ const TOOLS = [
181
+ ["bold", "B", "Bold"],
182
+ ["italic", "I", "Italic"],
183
+ ["heading", "H", "Heading"],
184
+ ["bullets", "•", "Bullet list"],
185
+ ["tasks", "☑", "Checklist"],
186
+ ["quote", "❝", "Quote"],
187
+ ];
188
+
189
+ export default function App() {
190
+ const { database, useLiveQuery } = useFireproof("notebook");
191
+ const { me, can, ready } = useVibe("notebook");
192
+ const { requestLogin } = useViewer();
193
+ const { docs } = useLiveQuery("type", { key: "note" });
194
+ const notes = useMemo(() => [...docs].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)), [docs]);
195
+
196
+ const [openId, setOpenId] = useState(null);
197
+ const [saved, setSaved] = useState("idle"); // idle | typing | saved
198
+ const [, bump] = useState(0); // re-render after a command so active states follow the cursor
199
+ const hostRef = useRef(null);
200
+ const surfaceRef = useRef(null);
201
+ const openRef = useRef(null); // the open note as last saved, read by the save callback
202
+ const open = notes.find((n) => n._id === openId) ?? null;
203
+
204
+ // One editor for the page's life: created once, destroyed in the effect's cleanup. The save
205
+ // callback reads the open note through a ref, so one editor serves every note without a rebuild.
206
+ useEffect(() => {
207
+ const surface = createEditor(hostRef.current, {
208
+ placeholder: "Write your note…",
209
+ onChange: async (json, text) => {
210
+ const current = openRef.current;
211
+ if (!current) return;
212
+ // Each save carries its own id, so the copy the live query hands back is recognised as ours
213
+ // whatever the clocks say.
214
+ const next = { ...current, title: titleOf(json), body: json, text, updatedAt: Date.now(), writeId: crypto.randomUUID() };
215
+ openRef.current = next;
216
+ await database.put(next);
217
+ setSaved("saved");
218
+ },
219
+ });
220
+ surface.editor.on("transaction", () => bump((n) => n + 1));
221
+ surface.editor.on("update", () => setSaved("typing"));
222
+ surfaceRef.current = surface;
223
+ return () => {
224
+ surface.flush();
225
+ surface.destroy();
226
+ surfaceRef.current = null;
227
+ };
228
+ }, []);
229
+
230
+ // A note arrives from the list (opened, or changed elsewhere): load it without a save echo. The
231
+ // app's own save comes back with the writeId it sent and is left alone; another device's save
232
+ // loads when nothing here is waiting to save, and otherwise waits for the next delivery (the
233
+ // words typed here land first; theirs arrive on the round trip after).
234
+ useEffect(() => {
235
+ const surface = surfaceRef.current;
236
+ if (!surface) return;
237
+ if (!open) {
238
+ openRef.current = null;
239
+ surface.load(EMPTY_DOC, { force: true });
240
+ surface.setEditable(false);
241
+ return;
242
+ }
243
+ const switched = !openRef.current || openRef.current._id !== open._id;
244
+ const theirs = !switched && open.writeId !== openRef.current.writeId;
245
+ if (switched) {
246
+ openRef.current = open;
247
+ surface.load(open.body ?? EMPTY_DOC, { force: true });
248
+ } else if (theirs && surface.load(open.body ?? EMPTY_DOC)) {
249
+ openRef.current = open;
250
+ }
251
+ surface.setEditable(Boolean(ready && can.edit(open).ok));
252
+ }, [open, ready]);
253
+
254
+ async function newNote() {
255
+ const draft = { type: "note", title: "Untitled", body: EMPTY_DOC, text: "", authorHandle: me?.userHandle };
256
+ if (!can.create(draft).ok) return;
257
+ surfaceRef.current?.flush();
258
+ const doc = { ...draft, _id: crypto.randomUUID(), updatedAt: Date.now(), writeId: crypto.randomUUID() };
259
+ await database.put(doc);
260
+ setOpenId(doc._id);
261
+ surfaceRef.current?.focus();
262
+ }
263
+
264
+ function pick(id) {
265
+ surfaceRef.current?.flush();
266
+ setOpenId(id);
267
+ }
268
+
269
+ const surface = surfaceRef.current;
270
+ const editable = Boolean(open && ready && can.edit(open).ok);
271
+
272
+ return (
273
+ <main className="min-h-screen bg-background text-foreground p-4 md:p-8">
274
+ <div className="grid gap-4 md:grid-cols-4">
275
+ <div className="md:col-span-1">
276
+ <Card variant="tight">
277
+ <Stack gap={2}>
278
+ <Row gap={2} align="center">
279
+ <Heading level={1}>Notebook</Heading>
280
+ <Spacer />
281
+ {ready && me && <Button size="sm" onClick={newNote}>New</Button>}
282
+ </Row>
283
+ {!ready && <div aria-busy="true"><Skeleton variant="row" count={3} /></div>}
284
+ {ready && !me && <Row gap={2} align="center" wrap><Text muted>Sign in and your notes stay with you.</Text><Button size="sm" variant="quiet" onClick={requestLogin}>Sign in</Button></Row>}
285
+ {notes.map((n) => (
286
+ <Button key={n._id} size="sm" variant={n._id === openId ? "solid" : "quiet"} onClick={() => pick(n._id)}>
287
+ {n.title || "Untitled"}
288
+ </Button>
289
+ ))}
290
+ </Stack>
291
+ </Card>
292
+ </div>
293
+
294
+ <div className="md:col-span-3">
295
+ <Card variant="tight">
296
+ <Stack gap={3}>
297
+ <Row gap={1} align="center" wrap>
298
+ {TOOLS.map(([command, glyph, label]) => (
299
+ <IconButton key={command} label={label} tone={surface?.isActive(command === "heading" ? "heading" : command === "bullets" ? "bulletList" : command === "tasks" ? "taskList" : command === "quote" ? "blockquote" : command) ? "blue" : undefined} disabled={!editable} onClick={() => surface?.run(command)}>
300
+ {glyph}
301
+ </IconButton>
302
+ ))}
303
+ <Spacer />
304
+ {open && <Badge tone={saved === "saved" ? "up" : "idle"}>{saved === "typing" ? "Typing…" : saved === "saved" ? "Saved" : editable ? "Ready" : "Read only"}</Badge>}
305
+ {open && can.delete(open).ok && <Button size="sm" variant="quiet" tone="down" onClick={() => database.del(open._id).then(() => setOpenId(null))}>Delete</Button>}
306
+ </Row>
307
+ {!open && ready && <Text muted>Pick a note, or start a new one.</Text>}
308
+ <div ref={hostRef} className={PAGE} onBlur={() => surfaceRef.current?.flush()} />
309
+ </Stack>
310
+ </Card>
311
+ </div>
312
+ </div>
313
+ </main>
314
+ );
315
+ }
316
+ ```
317
+
318
+ ### `access.js`
319
+ ```js app=notebook
320
+ // note: a page in someone's notebook. Its author reads, edits and removes it.
321
+ export function notebook(doc, oldDoc, user, ctx) {
322
+ if (!user) throw { forbidden: "sign in to keep notes" };
323
+ if (doc.type === "note" || (doc._deleted && oldDoc && oldDoc.type === "note")) {
324
+ const author = oldDoc ? oldDoc.authorHandle : doc.authorHandle;
325
+ if (author !== user.userHandle) throw { forbidden: "notes are kept as yourself" };
326
+ if (oldDoc && !doc._deleted && doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "a note keeps its author" };
327
+ return { channels: [`notes:${user.userHandle}`], grant: { users: { [user.userHandle]: [`notes:${user.userHandle}`] } } };
328
+ }
329
+ throw { forbidden: "unknown document type" };
330
+ }
331
+ ```
332
+
333
+ ## A saved note is its JSON plus the plain text
334
+
335
+ **A saved note is its JSON plus the plain text: `body` holds
336
+ `editor.getJSON()` (what the editor loads back, marks and attributes
337
+ intact), `text` holds `editor.getText()` (what search, previews and an AI
338
+ summary read), and `title` holds the first heading or the first words.**
339
+ HTML is a rendering, so it is not what gets kept: JSON survives a change of
340
+ extensions, diffs cleanly, and is what `setContent` takes back without a
341
+ parse. The list shows `title`; a search box filters on `text`; `callAI`
342
+ gets `text`. The whole document is one Fireproof document, which is right
343
+ for a page a person edits alone; a page several people edit at once wants
344
+ the shared-page shape below.
345
+
346
+ ## Saving waits for typing to pause
347
+
348
+ **Saving waits for typing to pause: the editor's `onUpdate` resets a timer
349
+ and the save runs once the keys have been quiet for 600 ms, with `flush()`
350
+ saving at once on blur, on switching documents and on unmount.** A
351
+ `database.put` on every keystroke floods the server with writes to one
352
+ `_id`, which collide on the document's sequence and get rejected; one save
353
+ per pause keeps writes rare and sequential (measured: three insertions in a
354
+ burst produced one change event, and `flush` produced the next one at
355
+ once). The timer clears itself when it fires, so a blur after a save sends
356
+ nothing twice. The save callback reads the open note through a ref rather
357
+ than a closure, so the editor is created once and switching notes costs
358
+ nothing.
359
+
360
+ ## Loading a document lands without a save
361
+
362
+ **A document arriving from the database is loaded with `setContent` and
363
+ `emitUpdate: false`** (Tiptap 3 emits by default; Tiptap 2 did not), and
364
+ the helper also compares the incoming JSON with the editor's own, so a live
365
+ query delivering the note the app just saved is a no-op (measured: loading
366
+ a document produced zero change events, loading the same one again produced
367
+ none, typing after it produced one). The app decides what counts as new: a
368
+ different `_id` is a switch and loads at once; the same `_id` with a
369
+ different `writeId` is another device's save, and it loads only when
370
+ nothing here is waiting to save (`load` returns `false` while a pause's
371
+ words are pending, and the effect tries again on the next delivery). A
372
+ save is recognised by the `writeId` it carried out and back, which holds
373
+ whatever the two devices' clocks say. Words typed here land first; theirs
374
+ arrive on the round trip after, which is the honest order for one person on
375
+ two devices.
376
+
377
+ ## Destroy the editor in the effect's cleanup
378
+
379
+ **Create the editor once in `useEffect(() => {…}, [])` and destroy the
380
+ editor in the effect's cleanup**, flushing first so the last pause's words
381
+ land. An editor that outlives its host keeps ProseMirror listeners on a
382
+ detached node and keeps the debounce timer alive; a remount without a
383
+ destroy is two editors on one element. The one editor serves every note:
384
+ the open note travels through a ref, and only the content is swapped.
385
+
386
+ ## Styling the page
387
+
388
+ The editor renders real elements (`h1`, `p`, `ul`, `li[data-type=taskItem]`)
389
+ inside a `.tiptap` root with no styles of its own, and a multi-file app
390
+ ships no stylesheet, so **the host element's `className` styles the page
391
+ through Tailwind's arbitrary variants**: `[&_h1]:text-2xl`,
392
+ `[&_ul]:list-disc`, `[&_a]:underline`, and the placeholder through
393
+ `[&_.is-editor-empty:first-child::before]:content-[attr(data-placeholder)]`.
394
+ The JSX stays class-only and the theme tokens (`text-primary`,
395
+ `text-muted-foreground`, `bg-muted`) carry the owner's restyle into the
396
+ written page. Measured: the pouf lint passes a host carrying these classes.
397
+
398
+ ## Commands and the toolbar
399
+
400
+ `surface.run("bold")`, `run("heading", 2)`, `run("tasks")`,
401
+ `run("link", href)`, `run("image", src)`, `run("undo")`: the toolbar is
402
+ buttons calling `run`, and `surface.isActive("bold")` colours the one that
403
+ applies at the cursor. Tiptap's own shortcuts work out of the box (`Mod+B`,
404
+ `Mod+I`, `Mod+Shift+8` for a bullet list, `#` then space for a heading,
405
+ `[ ]` then space for a task, `Mod+Z`). A global shortcut of the app's own
406
+ checks `event.target.isContentEditable` first, so typing stays typing. An
407
+ image is `run("image", url)` with a URL from the note's `_files` after a
408
+ save (`doc._files.photo.url`, the stable platform-minted address), so a
409
+ picture is bytes on the document rather than a base64 string in the JSON.
410
+
411
+ ## A shared page
412
+
413
+ ### `lib/editor.js`
414
+ ```js app=page
415
+ // The same helper module as the notebook, kept once per app; shown here in the part the shared
416
+ // page uses. Every Tiptap package is pinned to one version.
417
+ import { Editor } from "https://esm.sh/@tiptap/core@3.31.4";
418
+ import StarterKit from "https://esm.sh/@tiptap/starter-kit@3.31.4";
419
+ import Placeholder from "https://esm.sh/@tiptap/extension-placeholder@3.31.4";
420
+
421
+ export const EMPTY_DOC = { type: "doc", content: [{ type: "paragraph" }] };
422
+
423
+ export function createEditor(host, { content = EMPTY_DOC, placeholder = "Start writing…", editable = true, onChange, debounceMs = 600 } = {}) {
424
+ let timer = 0;
425
+ let loading = false;
426
+ const editor = new Editor({
427
+ element: host,
428
+ editable,
429
+ content,
430
+ extensions: [StarterKit, Placeholder.configure({ placeholder })],
431
+ onUpdate: ({ transaction }) => {
432
+ if (loading || !transaction.docChanged || !onChange) return;
433
+ clearTimeout(timer);
434
+ timer = setTimeout(() => {
435
+ timer = 0; // the pause has landed; flush has nothing left to send
436
+ onChange(editor.getJSON(), editor.getText());
437
+ }, debounceMs);
438
+ },
439
+ });
440
+ return {
441
+ editor,
442
+ load(json, { force = false } = {}) {
443
+ if (JSON.stringify(json) === JSON.stringify(editor.getJSON())) return true;
444
+ if (timer && !force) return false; // words are waiting to save: the person's keystrokes win
445
+ clearTimeout(timer);
446
+ timer = 0;
447
+ loading = true;
448
+ editor.commands.setContent(json ?? EMPTY_DOC, { emitUpdate: false });
449
+ loading = false;
450
+ return true;
451
+ },
452
+ /** True while a pause's words are still waiting to be saved. */
453
+ isDirty() {
454
+ return timer !== 0;
455
+ },
456
+ flush() {
457
+ if (!timer) return;
458
+ clearTimeout(timer);
459
+ timer = 0;
460
+ onChange?.(editor.getJSON(), editor.getText());
461
+ },
462
+ setEditable(on) {
463
+ editor.setEditable(on);
464
+ },
465
+ destroy() {
466
+ clearTimeout(timer);
467
+ editor.destroy();
468
+ },
469
+ };
470
+ }
471
+ ```
472
+
473
+ ### `App.jsx`
474
+ ```jsx app=page
475
+ import React, { useEffect, useRef, useState } from "react";
476
+ import { useFireproof } from "use-fireproof";
477
+ import { useVibe, useViewer } from "use-vibes";
478
+ import { Badge, Button, Card, Heading, Row, Spacer, Stack, Text } from "@vibes.diy/pouf";
479
+ import { EMPTY_DOC, createEditor } from "./lib/editor.js";
480
+
481
+ const PAGE_ID = "page:home";
482
+ const PAGE =
483
+ "min-h-64 [&_.tiptap]:outline-none [&_h1]:text-2xl [&_h1]:font-bold [&_h2]:text-xl [&_h2]:font-semibold [&_p]:my-2 [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6 " +
484
+ "[&_.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.is-editor-empty:first-child::before]:text-muted-foreground [&_.is-editor-empty:first-child::before]:float-left";
485
+
486
+ export default function App() {
487
+ const { database, useDocument } = useFireproof("teampage");
488
+ const { me, can, ready } = useVibe("teampage");
489
+ const { requestLogin, ViewerTag } = useViewer();
490
+ const { doc: page } = useDocument({ _id: PAGE_ID, type: "page", body: EMPTY_DOC, text: "" });
491
+ const [saved, setSaved] = useState("idle");
492
+ const hostRef = useRef(null);
493
+ const surfaceRef = useRef(null);
494
+ const pageRef = useRef(page); // the latest page and identity, read by the save callback
495
+ const meRef = useRef(me);
496
+ const lastWriteRef = useRef(null);
497
+ pageRef.current = page;
498
+ meRef.current = me;
499
+
500
+ useEffect(() => {
501
+ const surface = createEditor(hostRef.current, {
502
+ placeholder: "The team's page. Anyone signed in can write here.",
503
+ onChange: async (json, text) => {
504
+ const writeId = crypto.randomUUID();
505
+ lastWriteRef.current = writeId;
506
+ await database.put({ ...pageRef.current, _id: PAGE_ID, type: "page", body: json, text, updatedAt: Date.now(), writeId, editedBy: meRef.current?.userHandle });
507
+ setSaved("saved");
508
+ },
509
+ });
510
+ surface.editor.on("update", () => setSaved("typing"));
511
+ surfaceRef.current = surface;
512
+ return () => {
513
+ surface.flush();
514
+ surface.destroy();
515
+ surfaceRef.current = null;
516
+ };
517
+ }, []);
518
+
519
+ // Someone else's save lands here when nothing of ours is waiting; our own save (the writeId we
520
+ // sent) is left alone.
521
+ useEffect(() => {
522
+ const surface = surfaceRef.current;
523
+ if (!surface || !page) return;
524
+ if (page.writeId && page.writeId !== lastWriteRef.current) surface.load(page.body ?? EMPTY_DOC);
525
+ surface.setEditable(Boolean(ready && me && can.edit(page).ok));
526
+ }, [page, ready, me]);
527
+
528
+ return (
529
+ <main className="min-h-screen bg-background text-foreground p-4 md:p-8">
530
+ <Stack gap={4}>
531
+ <Row gap={2} align="center" wrap>
532
+ <Heading level={1}>Team page</Heading>
533
+ <Spacer />
534
+ {page?.editedBy && <Row gap={1} align="center"><Text size="sm" muted>last edited by</Text><ViewerTag userHandle={page.editedBy} /></Row>}
535
+ <Badge tone={saved === "saved" ? "up" : "idle"}>{saved === "typing" ? "Typing…" : saved === "saved" ? "Saved" : ready && me ? "Ready" : "Read only"}</Badge>
536
+ </Row>
537
+ {ready && !me && <Row gap={2} align="center" wrap><Text muted>Sign in to write on the page.</Text><Button size="sm" variant="quiet" onClick={requestLogin}>Sign in</Button></Row>}
538
+ <Card variant="tight">
539
+ <div ref={hostRef} className={PAGE} onBlur={() => surfaceRef.current?.flush()} />
540
+ </Card>
541
+ </Stack>
542
+ </main>
543
+ );
544
+ }
545
+ ```
546
+
547
+ ### `access.js`
548
+ ```js app=page
549
+ // page: the one shared page. Everyone reads it; anyone signed in writes it and is named as the editor.
550
+ export function teampage(doc, oldDoc, user, ctx) {
551
+ if (doc.type === "page" || (oldDoc && oldDoc.type === "page")) {
552
+ if (!user) throw { forbidden: "sign in to write on the page" };
553
+ if (doc.editedBy !== user.userHandle) throw { forbidden: "a page edit is signed as yourself" };
554
+ return { channels: ["page"], grant: { public: ["page"] } };
555
+ }
556
+ throw { forbidden: "unknown document type" };
557
+ }
558
+ ```
559
+
560
+ A shared page is last-writer-wins at the pause: two people typing in the
561
+ same minute each save their whole document, and the later save replaces
562
+ the earlier one, with the other person's editor loading it on the next
563
+ tick. That is honest for a team page edited in turns; say so in the app's
564
+ copy ("saves as you pause; one writer at a time lands"), and for true
565
+ simultaneous editing keep one document per section so two people on
566
+ different sections land independently.
567
+
568
+ ## Mobile
569
+
570
+ The editor is a plain `contenteditable`, so the phone keyboard works as it
571
+ does anywhere; keep the toolbar as `IconButton`s in a row that wraps under
572
+ the page, and the page itself full width. Tiptap's input rules (`#` for a
573
+ heading, `-` for a list, `[ ]` for a task) matter more on a phone than the
574
+ toolbar does. Flush on blur, because a phone backgrounds the tab without
575
+ warning.
576
+
577
+ ## Shapes a rich text vibe takes
578
+
579
+ - **A notebook** — a list of notes and one editor, each note its own
580
+ document, saved as you pause. The starter.
581
+ - **A journal** — one note per day, `_id: "day:YYYY-MM-DD"`, a calendar
582
+ strip picking the day, yesterday's page one tap away.
583
+ - **A shared page or wiki** — one document per page, everyone reads,
584
+ signed-in people write, last editor named. The second example; several
585
+ pages are several `_id`s with a page list.
586
+ - **Meeting notes with action items** — a checklist in the page, and a
587
+ query over `text` for lines that start with a box, listed in a sidebar.
588
+ - **A recipe book** — a page per recipe with a heading, an ingredients
589
+ checklist and numbered steps, the list filtered by `text`.
590
+ - **A writing app with an AI pass** — `callAI` over `text` for a summary or
591
+ a continuation, inserted with `editor.commands.insertContent` at the end.
592
+
593
+ ## Gotchas
594
+
595
+ - **The editor throws on the first key, about a keyed plugin** — two
596
+ copies of ProseMirror, from Tiptap packages at different versions. One
597
+ version across every import.
598
+ - **Every keystroke writes to the database** — the save sits in
599
+ `onUpdate`. The debounce and `flush`.
600
+ - **Opening a note saves it again, forever** — `setContent` emitted an
601
+ update (Tiptap 3's default) and the save loaded it back. `emitUpdate:
602
+ false` in `load`.
603
+ - **The list shows "Untitled" for every note** — the title is waiting to be
604
+ derived. `titleOf(json)` on every save.
605
+ - **Typing is lost when switching notes** — the debounce had not fired.
606
+ `flush()` before changing the open note.
607
+ - **Two editors after a hot reload** — the effect's cleanup did not destroy
608
+ the editor.
609
+ - **The page has no styles** — Tiptap ships none, and a multi-file app
610
+ ships no stylesheet. The host's arbitrary-variant classes.
611
+ - **A pasted image makes the document huge** — a base64 image in the JSON.
612
+ Bytes go in `_files`; the JSON holds the URL.
package/llms/strudel.md CHANGED
@@ -31,8 +31,14 @@ the platform rewrites bare specifiers only in the top-of-file import region,
31
31
  so a lazy import carries the complete versioned URL. Laziness is the point —
32
32
  first paint owes nothing to a 165 KB engine plus a drum kit, and the
33
33
  AudioContext wants a user gesture anyway, which the play button is.
34
- `initStrudel` installs its own first-click unlock, so the loader running
35
- inside the click handler is the whole iOS story.
34
+ **The loader makes the `AudioContext` itself, synchronously, before its
35
+ first `await`**, resumes it, and hands it to the engine with
36
+ `setAudioContext` ahead of `initStrudel`. The engine's own first-click
37
+ unlock is registered only once the download finishes, by which time the
38
+ press that started it has passed; a context made and resumed inside that
39
+ press is what iOS asks of sound (measured 2026-10-07: the handed-in context
40
+ is the one the engine plays through, `running`, events firing after a
41
+ 1.7 s load).
36
42
 
37
43
  Strudel is AGPL-licensed. The app links to the engine at its own address the
38
44
  way a browser links to any page, so the two programs stay separate:
@@ -78,12 +84,17 @@ const DRUM_KIT_URL = "https://cdn.jsdelivr.net/gh/tidalcycles/Dirt-Samples@maste
78
84
  const DRUM_MACHINES_URL = "https://cdn.jsdelivr.net/gh/felixroos/dough-samples@main/tidal-drum-machines.json";
79
85
 
80
86
  let enginePromise;
87
+ let audioContext;
81
88
  const listeners = new Set();
82
89
 
83
90
  /** One fetch of the engine and the drum kit, however many times play is pressed. */
84
91
  export function loadStrudel() {
85
92
  enginePromise ??= (async () => {
93
+ // Runs inside the press that called play(): the context is made and resumed in the gesture.
94
+ audioContext = new AudioContext();
95
+ audioContext.resume();
86
96
  const web = await import(ENGINE_URL);
97
+ web.setAudioContext(audioContext);
87
98
  const repl = await web.initStrudel({
88
99
  prebake: () => Promise.all([web.samples(DRUM_KIT_URL), web.samples(DRUM_MACHINES_URL)]),
89
100
  onEvalError: (error) => {
@@ -107,6 +118,8 @@ export function onPatternError(fn) {
107
118
  * the `false` keeps the engine's own sound output in place beside the callback.
108
119
  */
109
120
  export async function play(code, onEvent) {
121
+ // Still inside the press: a context the browser suspended while the tab was away resumes here.
122
+ if (audioContext?.state === "suspended") audioContext.resume();
110
123
  const { web, repl } = await loadStrudel();
111
124
  const pattern = await web.evaluate(code, false);
112
125
  if (!pattern) return null;
@@ -219,10 +232,11 @@ export default function App() {
219
232
 
220
233
  async function handlePlay(source = code) {
221
234
  setProblem(null);
222
- if (state === "idle") setState("loading");
235
+ const wasPlaying = state === "playing"; // read once: `state` is this render's value
236
+ if (!wasPlaying) setState("loading");
223
237
  const pattern = await play(source);
224
238
  if (pattern) setState("playing");
225
- else if (state === "loading") setState("idle");
239
+ else if (!wasPlaying) setState("idle");
226
240
  }
227
241
 
228
242
  async function handleStop() {
@@ -636,8 +650,8 @@ the kits: `samples({ kick: "https://…/kick.wav", snare: "…" })` inside
636
650
 
637
651
  Play is a large button near the top; nothing sounds until it is pressed. A
638
652
  grid's steps are `IconButton`s whose label names the row, the step and its
639
- state, and a tempo slider sits beside play rather than in a settings drawer. The engine's own
640
- first-click unlock covers iOS as long as the loader runs inside the tap.
653
+ state, and a tempo slider sits beside play rather than in a settings drawer. The loader makes
654
+ and resumes the audio context inside the tap, which is what iOS asks of sound.
641
655
 
642
656
  ## Shapes a Strudel vibe takes
643
657