@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.
- package/llms/access.md +105 -15
- package/llms/blockly.d.ts +2 -0
- package/llms/blockly.js +59 -0
- package/llms/blockly.js.map +1 -0
- package/llms/blockly.md +964 -0
- package/llms/fireproof.md +4 -4
- package/llms/index.d.ts +7 -1
- package/llms/index.js +18 -0
- package/llms/index.js.map +1 -1
- package/llms/motion.d.ts +2 -0
- package/llms/motion.js +47 -0
- package/llms/motion.js.map +1 -0
- package/llms/motion.md +601 -0
- package/llms/physics.d.ts +2 -0
- package/llms/physics.js +44 -0
- package/llms/physics.js.map +1 -0
- package/llms/physics.md +635 -0
- package/llms/richtext.d.ts +2 -0
- package/llms/richtext.js +47 -0
- package/llms/richtext.js.map +1 -0
- package/llms/richtext.md +612 -0
- package/llms/strudel.d.ts +2 -0
- package/llms/strudel.js +36 -0
- package/llms/strudel.js.map +1 -0
- package/llms/strudel.md +686 -0
- package/llms/web-audio.md +2 -0
- package/llms/worlds.d.ts +2 -0
- package/llms/worlds.js +45 -0
- package/llms/worlds.js.map +1 -0
- package/llms/worlds.md +826 -0
- package/package.json +5 -4
- package/restyle.js +3 -2
- package/restyle.js.map +1 -1
- package/system-prompt-initial-oneshot.md +3 -2
- package/system-prompt-initial.md +3 -2
- package/system-prompt.md +6 -3
|
@@ -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"}
|
package/llms/richtext.md
ADDED
|
@@ -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.js
ADDED
|
@@ -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"}
|