@vibes.diy/prompts 14.3.78 → 14.3.80

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.
@@ -75,18 +75,19 @@ for the rest.
75
75
  ### `App.jsx`
76
76
  ```jsx app=instagram-connection
77
77
  import { useConnection } from "use-vibes";
78
+ import { Button, Text } from "@vibes.diy/pouf";
78
79
 
79
80
  function ConnectInstagram() {
80
81
  const { state, consented, available, accountLabel, link, refresh } = useConnection("instagram");
81
82
  if (state === "pending") return null; // still resolving — never flash a Connect button
82
83
  // A connection that already works keeps working, whatever `available` says.
83
84
  if (consented && (state === "live" || state === "expiring"))
84
- return <p>Connected as {accountLabel}</p>;
85
+ return <Text>Connected as {accountLabel}</Text>;
85
86
  // Everything below asks the visitor to start or redo a connection, which is
86
87
  // the only thing `available` can stop.
87
- if (!available) return <p>Connecting Instagram is not available right now.</p>;
88
- if (state === "dead") return <p>Your Instagram connection expired. Reconnect it in Settings.</p>;
89
- return <button onClick={link}>Connect Instagram</button>;
88
+ if (!available) return <Text muted>Connecting Instagram is not available right now.</Text>;
89
+ if (state === "dead") return <Text muted>Your Instagram connection expired. Reconnect it in Settings.</Text>;
90
+ return <Button type="button" onClick={link}>Connect Instagram</Button>;
90
91
  }
91
92
  ```
92
93
 
@@ -28,7 +28,7 @@ when they're ready.
28
28
  ```jsx
29
29
  import React from "react";
30
30
  import { createVibe } from "use-vibes";
31
- import { Button } from "@vibes.diy/look";
31
+ import { Button } from "@vibes.diy/pouf";
32
32
 
33
33
  export default function App() {
34
34
  // `ready` + `spec` come from the interview (see the full example below).
@@ -69,7 +69,7 @@ Let the user edit each slide inline and present full-screen.`;
69
69
  import React from "react";
70
70
  import { callAI } from "call-ai";
71
71
  import { createVibe } from "use-vibes";
72
- import { Button } from "@vibes.diy/look";
72
+ import { Button } from "@vibes.diy/pouf";
73
73
 
74
74
  export default function App() {
75
75
  const [answers, setAnswers] = React.useState({});
@@ -95,7 +95,7 @@ export default function App() {
95
95
  <main id="app">
96
96
  {/* ...interview... */}
97
97
  {!spec ? (
98
- <Button size="lg" disabled={building} onClick={finish}>
98
+ <Button size="lg" loading={building} onClick={finish}>
99
99
  {building ? "Preparing…" : "I'm ready"}
100
100
  </Button>
101
101
  ) : (
@@ -131,6 +131,7 @@ blocked), offer a fallback link so the hand-off still works:
131
131
 
132
132
  ```jsx
133
133
  import { createVibe, buildCreateVibeUrl } from "use-vibes";
134
+ import { Button } from "@vibes.diy/pouf";
134
135
 
135
136
  function HandoffButton({ spec }) {
136
137
  const [blocked, setBlocked] = React.useState(false);
@@ -139,7 +140,7 @@ function HandoffButton({ spec }) {
139
140
  Open the builder
140
141
  </a>
141
142
  ) : (
142
- <button onClick={() => setBlocked(!createVibe(spec))}>Create my pitch deck</button>
143
+ <Button type="button" onClick={() => setBlocked(!createVibe(spec))}>Create my pitch deck</Button>
143
144
  );
144
145
  }
145
146
  ```
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
  }