@vibes.diy/prompts 14.3.82 → 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 @@
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"}
@@ -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.
@@ -0,0 +1,2 @@
1
+ import type { LlmConfig } from "./types.js";
2
+ export declare const strudelConfig: LlmConfig;
@@ -0,0 +1,36 @@
1
+ export const strudelConfig = {
2
+ name: "strudel",
3
+ label: "Strudel live coding",
4
+ description: "Live-coding music with Strudel, where the music is a pattern written as text: a pattern pad " +
5
+ "with an editor, examples, play and stop and saved patterns; a groovebox whose pads write " +
6
+ "mini-notation and whose lights follow the engine's events; knob instruments that re-evaluate " +
7
+ "a pattern as sliders move; generative radios; a prompt box that asks callAI to rewrite the " +
8
+ "groove; a shared groovebook with an access rule. Covers the AGPL engine fetched at play time " +
9
+ "by a pinned esm.sh dynamic import in a memoized loader (never bundled, credited visibly), the " +
10
+ "Dirt-Samples kit and the drum-machine banks from jsDelivr, mini-notation and the effect chain, " +
11
+ "the verbatim text-to-pattern system prompt for callAI, cpm as cycles per minute with the " +
12
+ "beats-per-cycle table, " +
13
+ "onEvalError for patterns that did not compile, hush on stop, and onTrigger for visuals. " +
14
+ "strudel, tidal, tidalcycles, live coding, livecoding, algorave, mini-notation, pattern music, " +
15
+ "algorithmic music, generative music, text patterns, code music",
16
+ cues: [
17
+ "strudel",
18
+ "tidal",
19
+ "tidalcycles",
20
+ "tidal cycles",
21
+ "live coding",
22
+ "live-coding",
23
+ "livecoding",
24
+ "live code music",
25
+ "algorave",
26
+ "mini-notation",
27
+ "mininotation",
28
+ "pattern music",
29
+ "algorithmic music",
30
+ "generative music",
31
+ "code music",
32
+ "music from code",
33
+ "text patterns",
34
+ ],
35
+ };
36
+ //# sourceMappingURL=strudel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"strudel.js","sourceRoot":"","sources":["../../jsr/llms/strudel.ts"],"names":[],"mappings":"AASA,MAAM,CAAC,MAAM,aAAa,GAAc;IACtC,IAAI,EAAE,SAAS;IACf,KAAK,EAAE,qBAAqB;IAC5B,WAAW,EACT,8FAA8F;QAC9F,2FAA2F;QAC3F,+FAA+F;QAC/F,6FAA6F;QAC7F,+FAA+F;QAC/F,gGAAgG;QAChG,iGAAiG;QACjG,2FAA2F;QAC3F,yBAAyB;QACzB,0FAA0F;QAC1F,gGAAgG;QAChG,gEAAgE;IAClE,IAAI,EAAE;QACJ,SAAS;QACT,OAAO;QACP,aAAa;QACb,cAAc;QACd,aAAa;QACb,aAAa;QACb,YAAY;QACZ,iBAAiB;QACjB,UAAU;QACV,eAAe;QACf,cAAc;QACd,eAAe;QACf,mBAAmB;QACnB,kBAAkB;QAClB,YAAY;QACZ,iBAAiB;QACjB,eAAe;KAChB;CACF,CAAC"}