@vibes.diy/prompts 14.3.79 → 14.3.81

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.
Files changed (42) hide show
  1. package/llms/access.md +120 -85
  2. package/llms/backend.js +2 -2
  3. package/llms/backend.js.map +1 -1
  4. package/llms/backend.md +51 -43
  5. package/llms/bluesky.md +16 -14
  6. package/llms/board-games.d.ts +2 -0
  7. package/llms/board-games.js +29 -0
  8. package/llms/board-games.js.map +1 -0
  9. package/llms/board-games.md +477 -0
  10. package/llms/calendar.js +19 -2
  11. package/llms/calendar.js.map +1 -1
  12. package/llms/calendar.md +119 -1
  13. package/llms/callai.md +91 -20
  14. package/llms/connections.md +5 -4
  15. package/llms/create-vibe.md +5 -4
  16. package/llms/fireproof.md +261 -109
  17. package/llms/image-gen.md +44 -27
  18. package/llms/index.d.ts +2 -1
  19. package/llms/index.js +3 -0
  20. package/llms/index.js.map +1 -1
  21. package/llms/p5.md +9 -11
  22. package/llms/spotify.md +5 -2
  23. package/llms/three-js.md +163 -166
  24. package/llms/use-vibe.md +30 -8
  25. package/llms/use-viewer.md +61 -45
  26. package/llms/voxel.md +32 -37
  27. package/llms/youtube.md +5 -2
  28. package/package.json +4 -4
  29. package/prompts.d.ts +4 -2
  30. package/prompts.js +47 -24
  31. package/prompts.js.map +1 -1
  32. package/system-prompt-initial-oneshot.md +15 -12
  33. package/system-prompt-initial.md +14 -11
  34. package/system-prompt.md +36 -54
  35. package/themes/colorsets.js +18 -2
  36. package/themes/colorsets.js.map +1 -1
  37. package/themes/looks/pouf/brief.md +65 -47
  38. package/themes/looks/pouf/catalog.generated.d.ts +1 -1
  39. package/themes/looks/pouf/catalog.generated.js +103 -10
  40. package/themes/looks/pouf/catalog.generated.js.map +1 -1
  41. package/themes/looks/pouf/variety.js +2 -2
  42. package/themes/looks/pouf/variety.js.map +1 -1
package/llms/fireproof.md CHANGED
@@ -29,14 +29,17 @@ When the server later refuses a write the visitor can act on — a signed-in vis
29
29
  function NoteForm({ text }) {
30
30
  const { database, writeRejection, clearWriteRejection } = useFireproof("notes");
31
31
  return (
32
- <div>
33
- <button onClick={() => database.put({ type: "note", text })}>Save</button>
32
+ <Stack gap={2}>
33
+ <Row>
34
+ <Button type="button" onClick={() => database.put({ type: "note", text })}>Save</Button>
35
+ </Row>
34
36
  {writeRejection && (
35
- <p role="status">
36
- {writeRejection.message} <button onClick={clearWriteRejection}>Dismiss</button>
37
- </p>
37
+ <Row gap={2} align="center" wrap>
38
+ <p role="status">{writeRejection.message}</p>
39
+ <Button type="button" size="sm" variant="quiet" onClick={clearWriteRejection}>Dismiss</Button>
40
+ </Row>
38
41
  )}
39
- </div>
42
+ </Stack>
40
43
  );
41
44
  }
42
45
  ```
@@ -53,6 +56,7 @@ import React, { useState } from "react";
53
56
  import { callAI } from "call-ai";
54
57
  import { useFireproof } from "use-fireproof";
55
58
  import { useVibe } from "use-vibes";
59
+ import { Button, Field, Heading, Input, Row, RowCard, Stack, Text, Textarea } from "@vibes.diy/pouf";
56
60
 
57
61
  const taskSchema = {
58
62
  type: "object",
@@ -100,19 +104,23 @@ function TaskCard({ task, database, can, onCorrect, onSaved, disabled }) {
100
104
  const { useDocument } = useFireproof(database);
101
105
  const { doc, merge, save } = useDocument(task);
102
106
  const [editing, setEditing] = useState(false);
103
- return <article>
104
- <h2>{task.title}</h2><p>{task.details}</p><p>{task.dueDate}</p>
107
+ return <RowCard><Stack gap={2}>
108
+ <Text>{task.title}</Text><Text muted>{task.details}</Text><Text muted>{task.dueDate}</Text>
105
109
  {can.edit(task).ok && <>
106
- <button type="button" disabled={disabled} onClick={() => onCorrect(task)}>Change by sentence</button>
107
- <button type="button" disabled={disabled} onClick={() => setEditing(true)}>Edit details</button>
110
+ <Row gap={2} wrap>
111
+ <Button type="button" size="sm" variant="quiet" disabled={disabled} onClick={() => onCorrect(task)}>Change by sentence</Button>
112
+ <Button type="button" size="sm" variant="quiet" disabled={disabled} onClick={() => setEditing(true)}>Edit details</Button>
113
+ </Row>
108
114
  {editing && <form onSubmit={async (event) => { event.preventDefault(); if (disabled) return; await save(); onSaved(task); setEditing(false); }}>
109
- <input disabled={disabled} aria-label="Task" value={doc.title} onChange={(event) => merge({ title: event.target.value })} />
110
- <textarea disabled={disabled} aria-label="Details" value={doc.details} onChange={(event) => merge({ details: event.target.value })} />
111
- <input disabled={disabled} aria-label="Due date" type="date" value={doc.dueDate} onChange={(event) => merge({ dueDate: event.target.value })} />
112
- <button type="submit" disabled={disabled}>Save details</button>
115
+ <Stack gap={3}>
116
+ <Field label="Task">{(id, describedBy) => <Input id={id} describedBy={describedBy} disabled={disabled} value={doc.title} onChange={(title) => merge({ title })} />}</Field>
117
+ <Field label="Details">{(id, describedBy) => <Textarea id={id} describedBy={describedBy} disabled={disabled} value={doc.details} onChange={(details) => merge({ details })} />}</Field>
118
+ <Field label="Due date">{(id, describedBy) => <Input id={id} describedBy={describedBy} disabled={disabled} type="date" value={doc.dueDate} onChange={(dueDate) => merge({ dueDate })} />}</Field>
119
+ <Row><Button type="submit" size="sm" variant="quiet" disabled={disabled}>Save details</Button></Row>
120
+ </Stack>
113
121
  </form>}
114
122
  </>}
115
- </article>;
123
+ </Stack></RowCard>;
116
124
  }
117
125
 
118
126
  export default function App() {
@@ -188,21 +196,27 @@ export default function App() {
188
196
  setMessage(error.message);
189
197
  } finally { setBusy(false); }
190
198
  }
191
- return <main>
192
- <h1>Tasks in your own words</h1>
199
+ return <main className="min-h-screen bg-background text-foreground font-sans p-6"><Stack gap={5}>
200
+ <Heading level={1}>Tasks in your own words</Heading>
193
201
  <form onSubmit={submitSentence}>
194
- <textarea aria-label="Describe tasks, a correction, or ask a question" value={sentence} disabled={busy || pending !== undefined} onChange={(event) => setSentence(event.target.value)} />
195
- {current !== undefined && <p>Changing: {current.title} <button type="button" disabled={busy || pending !== undefined} onClick={() => { setCurrent(undefined); setSentence(""); }}>Add a new task</button></p>}
196
- <button type="submit" disabled={!ready || busy || sentence.trim().length === 0}>{busy ? "Adding your changes…" : pending !== undefined ? "Retry remaining tasks" : current === undefined ? "Add tasks" : "Apply correction"}</button>
197
- <button type="button" disabled={!ready || busy || pending !== undefined || sentence.trim().length === 0} onClick={askRecords}>Ask about tasks</button>
202
+ <Stack gap={3}>
203
+ <Field label="Describe tasks, a correction, or ask a question">{(id, describedBy) => <Textarea id={id} describedBy={describedBy} value={sentence} disabled={busy || pending !== undefined} onChange={setSentence} />}</Field>
204
+ {current !== undefined && <Row gap={2} align="center" wrap><Text muted>Changing: {current.title}</Text><Button type="button" size="sm" variant="quiet" disabled={busy || pending !== undefined} onClick={() => { setCurrent(undefined); setSentence(""); }}>Add a new task</Button></Row>}
205
+ <Row gap={2} wrap>
206
+ <Button type="submit" disabled={!ready || busy || sentence.trim().length === 0}>{busy ? "Adding your changes…" : pending !== undefined ? "Retry remaining tasks" : current === undefined ? "Add tasks" : "Apply correction"}</Button>
207
+ <Button type="button" variant="quiet" disabled={!ready || busy || pending !== undefined || sentence.trim().length === 0} onClick={askRecords}>Ask about tasks</Button>
208
+ </Row>
209
+ </Stack>
198
210
  </form>
199
- <p>{answer}</p>
211
+ <Text>{answer}</Text>
200
212
  <p role="status">{message}</p>
201
- {writeRejection && <p role="status">{writeRejection.message} <button type="button" onClick={clearWriteRejection}>Dismiss</button></p>}
202
- {lastWrite !== undefined && <button type="button" disabled={busy} onClick={undoLast}>Undo</button>}
203
- {pending !== undefined && !busy && <button type="button" onClick={() => { setPending(undefined); setSentence(""); setCurrent(undefined); }}>Discard unsaved tasks</button>}
213
+ {writeRejection && <Row gap={2} align="center" wrap><p role="status">{writeRejection.message}</p><Button type="button" size="sm" variant="quiet" onClick={clearWriteRejection}>Dismiss</Button></Row>}
214
+ <Row gap={2} wrap>
215
+ {lastWrite !== undefined && <Button type="button" size="sm" variant="quiet" disabled={busy} onClick={undoLast}>Undo</Button>}
216
+ {pending !== undefined && !busy && <Button type="button" size="sm" variant="quiet" tone="down" onClick={() => { setPending(undefined); setSentence(""); setCurrent(undefined); }}>Discard unsaved tasks</Button>}
217
+ </Row>
204
218
  {docs.map((task) => <TaskCard key={task._id} task={task} database={database} can={can} disabled={busy || pending !== undefined} onSaved={(previous) => setLastWrite({ created: [], replaced: previous })} onCorrect={(doc) => { if (busy || pending !== undefined) return; setCurrent(doc); setSentence(""); }} />)}
205
- </main>;
219
+ </Stack></main>;
206
220
  }
207
221
  ```
208
222
 
@@ -337,9 +351,15 @@ To update an existing document from a click handler or callback, use `database.p
337
351
  =======
338
352
  <li key={doc._id}>
339
353
  {doc.text}
340
- <button onClick={() => database.put({ ...doc, favorite: !doc.favorite })}>
341
- {doc.favorite ? "★" : "☆"}
342
- </button>
354
+ <IconButton
355
+ size="sm"
356
+ variant={doc.favorite ? "solid" : "quiet"}
357
+ tone="warn"
358
+ aria-pressed={Boolean(doc.favorite)}
359
+ label={doc.favorite ? "Unfavorite" : "Favorite"}
360
+ icon={<Icon name="star" size="sm" />}
361
+ onClick={() => database.put({ ...doc, favorite: !doc.favorite })}
362
+ />
343
363
  </li>
344
364
  >>>>>>> REPLACE
345
365
  ```
@@ -348,7 +368,17 @@ Never call hooks inside handlers — `const { doc, save } = useDocument({ _id: i
348
368
 
349
369
  ### Continuous Controls — `merge()` on every event, `save()` once on commit
350
370
 
351
- A continuous control (slider, drag, color picker, live-typed text bound to one doc) fires many events in quick succession. **Never call `database.put()` or `save()` on every `onChange`** — that floods the server with rapid concurrent writes to the same `_id`, which collide on the per-document sequence and get rejected (`Failed to put document …`). Instead, `merge()` locally on each event to keep the UI live, and `save()` once when the interaction commits (`onPointerUp`, `onBlur`, `onChange` for a range input's final value, or a debounced trailing call). Editing a single doc with a slider:
371
+ A continuous control (slider, drag, color picker, live-typed text bound to one doc) fires many events in quick succession. **Never call `database.put()` or `save()` on every `onChange`** — that floods the server with rapid concurrent writes to the same `_id`, which collide on the per-document sequence and get rejected (`Failed to put document …`). Instead, `merge()` on each event to keep the UI live (it updates the local draft and, when the doc has an `_id`, broadcasts the merged snapshot to connected peers, but persists nothing), and `save()` once when the interaction commits (`onPointerUp`, `onBlur`, or a debounced trailing call; a `Slider`'s `onChange` fires on every step, so it is not a commit, and the wrapper around it hears the release). Editing a single doc with a slider:
372
+
373
+ ### `App.jsx`
374
+ ```jsx
375
+ <<<<<<< SEARCH
376
+ import { useFireproof } from "use-fireproof";
377
+ =======
378
+ import { useFireproof } from "use-fireproof";
379
+ import { Slider } from "@vibes.diy/pouf/interactive";
380
+ >>>>>>> REPLACE
381
+ ```
352
382
 
353
383
  ### `App.jsx`
354
384
  ```jsx
@@ -359,6 +389,7 @@ A continuous control (slider, drag, color picker, live-typed text bound to one d
359
389
 
360
390
  // One shared mix doc; slider updates the UI on every event, persists on commit
361
391
  const { doc: mix, merge: mergeMix, save: saveMix } = useDocument({ _id: "mix:current", level: 50 });
392
+ const SLIDER_KEYS = new Set(["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown"]);
362
393
  >>>>>>> REPLACE
363
394
  ```
364
395
 
@@ -367,14 +398,16 @@ A continuous control (slider, drag, color picker, live-typed text bound to one d
367
398
  <<<<<<< SEARCH
368
399
  <h3>Recent Documents</h3>
369
400
  =======
370
- <input
371
- type="range"
372
- min="0"
373
- max="100"
374
- value={mix.level}
375
- onChange={(e) => mergeMix({ level: Number(e.target.value) })} // live, no write
401
+ <div
376
402
  onPointerUp={() => saveMix().catch((err) => console.error("save failed", err))} // one write on commit
377
- />
403
+ onKeyUp={(e) => {
404
+ // only the keys that move the slider commit; Tab or focusing it writes nothing
405
+ if (SLIDER_KEYS.has(e.key)) saveMix().catch((err) => console.error("save failed", err));
406
+ }}
407
+ >
408
+ {/* live, no write */}
409
+ <Slider label="Level" min={0} max={100} value={[mix.level]} onChange={([level]) => mergeMix({ level })} />
410
+ </div>
378
411
 
379
412
  <h3>Recent Documents</h3>
380
413
  >>>>>>> REPLACE
@@ -382,6 +415,127 @@ A continuous control (slider, drag, color picker, live-typed text bound to one d
382
415
 
383
416
  If you genuinely need to persist mid-drag, debounce the `save()` so at most one write is in flight, and still `.catch()` the rejection. Better yet, prefer one document per event (see the Counter Pattern) over hammering a single hot document.
384
417
 
418
+ ### Strokes — `merge()` the gesture live, save its cells once on pointer-up
419
+
420
+ A drag that touches many documents (painting pixels, drawing on a shared board) splits the same way: **the stroke is live, the cells are durable.** Keep the in-progress stroke in a per-person draft (`_id: "stroke:" + handle`) and `merge()` each new cell into it on `onPointerMove`; peers see it as it is drawn, because `merge()` broadcasts the draft and their `useLiveQuery` returns it as a row. On `onPointerUp`, `put()` one document per touched cell, then `merge()` the stroke back to empty. Never `put()` from `onPointerMove`: one quick drag becomes dozens of writes in flight.
421
+
422
+ Keep each cell its own document with a deterministic `_id` (`pixel-<x>-<y>`). Never fold the canvas into one document every visitor saves: the server keeps whichever save arrives last, so one visitor's save erases everyone else's strokes. An unsaved draft drops out for peers about 12 seconds after it stops updating.
423
+
424
+ A draft reaches only the peers already subscribed to its channel, and a channel's grant comes from a saved document. Create a fixed-id room record on that channel (see Required starting records) so even the first stroke on an empty canvas is seen live.
425
+
426
+ ### `App.jsx`
427
+ ```jsx
428
+ import React, { useEffect, useRef } from "react";
429
+ import { useFireproof } from "use-fireproof";
430
+ import { useViewer, useVibe } from "use-vibes";
431
+ import { Button } from "@vibes.diy/pouf";
432
+
433
+ const SIZE = 32;
434
+ const CELL = 16;
435
+ const COLOR = "#3a86ff";
436
+
437
+ export default function App() {
438
+ const { viewer, isViewerPending, requestLogin } = useViewer();
439
+ if (isViewerPending) return null;
440
+ if (!viewer) return <Button type="button" onClick={requestLogin}>Sign in to paint</Button>;
441
+ return <Canvas key={viewer.userHandle} me={viewer.userHandle} />; // fixed draft _id per painter
442
+ }
443
+
444
+ function Canvas({ me }) {
445
+ // Many people write the same pixel docs: fail fast rather than replay a stale queued write.
446
+ const { database, useLiveQuery, useDocument, writeRejection } = useFireproof("pixels", { offlineQueue: false });
447
+ const { ready, can } = useVibe("pixels");
448
+ const canPaint = ready && can.create({ _id: "pixel-0-0", type: "pixel", x: 0, y: 0, color: COLOR, paintedBy: me }).ok;
449
+ const { docs: pixels } = useLiveQuery("type", { key: "pixel" });
450
+ const { docs: strokes } = useLiveQuery("type", { key: "stroke" });
451
+ const { doc: stroke, merge: mergeStroke } = useDocument({ _id: "stroke:" + me, type: "stroke", userHandle: me, color: COLOR, cells: [] });
452
+ const cellsRef = useRef([]);
453
+ const canvasRef = useRef(null);
454
+
455
+ useEffect(() => {
456
+ // The room record grants the channel, so peers subscribe before the first stroke.
457
+ const room = { _id: "canvas:main", type: "canvas" };
458
+ if (ready && can.create(room).ok) database.put(room);
459
+ }, [ready, can, database]);
460
+
461
+ useEffect(() => {
462
+ const ctx = canvasRef.current.getContext("2d");
463
+ ctx.fillStyle = "#ffffff";
464
+ ctx.fillRect(0, 0, SIZE * CELL, SIZE * CELL);
465
+ const fill = (x, y, color) => {
466
+ ctx.fillStyle = color;
467
+ ctx.fillRect(x * CELL, y * CELL, CELL, CELL);
468
+ };
469
+ for (const p of pixels) fill(p.x, p.y, p.color);
470
+ for (const s of strokes) if (s.userHandle !== me) for (const [x, y] of s.cells ?? []) fill(x, y, s.color);
471
+ for (const [x, y] of stroke.cells) fill(x, y, stroke.color);
472
+ }, [pixels, strokes, stroke, me]);
473
+
474
+ function extendStroke(e) {
475
+ if (!canPaint) return;
476
+ const r = canvasRef.current.getBoundingClientRect();
477
+ const x = Math.floor(((e.clientX - r.left) / r.width) * SIZE);
478
+ const y = Math.floor(((e.clientY - r.top) / r.height) * SIZE);
479
+ if (x < 0 || y < 0 || x >= SIZE || y >= SIZE) return;
480
+ if (cellsRef.current.some((c) => c[0] === x && c[1] === y)) return;
481
+ cellsRef.current = [...cellsRef.current, [x, y]];
482
+ mergeStroke({ cells: cellsRef.current }); // live for everyone, no write
483
+ }
484
+ async function endStroke() {
485
+ const cells = cellsRef.current;
486
+ cellsRef.current = [];
487
+ const writes = [];
488
+ for (const [x, y] of cells) writes.push(database.put({ _id: `pixel-${x}-${y}`, type: "pixel", x, y, color: COLOR, paintedBy: me }));
489
+ await Promise.all(writes); // the durable commit: one write per touched cell
490
+ if (cellsRef.current.length === 0) mergeStroke({ cells: [] }); // unless a newer stroke began
491
+ }
492
+
493
+ return (
494
+ <div>
495
+ {writeRejection && <p role="status">{writeRejection.message}</p>}
496
+ <canvas
497
+ ref={canvasRef}
498
+ width={SIZE * CELL}
499
+ height={SIZE * CELL}
500
+ className="w-full max-w-lg aspect-square touch-none"
501
+ onPointerDown={(e) => {
502
+ e.currentTarget.setPointerCapture(e.pointerId);
503
+ extendStroke(e);
504
+ }}
505
+ onPointerMove={(e) => e.buttons && extendStroke(e)}
506
+ onPointerUp={endStroke}
507
+ onPointerCancel={endStroke}
508
+ />
509
+ </div>
510
+ );
511
+ }
512
+ ```
513
+
514
+ The access rules see the broadcast draft too, so `stroke` needs its own branch that binds the `_id` to the sender:
515
+
516
+ ### `access.js`
517
+ ```js
518
+ export default function (doc, oldDoc, user, ctx) {
519
+ if (!user) throw { forbidden: "sign in to paint" };
520
+ const inGrid = (n) => Number.isInteger(n) && n >= 0 && n < 32;
521
+ if (doc.type === "canvas") {
522
+ if (doc._id !== "canvas:main") throw { forbidden: "one canvas" };
523
+ return { channels: ["canvas"], grant: { public: ["canvas"] } };
524
+ }
525
+ if (doc.type === "stroke") {
526
+ if (doc._id !== "stroke:" + user.userHandle || doc.userHandle !== user.userHandle) throw { forbidden: "not your stroke" };
527
+ if (!Array.isArray(doc.cells) || doc.cells.length > 1024 || !doc.cells.every((c) => Array.isArray(c) && c.length === 2 && inGrid(c[0]) && inGrid(c[1]))) throw { forbidden: "bad stroke" };
528
+ return { channels: ["canvas"], grant: { public: ["canvas"] } };
529
+ }
530
+ if (doc.type === "pixel") {
531
+ if (!inGrid(doc.x) || !inGrid(doc.y) || doc._id !== `pixel-${doc.x}-${doc.y}`) throw { forbidden: "pixel out of bounds" };
532
+ if (doc.paintedBy !== user.userHandle) throw { forbidden: "not your paint" };
533
+ return { channels: ["canvas"], grant: { public: ["canvas"] } };
534
+ }
535
+ throw { forbidden: "unknown document type" };
536
+ }
537
+ ```
538
+
385
539
  ### Query Data
386
540
 
387
541
  Data is queried by sorted indexes defined by the application. Sort by strings, numbers, or booleans, as well as arrays for grouping. Use numbers when possible for sorting continuous data. You can use the `_id` field for temporal sorting so you don't have to write code to get simple recent document lists, as in the basic example above.
@@ -620,7 +774,7 @@ Building an image uploader with `_files`:
620
774
  ```jsx
621
775
  import React from "react";
622
776
  import { useFireproof } from "use-fireproof";
623
- import { Button, Card, CardContent, Input, Label } from "@vibes.diy/look";
777
+ import { Button, Card, ErrorNote, Field, Figure, Grid, Heading, Input, Stack, Text } from "@vibes.diy/pouf";
624
778
 
625
779
  export default function App() {
626
780
  const { useDocument, useLiveQuery } = useFireproof("imageUploads");
@@ -654,46 +808,45 @@ export default function App() {
654
808
  };
655
809
 
656
810
  const c = {
657
- page: "bg-background text-foreground font-sans",
658
- meta: "text-muted-foreground",
811
+ page: "min-h-screen p-6 max-w-lg mx-auto bg-background text-foreground font-sans",
659
812
  };
660
813
 
661
814
  return (
662
- <div className={`min-h-screen p-6 max-w-lg mx-auto ${c.page}`}>
663
- <h2 className="text-2xl font-bold mb-4">Image Uploader</h2>
664
- <form onSubmit={onSubmit} className="space-y-3">
665
- <Label htmlFor="photo">Photo</Label>
666
- <Input id="photo" type="file" accept="image/*" onChange={onPickFile} className="w-full" />
667
- <Input
668
- type="text"
669
- placeholder="Caption"
670
- value={doc.caption}
671
- onChange={(e) => merge({ caption: e.target.value })}
672
- className="w-full"
673
- />
674
- <Button type="submit" size="lg">
675
- Upload
676
- </Button>
677
- {saveError && <p role="alert" className="text-destructive">{saveError}</p>}
678
- </form>
679
-
680
- <h3 className="text-lg font-bold mt-8">Recent Uploads</h3>
681
- <div className="grid grid-cols-2 gap-4 mt-2">
682
- {docs.map((d) => (
683
- <Card key={d._id}>
684
- <CardContent>
685
- {d._files?.photo?.url && <img src={d._files.photo.url} alt={d.caption || "upload"} className="w-full h-auto rounded-md" />}
686
- <p className={`${c.meta} mt-2`}>{d.caption || "No caption"}</p>
687
- </CardContent>
688
- </Card>
689
- ))}
690
- </div>
815
+ <div className={c.page}>
816
+ <Stack gap={5}>
817
+ <Heading level={1}>Image Uploader</Heading>
818
+ <form onSubmit={onSubmit}>
819
+ <Stack gap={3}>
820
+ {/* A file picker hands over files, not a value, so it stays the browser's own control inside the Field. */}
821
+ <Field label="Photo">{(id, describedBy) => <input id={id} aria-describedby={describedBy} type="file" accept="image/*" onChange={onPickFile} />}</Field>
822
+ <Field label="Caption">
823
+ {(id, describedBy) => <Input id={id} describedBy={describedBy} value={doc.caption} onChange={(caption) => merge({ caption })} />}
824
+ </Field>
825
+ <Button type="submit" size="lg">
826
+ Upload
827
+ </Button>
828
+ {saveError && <ErrorNote>{saveError}</ErrorNote>}
829
+ </Stack>
830
+ </form>
831
+
832
+ <Heading level={2}>Recent Uploads</Heading>
833
+ <Grid cols={2} gap={4}>
834
+ {docs.map((d) => (
835
+ <Card key={d._id}>
836
+ <Stack gap={2}>
837
+ {d._files?.photo?.url && <Figure src={d._files.photo.url} alt={d.caption || "upload"} width={400} height={300} />}
838
+ <Text muted>{d.caption || "No caption"}</Text>
839
+ </Stack>
840
+ </Card>
841
+ ))}
842
+ </Grid>
843
+ </Stack>
691
844
  </div>
692
845
  );
693
846
  }
694
847
  ```
695
848
 
696
- For multi-file uploads (e.g. `<input multiple>`), build the `_files` map keyed by filename and iterate with `Object.entries(doc._files)` to render each entry.
849
+ For multi-file uploads (the file picker with `multiple`), build the `_files` map keyed by filename and iterate with `Object.entries(doc._files)` to render each entry.
697
850
 
698
851
  Adding multi-file support:
699
852
 
@@ -756,7 +909,8 @@ Code listing for a bird sightings log App.jsx. Note the code ordering: hooks, th
756
909
  ```js
757
910
  import React from "react";
758
911
  import { useFireproof } from "use-fireproof";
759
- import { Button, Card, CardContent, Input, Label } from "@vibes.diy/look";
912
+ import { Button, Field, Heading, Input, Row, RowCard, Spacer, Stack, Text } from "@vibes.diy/pouf";
913
+ import { Checkbox } from "@vibes.diy/pouf/interactive";
760
914
 
761
915
  export default function App() {
762
916
  // 1. Hooks and document shapes
@@ -779,8 +933,8 @@ export default function App() {
779
933
  });
780
934
 
781
935
  // 2. Event handlers
782
- const handleInputChange = (e) => {
783
- mergeNewSighting({ species: e.target.value });
936
+ const handleInputChange = (species) => {
937
+ mergeNewSighting({ species });
784
938
  };
785
939
 
786
940
  const handleSubmit = (e) => {
@@ -788,48 +942,46 @@ export default function App() {
788
942
  submitNewSighting();
789
943
  };
790
944
 
791
- // 3. ClassNames — right before JSX, for the plain elements only; the look's
945
+ // 3. ClassNames — right before JSX, for the plain elements only; the pouf
792
946
  // components carry their own surface, ink and shape.
793
947
  const c = {
794
- page: "bg-background text-foreground font-sans",
795
- meta: "text-muted-foreground",
948
+ page: "min-h-screen max-w-md mx-auto p-6 bg-background text-foreground font-sans",
796
949
  };
797
950
 
798
951
  // 4. JSX return
799
952
  return (
800
- <div className={`min-h-screen max-w-md mx-auto p-6 ${c.page}`}>
801
- <h2 className="text-2xl font-bold mb-4">Bird Sightings</h2>
802
- <form onSubmit={handleSubmit} className="mb-4">
803
- <Label htmlFor="species" className="mb-2 block">
804
- Species
805
- </Label>
806
- <Input className="w-full" id="species" type="text" onChange={handleInputChange} value={newSighting.species} />
807
- </form>
808
- <ul className="space-y-3">
809
- {sightings.map((doc) => (
810
- <li key={doc._id}>
811
- <Card>
812
- <CardContent className="flex flex-col items-start">
813
- <div className="flex items-center justify-between w-full">
814
- <div className="flex items-center">
815
- <input
816
- className="mr-2"
817
- type="checkbox"
818
- checked={doc.confirmed}
819
- onChange={() => database.put({ ...doc, confirmed: !doc.confirmed })}
820
- />
821
- <span className="font-medium">{doc.species}</span>
822
- </div>
823
- <Button variant="destructive" size="sm" onClick={() => database.del(doc._id)}>
824
- Delete
825
- </Button>
826
- </div>
827
- <div className={`${c.meta} mt-1`}>{new Date(doc.createdAt).toISOString()}</div>
828
- </CardContent>
829
- </Card>
830
- </li>
831
- ))}
832
- </ul>
953
+ <div className={c.page}>
954
+ <Stack gap={5}>
955
+ <Heading level={1}>Bird Sightings</Heading>
956
+ <form onSubmit={handleSubmit}>
957
+ <Row gap={2} align="top">
958
+ <Field label="Species">
959
+ {(id, describedBy) => <Input id={id} describedBy={describedBy} onChange={handleInputChange} value={newSighting.species} />}
960
+ </Field>
961
+ <Button type="submit">Add</Button>
962
+ </Row>
963
+ </form>
964
+ <Stack gap={3}>
965
+ {sightings.map((doc) => (
966
+ <RowCard key={doc._id}>
967
+ <Row gap={3} align="center">
968
+ <Checkbox
969
+ label={doc.species}
970
+ checked={doc.confirmed}
971
+ onChange={(confirmed) => database.put({ ...doc, confirmed: confirmed === true })}
972
+ />
973
+ <Spacer />
974
+ <Button type="button" variant="quiet" tone="down" size="sm" onClick={() => database.del(doc._id)}>
975
+ Delete
976
+ </Button>
977
+ </Row>
978
+ <Text size="sm" muted>
979
+ {new Date(doc.createdAt).toISOString()}
980
+ </Text>
981
+ </RowCard>
982
+ ))}
983
+ </Stack>
984
+ </Stack>
833
985
  </div>
834
986
  );
835
987
  }
package/llms/image-gen.md CHANGED
@@ -145,23 +145,26 @@ When each item's substance is its words — a generated joke, a short verse, a p
145
145
 
146
146
  ```jsx
147
147
  import { ImgGen, useImgGen } from "use-vibes";
148
+ import { Button, RowCard, Text } from "@vibes.diy/pouf";
148
149
 
149
150
  function PunCard({ pun, database }) {
150
151
  const [wanted, setWanted] = React.useState(false);
151
152
  const { document: saved } = useImgGen({ _id: pun._id, database });
152
153
  const hasPicture = Object.keys(saved?._files ?? {}).length > 0;
153
154
  return (
154
- <li>
155
- <p>{pun.text}</p>
155
+ <RowCard>
156
+ <Text>{pun.text}</Text>
156
157
  {wanted || hasPicture ? (
157
158
  <figure>
158
159
  <ImgGen _id={pun._id} database={database} prompt={`A cheerful cartoon of this pun: ${pun.text}`} />
159
160
  {hasPicture ? null : <figcaption>Making a picture…</figcaption>}
160
161
  </figure>
161
162
  ) : (
162
- <button onClick={() => setWanted(true)}>Picture this</button>
163
+ <Button type="button" size="sm" variant="quiet" onClick={() => setWanted(true)}>
164
+ Picture this
165
+ </Button>
163
166
  )}
164
- </li>
167
+ </RowCard>
165
168
  );
166
169
  }
167
170
  ```
@@ -209,37 +212,51 @@ public facts. Public viewers render its `_files` URL with `<img>`; keep generati
209
212
  and revision controls on the creator's draft. Share the same `useImgGen` hook
210
213
  result between preview, download and publication so they select the same version.
211
214
 
212
- ## Access Functions and `doc.type`
215
+ ## Access Functions and pictures
213
216
 
214
- If your app defines an access function that keys on `doc.type` (the common pattern — e.g. it throws `{ forbidden: "unknown document type" }` for any unenumerated type), be aware of how ImgGen sets `type`:
217
+ Your `access.js` governs your app's own records — the story, the hat, the roast — and ImgGen keeps its pictures beside them in `media:<database>`, under the platform's media rules: every generation, a regenerate or an img2img edit included, is a new `version` record authored by whoever asked for it, and each person keeps their own `pointer` record for the picture they are viewing. So a type-keyed access function with one branch per record type already serves an image app, author checks and all; give each record the author and channel its own sharing calls for. When pictures should be more open or more closed than their record, write the access skill's media export, which checks that `doc.authorHandle` is the caller and admits both `version` and `pointer` records.
215
218
 
216
- - **Image attached to a host doc** (`<ImgGen _id={hostDoc._id} database={db} />`): the image inherits the host doc's existing `type` (`story`, `hat`, whatever) and its access/channels. The write passes a type-keyed access function with no extra work.
217
- - **Standalone image** (no `_id`, or a brand-new doc): the doc is written with `type: "image"`. A type-keyed access function will **reject** this unless it explicitly permits `"image"`. Add an `"image"` branch (with the grant/channel you want image docs to live on) so the write is accepted — otherwise image generation fails silently with `Failed to put document:`.
219
+ ## Choosing a Model
218
220
 
219
- Prefer attaching images to the host doc whose access you already grant; reach for standalone image docs only when you've added an `"image"` branch to the access function.
221
+ Every `<ImgGen>` draws with the app's default image model. `listModels({ kind: "image" })` (from `call-ai`, beside `callAI`) returns the image models this app can use, the app's own choice first, and each entry's `id` goes straight into the `model` prop: `<ImgGen prompt="An astronaut riding a horse" model={m.id} />`. The entry with `isDefault: true` is the one ImgGen uses when `model` is left out; with `images` present the default is the platform's image-edit model instead, so img2img leaves `model` out (see above). When the app's maker saved their own provider key, it covers the image model they chose in settings; other models run on the person's credits. The callAI skill's "Choosing a model" section lists every field.
220
222
 
221
- **ImgGen does not stamp your app's author field.** The doc ImgGen writes (a standalone `type: "image"` doc, or the version it appends to a host doc) carries no `authorHandle`/`userHandle`/`senderHandle` of yours — that field is `null`/`undefined`. So an access-fn branch that author-gates the type ImgGen writes (`if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" }`) **rejects the write**: `null !== user.userHandle`, the signed-in maker included, and the image silently fails to persist as the doc you meant (`Failed to put document:`). Two fixes: (1) **attach the image to a host doc you created with the author field already stamped** — `<ImgGen _id={hostDoc._id} database={db} />` inherits the host's `type` and access, so the author check already passed when you wrote the host; this is the preferred shape. (2) For a genuinely **standalone** image doc, do NOT author-gate the `"image"` branch on strict author equality — route it to the channel you want and skip the author check (the platform's ImgGen write is server-authoritative, so there's no spoof to guard against). Never author-gate a type whose docs ImgGen originates without stamping the author yourself first.
223
+ To compare image models, call `listModels({ kind: "image" })` and render one `<ImgGen>` per entry with `model={m.id}`. Every picture shares one prompt, each card is labelled with `m.name`, and each `_id` pairs the prompt with the model so a reload shows the saved pictures:
222
224
 
223
- **A version append (regenerate, or an img2img edit on an existing `_id`) arrives as the VIEWING user, not the original author.** So even when you DID stamp the author on the host doc, an `if (oldDoc && doc.authorHandle !== user.userHandle) throw { forbidden: "not author" }` on that type denies any _other_ signed-in viewer's generation on a shared/public-read doc — after the platform already billed it, arming an unbounded billed-retry loop (#3784/#3832). Author-equality belongs on **create and ownership change**, not on every update. Fix the author at create, forbid re-authoring, and for a non-author update accept only a legitimate ImgGen version append — the platform predicate `ctx.isImgGenVersionAppend(doc, oldDoc)` decides that (`oldDoc` is `null` on create):
225
+ ### `App.jsx`
226
+ ```jsx
227
+ import React, { useEffect, useState } from "react";
228
+ import { ImgGen } from "use-vibes";
229
+ import { listModels } from "call-ai";
230
+ import { Button, Card, Field, Grid, Input, Stack, Text } from "@vibes.diy/pouf";
224
231
 
225
- ```js
226
- if (oldDoc === null) {
227
- if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
228
- } else if (doc.authorHandle !== oldDoc.authorHandle) {
229
- throw { forbidden: "cannot change author" };
230
- } else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
231
- throw { forbidden: "not author" };
232
+ export default function App() {
233
+ const [models, setModels] = useState([]);
234
+ const [message, setMessage] = useState("");
235
+ const [draft, setDraft] = useState("a penguin riding a bicycle");
236
+ const [prompt, setPrompt] = useState("a penguin riding a bicycle");
237
+ useEffect(() => {
238
+ listModels({ kind: "image" })
239
+ .then(setModels)
240
+ .catch((error) => setMessage(error.message));
241
+ }, []);
242
+ return (
243
+ <Stack>
244
+ <Field label="Picture">{(id) => <Input id={id} value={draft} onChange={setDraft} />}</Field>
245
+ <Button onClick={() => setPrompt(draft)} disabled={draft.trim().length === 0}>Draw it with every model</Button>
246
+ <Text>{message}</Text>
247
+ <Grid cols={3}>
248
+ {models.map((m) => (
249
+ <Card key={m.id}>
250
+ <ImgGen _id={`audition:${prompt}:${m.id}`} prompt={prompt} model={m.id} alt={`${prompt}, drawn by ${m.name}`} />
251
+ <Text>{m.isDefault ? `${m.name} · this app's default` : m.name}</Text>
252
+ </Card>
253
+ ))}
254
+ </Grid>
255
+ </Stack>
256
+ );
232
257
  }
233
258
  ```
234
259
 
235
- The platform predicate `ctx.isImgGenVersionAppend` accepts exactly one legitimate ImgGen version append by a non-author — prior versions/files and all other fields must be unchanged, so a field you didn't anticipate stays author-protected by default; deletes stay author-only. Because `versions`/`currentVersion` are among the version fields it allows, a non-author can also advance the DISPLAYED version (the shared-generation feature); guard `currentVersion` specifically if you need author-only display control.
236
-
237
- ## Choosing a Model
238
-
239
- Override the model per component: `<ImgGen prompt="An astronaut riding a horse" model="openai/gpt-5-image-mini" />`
240
-
241
- Model ids follow the `provider/model-name` form from the platform's model catalog. Unknown ids surface as an error in the component's error UI.
242
-
243
260
  #### Props
244
261
 
245
262
  - `prompt`: text prompt (required unless `_id` is provided)
@@ -248,4 +265,4 @@ Model ids follow the `provider/model-name` form from the platform's model catalo
248
265
  - `database`: Fireproof db name or instance (default `"ImgGen"`)
249
266
  - `className`, `alt`, `style`: standard image styling
250
267
  - `showControls`: toggle regenerate + version nav (default `true`)
251
- - `model`: override the image-gen model for this component
268
+ - `model`: an `id` from `listModels({ kind: "image" })` to draw with instead of the app's default