@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/access.md CHANGED
@@ -34,7 +34,7 @@ writes.
34
34
 
35
35
  Express every refusal about **who may act** through the supplied validators — `ctx.requireAccess`, `ctx.requireRole`, `ctx.requireBackend` — so that admin mode passes them and the function still runs, applying your own channels and grants to the repaired document; a refusal about the document's own **shape**, like an unknown type or a missing field, stays a plain `throw` and is right to, while a write-once check sits in between and is worth naming in a comment, since a schema repair has to get past it.
36
36
 
37
- A component-owned image database (`media:<hostDbName>`) uses platform-authored rules by default: each generated image is readable by whoever can read the record it was generated for, and by the person who generated it. An explicit string-named export such as `export { posterMedia as "media:posters" }` overrides that one media database. Neither `export default` nor a function merely named `media` overrides it. Within those, platform-driven writes onto the app's own docs — like an `<ImgGen>` version append (the `ctx.isImgGenVersionAppend` shape) — flow through your function like any other write.
37
+ A component-owned image database (`media:<hostDbName>`) uses platform-authored rules by default: each generated image is readable by whoever can read the record it was generated for, and by the person who generated it. An explicit string-named export such as `export { posterMedia as "media:posters" }` overrides that one media database. Neither `export default` nor a function merely named `media` overrides it. Your function governs your app's own records, and every picture `<ImgGen>` makes is saved in that media database as its creator's own record, so pictures need no branch of their own in yours.
38
38
 
39
39
  ### AccessDescriptor return type
40
40
 
@@ -79,18 +79,17 @@ App.jsx — the checkbox is the consent call; the picker is a doc write:
79
79
  const { can } = useVibe("logs"); // the write gate is always the server's own verdict
80
80
 
81
81
  // checking is the consent preflight; unchecking is the off switch. Never one verb for both edges.
82
- <label>
83
- <input
84
- type="checkbox"
82
+ <Row gap={2} align="center">
83
+ <Checkbox
84
+ label="Shared with followers"
85
85
  checked={followersEnabled}
86
- onChange={(e) => (e.target.checked ? requestFollowersAccess() : setVisibility("private"))}
86
+ onChange={(checked) => (checked === true ? requestFollowersAccess() : setVisibility("private"))}
87
87
  />
88
- Shared with{" "}
89
- <button type="button" onClick={() => setShowFollowers(true)}>
90
- followers
91
- </button>
92
- </label>
93
- {showFollowers && <ul>{followers.map((f) => <li key={f.handle}>{f.handle}</li>)}</ul>}
88
+ <Button type="button" size="sm" variant="quiet" onClick={() => setShowFollowers(true)}>
89
+ See followers
90
+ </Button>
91
+ </Row>
92
+ {showFollowers && <Stack gap={1}>{followers.map((f) => <Text key={f.handle}>{f.handle}</Text>)}</Stack>}
94
93
 
95
94
  {/* the app's own list — one share doc per person picked */}
96
95
  {can.create({ type: "share", authorHandle: me?.userHandle, buddyHandle: "x" }).ok && (
@@ -176,9 +175,9 @@ would copy draft prompts and prior versions. Unpublishing removes that record's
176
175
  public grant. Explicit media bindings fail closed on older access outputs until
177
176
  the publish-time backfill evaluates them under the new rule.
178
177
 
179
- ### The ImgGen version-append nuance
178
+ ### Pictures on shared records
180
179
 
181
- The platform predicate `ctx.isImgGenVersionAppend(doc, oldDoc)` 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 for author-only display control. Write-once docs can simply `if (oldDoc === null) {} else throw`.
180
+ Anyone who can read a record can make and keep pictures for it: each generation is saved in the media database as that person's own record, so a shared record stays author-edited while everyone's pictures land. Write-once docs can simply `if (oldDoc === null) {} else throw`.
182
181
 
183
182
  ### `_id` strategy matters
184
183
 
@@ -317,14 +316,12 @@ export function chat(doc, oldDoc, user, ctx) {
317
316
  if (!user) throw { forbidden: "authentication required" };
318
317
 
319
318
  if (doc.type === "message") {
320
- // Author fixed at create; ownership immutable. On update a non-author may
321
- // only append one legitimate ImgGen version — the platform predicate
322
- // ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
319
+ // Author fixed at create and kept for good; the author edits their own.
323
320
  if (oldDoc === null) {
324
321
  if (doc.userHandle !== user.userHandle) throw { forbidden: "not author" };
325
322
  } else if (doc.userHandle !== oldDoc.userHandle) {
326
323
  throw { forbidden: "cannot change author" };
327
- } else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.userHandle !== user.userHandle) {
324
+ } else if (oldDoc.userHandle !== user.userHandle) {
328
325
  throw { forbidden: "not author" };
329
326
  }
330
327
  ctx.requireAccess(doc.channelId);
@@ -335,7 +332,7 @@ export function chat(doc, oldDoc, user, ctx) {
335
332
  }
336
333
  ```
337
334
 
338
- `ctx.requireAccess(channel)` gates on channel **membership** (a `grant.users`/`grant.roles` grant), NOT on `grant.public`, which is read-only — so a channel anyone signed-in should post to must **not** gate writes on `requireAccess` (it would block every non-owner); just check the author and route the doc. Reserve `requireAccess` for members-only channels whose writers you granted membership. For writes that need no sign-in at all ("anyone can sign/submit"), return `allowAnonymous: true` instead of throwing on `!user`. A grant/member/role doc must also return `channels` (route it to an owner-readable admin channel like `["admin:grants"]`) — a channel-less result is rejected as "unreadable write". On updates of shared-visible docs, keep ownership immutable (`if (oldDoc && doc.<authorField> !== oldDoc.<authorField>) throw`, where `<authorField>` is your doc's author field — `authorHandle`/`userHandle`/`senderHandle`) rather than requiring the updater to BE the author — image version appends run as the VIEWING user, and an author-only update gate turns every other viewer's generation into a billed deny-retry. Reserve author-only updates for genuinely private per-user docs; on shared docs, let a non-author through only when `ctx.isImgGenVersionAppend(doc, oldDoc)` accepts the write (as `chat.message` above shows) — it permits exactly one legitimate ImgGen version append and requires every other field unchanged.
335
+ `ctx.requireAccess(channel)` gates on channel **membership** (a `grant.users`/`grant.roles` grant), NOT on `grant.public`, which is read-only — so a channel anyone signed-in should post to must **not** gate writes on `requireAccess` (it would block every non-owner); just check the author and route the doc. Reserve `requireAccess` for members-only channels whose writers you granted membership. For writes that need no sign-in at all ("anyone can sign/submit"), return `allowAnonymous: true` instead of throwing on `!user`. A grant/member/role doc must also return `channels` (route it to an owner-readable admin channel like `["admin:grants"]`) — a channel-less result is rejected as "unreadable write". On updates, keep ownership immutable (`if (oldDoc && doc.<authorField> !== oldDoc.<authorField>) throw`, where `<authorField>` is your doc's author field — `authorHandle`/`userHandle`/`senderHandle`) and let the author edit their own (as `chat.message` above shows). Pictures other viewers make for a shared doc are their own records in the media database, so an author-only update keeps every picture flowing.
339
336
 
340
337
  **A follow-up edit that adds a NEW doc type updates `access.js` FIRST.** The app runs live while your edits stream in, and writes are enforced against the access.js that was in force before this turn until the whole turn completes — so a `db.put` of a doc type the old function rejects fails immediately (`unknown document type`), even though your access.js update lands later in the same reply. Emit the access.js edit adding the new type's branch before the App.jsx edits that write it. And never fire-and-forget background writes of a newly added type: gate seed/auto-write effects on `useVibe(dbName)` — `if (!ready || !can.create(sampleDoc).ok) return;` with `ready`/`can` in the effect deps, checking a representative sample of **each** doc type the effect writes — so a not-yet-allowed write is skipped quietly instead of surfacing rejection errors the user didn't cause.
341
338
 
@@ -378,14 +375,12 @@ export function wall(doc, oldDoc, user, ctx) {
378
375
  }
379
376
 
380
377
  if (doc.type === "post") {
381
- // Author fixed at create; ownership immutable. On update a non-author may
382
- // only append one legitimate ImgGen version — the platform predicate
383
- // ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
378
+ // Author fixed at create and kept for good; the author edits their own.
384
379
  if (oldDoc === null) {
385
380
  if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
386
381
  } else if (doc.authorHandle !== oldDoc.authorHandle) {
387
382
  throw { forbidden: "cannot change author" };
388
- } else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
383
+ } else if (oldDoc.authorHandle !== user.userHandle) {
389
384
  throw { forbidden: "not author" };
390
385
  }
391
386
  return { channels: [doc.channelId] };
@@ -421,7 +416,7 @@ export function board(doc, oldDoc, user, ctx) {
421
416
  }
422
417
  ```
423
418
 
424
- `ctx.requireAccess(channel)` gates on **membership** (a `grant.users`/`grant.roles` grant), not `grant.public` (read-only) — so an open feed anyone signed-in may post to must not gate writes on it; check the author and route the doc. For writes needing no sign-in ("anyone can sign/submit"), return `allowAnonymous: true` instead of throwing on `!user`. A grant/share/request doc must also return `channels` — a channel-less result is rejected. On updates of shared-visible docs, keep ownership immutable (`doc.authorHandle !== oldDoc.authorHandle` → throw) rather than requiring the updater to BE the author — image version appends run as the viewing user, and an author-only update gate turns every other viewer's generation into a billed deny-retry. Reserve author-only updates for genuinely private per-user docs; on shared docs, let a non-author through only when `ctx.isImgGenVersionAppend(doc, oldDoc)` accepts the write (as `wall.post` above shows) — it permits exactly one legitimate ImgGen version append and requires every other field unchanged.
419
+ `ctx.requireAccess(channel)` gates on **membership** (a `grant.users`/`grant.roles` grant), not `grant.public` (read-only) — so an open feed anyone signed-in may post to must not gate writes on it; check the author and route the doc. For writes needing no sign-in ("anyone can sign/submit"), return `allowAnonymous: true` instead of throwing on `!user`. A grant/share/request doc must also return `channels` — a channel-less result is rejected. On updates, keep ownership immutable (`doc.authorHandle !== oldDoc.authorHandle` → throw) and let the author edit their own (as `wall.post` above shows). Pictures other viewers make for a shared doc are their own records in the media database, so an author-only update keeps every picture flowing.
425
420
 
426
421
  **Build the permission model around what a newcomer should be able to do.** When a stranger opens the app, they should immediately be able to do the thing it's _for_ — add their own entries, post a note, drop a pin, join a shared canvas. So the default is: every signed-in visitor is a first-class participant who creates their own objects and edits what they created (`doc.authorHandle === user.userHandle`, checking `oldDoc` on updates), from first load, with no one needing to let them in.
427
422
 
@@ -550,9 +545,15 @@ import { useVibe } from "use-vibes";
550
545
  {docs.map((doc) => (
551
546
  <li key={doc._id}>
552
547
  {doc.text}
553
- <button onClick={() => database.put({ ...doc, favorite: !doc.favorite })}>
554
- {doc.favorite ? "★" : "☆"}
555
- </button>
548
+ <IconButton
549
+ size="sm"
550
+ variant={doc.favorite ? "solid" : "quiet"}
551
+ tone="warn"
552
+ aria-pressed={Boolean(doc.favorite)}
553
+ label={doc.favorite ? "Unfavorite" : "Favorite"}
554
+ icon={<Icon name="star" size="sm" />}
555
+ onClick={() => database.put({ ...doc, favorite: !doc.favorite })}
556
+ />
556
557
  </li>
557
558
  ))}
558
559
  </ul>
@@ -564,7 +565,7 @@ import { useVibe } from "use-vibes";
564
565
  >>>>>>> REPLACE
565
566
  ```
566
567
 
567
- The AI agent writes the access function (so it knows the role names) and writes the UI (so it knows which roles gate which components). The `access` object is the bridge — it lets the UI reflect server-enforced permissions without duplicating the logic.
568
+ The AI agent writes the access function (so it knows the role names) and writes the UI (so it knows which roles gate which components). The `access` object is the link between them — it lets the UI reflect server-enforced permissions without duplicating the logic.
568
569
 
569
570
  The access function is the single source of truth for permissions. Gate write surfaces with `useVibe(dbName).can`, which runs this same access function. The `access` object (`access.hasRole(name)`, `access.hasChannel(name)`) reflects the viewer's resolved roles/channels for DISPLAY — role badges, showing/hiding read-only sections — not as the write gate.
570
571
 
@@ -611,14 +612,12 @@ export function announcements(doc, oldDoc, user, ctx) {
611
612
  }
612
613
 
613
614
  if (doc.type === "post") {
614
- // Author fixed at create; ownership immutable. On update a non-author may
615
- // only append one legitimate ImgGen version — the platform predicate
616
- // ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
615
+ // Author fixed at create and kept for good; the author edits their own.
617
616
  if (oldDoc === null) {
618
617
  if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
619
618
  } else if (doc.authorHandle !== oldDoc.authorHandle) {
620
619
  throw { forbidden: "cannot change author" };
621
- } else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
620
+ } else if (oldDoc.authorHandle !== user.userHandle) {
622
621
  throw { forbidden: "not author" };
623
622
  }
624
623
  ctx.requireAccess(doc.channel);
@@ -636,12 +635,12 @@ App.jsx — `useVibe().can` gates every write surface (posts AND owner-only mana
636
635
  import React from "react";
637
636
  import { useFireproof } from "use-fireproof";
638
637
  import { useViewer, useVibe } from "use-vibes";
639
- import { Button, Textarea } from "@vibes.diy/look";
638
+ import { Button, Field, RowCard, Skeleton, Stack, Text, Textarea } from "@vibes.diy/pouf";
640
639
 
641
640
  export default function App() {
642
641
  const { viewer, isViewerPending, ViewerTag } = useViewer();
643
642
  const { database, useLiveQuery, access } = useFireproof("announcements");
644
- const { can } = useVibe("announcements");
643
+ const { can, ready } = useVibe("announcements");
645
644
 
646
645
  const { docs: posts } = useLiveQuery("type", { key: "post" });
647
646
  const [draft, setDraft] = React.useState("");
@@ -654,7 +653,9 @@ export default function App() {
654
653
  // ctx.requireRole("owner"), so this verdict is false for everyone but the owner.
655
654
  const canGrant = can.create({ type: "roleGrant", role: "poster", userHandle: "newUser" });
656
655
 
657
- if (isViewerPending) return null;
656
+ // Hold the page while identity and the access function resolve: every verdict
657
+ // reads pending until then, so the write surface waits on a quiet checking state.
658
+ if (isViewerPending || !ready) return <Skeleton />;
658
659
 
659
660
  async function submitPost() {
660
661
  if (!draft.trim() || !viewer) return;
@@ -669,7 +670,7 @@ export default function App() {
669
670
  }
670
671
 
671
672
  return (
672
- <div>
673
+ <Stack gap={4}>
673
674
  {/* No current-user pill / sign-in button here — that's system chrome in the
674
675
  Vibes Switch (the panel the logo opens). ViewerTag below renders post authors. */}
675
676
 
@@ -681,32 +682,34 @@ export default function App() {
681
682
  submitPost();
682
683
  }}
683
684
  >
684
- <Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className="w-full" />
685
- <Button type="submit">Post</Button>
685
+ <Stack gap={3}>
686
+ <Field label="Announcement">{(id, describedBy) => <Textarea id={id} describedBy={describedBy} value={draft} onChange={setDraft} />}</Field>
687
+ <Button type="submit">Post</Button>
688
+ </Stack>
686
689
  </form>
687
690
  ) : (
688
- viewer && <p className="text-muted-foreground">{canPost.reason}</p>
691
+ viewer && <Text muted>Posting opens up once the owner adds you as a poster.</Text>
689
692
  )}
690
693
 
691
694
  {/* owner-only management — gated on can.* */}
692
695
  {canGrant.ok && (
693
- <Button variant="secondary" onClick={() => database.put({ type: "roleGrant", role: "poster", userHandle: "newUser" })}>
696
+ <Button type="button" variant="quiet" onClick={() => database.put({ type: "roleGrant", role: "poster", userHandle: "newUser" })}>
694
697
  Grant poster role
695
698
  </Button>
696
699
  )}
697
700
 
698
701
  {posts.map((p) => (
699
- <div key={p._id}>
702
+ <RowCard key={p._id}>
700
703
  <ViewerTag userHandle={p.authorHandle} />
701
- <p>{p.body}</p>
704
+ <Text>{p.body}</Text>
702
705
  {can.delete(p).ok && (
703
- <Button variant="destructive" size="sm" onClick={() => database.del(p._id)}>
706
+ <Button type="button" variant="quiet" tone="down" size="sm" onClick={() => database.del(p._id)}>
704
707
  Delete
705
708
  </Button>
706
709
  )}
707
- </div>
710
+ </RowCard>
708
711
  ))}
709
- </div>
712
+ </Stack>
710
713
  );
711
714
  }
712
715
  ```
@@ -722,12 +725,12 @@ const canRevoke = can.delete(grantDoc);
722
725
  {(canAppoint.ok || canRevoke.ok) && (
723
726
  <ModeratorPanel>
724
727
  {canAppoint.ok && (
725
- <Button onClick={() => database.put({ type: "modGrant", role: "moderator", userHandle })}>Appoint</Button>
728
+ <Button type="button" onClick={() => database.put({ type: "modGrant", role: "moderator", userHandle })}>Appoint</Button>
726
729
  )}
727
730
  {canRevoke.ok ? (
728
- <Button variant="destructive" onClick={() => database.del(grantDoc._id)}>Revoke</Button>
731
+ <Button type="button" variant="quiet" tone="down" onClick={() => database.del(grantDoc._id)}>Revoke</Button>
729
732
  ) : (
730
- <p>{canRevoke.reason}</p>
733
+ <Text muted>{canRevoke.reason}</Text>
731
734
  )}
732
735
  </ModeratorPanel>
733
736
  )}
@@ -750,14 +753,12 @@ export function chat(doc, oldDoc, user, ctx) {
750
753
  }
751
754
 
752
755
  if (doc.type === "post") {
753
- // Author fixed at create; ownership immutable. On update a non-author may
754
- // only append one legitimate ImgGen version — the platform predicate
755
- // ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
756
+ // Author fixed at create and kept for good; the author edits their own.
756
757
  if (oldDoc === null) {
757
758
  if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
758
759
  } else if (doc.authorHandle !== oldDoc.authorHandle) {
759
760
  throw { forbidden: "cannot change author" };
760
- } else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
761
+ } else if (oldDoc.authorHandle !== user.userHandle) {
761
762
  throw { forbidden: "not author" };
762
763
  }
763
764
  // Any signed-in author may post to this open channel. Do NOT call
@@ -792,7 +793,7 @@ App.jsx — `access.hasChannel()` filters which channels are visible (display);
792
793
  =======
793
794
  {/* filter to channels the viewer can see — use _id as channel identifier (display only) */}
794
795
  {channels.filter((ch) => access.hasChannel(ch._id)).map((ch) => (
795
- <button key={ch._id} onClick={() => setChannel(ch._id)}>{ch.name}</button>
796
+ <Button key={ch._id} type="button" size="sm" variant={ch._id === channel ? "solid" : "quiet"} tone="info" onClick={() => setChannel(ch._id)}>{ch.name}</Button>
796
797
  ))}
797
798
 
798
799
  {/* gate the write surface on useVibe().can */}
@@ -863,18 +864,18 @@ App.jsx — `access.hasChannel()` shows only the lists the viewer belongs to; `u
863
864
 
864
865
  ```jsx
865
866
  <<<<<<< SEARCH
866
- {viewer && <button onClick={addNote}>+ note</button>}
867
+ {viewer && <Button type="button" onClick={addNote}>Add a note</Button>}
867
868
  =======
868
869
  {/* create list — any signed-in visitor can start their own */}
869
870
  {can.create({ type: "list", author: me?.userHandle }).ok && (
870
- <button onClick={() => database.put({ type: "list", author: me.userHandle, name: "new list" })}>
871
- + new list
872
- </button>
871
+ <Button type="button" onClick={() => database.put({ type: "list", author: me.userHandle, name: "new list" })}>
872
+ New list
873
+ </Button>
873
874
  )}
874
875
 
875
876
  {visible.map((list) => (
876
877
  <section key={list._id}>
877
- <h3>{list.name}</h3>
878
+ <Heading level={2}>{list.name}</Heading>
878
879
  {/* add-item form is shown only when the access fn would accept the write */}
879
880
  {canAddItem(list) && <AddItemForm listId={list._id} />}
880
881
  {/* invite peer — only shown to members of this list */}
@@ -987,7 +988,7 @@ App.jsx — the same board list serves the creator and the invited recipient. `B
987
988
  ```jsx partial
988
989
  function BoardWorkspace({ ensureDefaultBoard }) {
989
990
  const { useLiveQuery } = useFireproof("boards");
990
- const { me, can } = useVibe("boards");
991
+ const { me, can, ready } = useVibe("boards");
991
992
  const [pickedBoardId, setPickedBoardId] = React.useState();
992
993
  const { docs: boards } = useLiveQuery("type", { key: "board" });
993
994
  const ownBoardId = me?.userHandle ? `default-${me.userHandle}` : undefined;
@@ -1002,15 +1003,21 @@ function BoardWorkspace({ ensureDefaultBoard }) {
1002
1003
  boards[0];
1003
1004
  return (
1004
1005
  <section>
1005
- {ownBoardId && !boards.some((board) => board._id === ownBoardId) && can.create(ownBoard).ok && (
1006
- <button onClick={createOwnBoard}>Create my board</button>
1006
+ {ownBoardId && !boards.some((board) => board._id === ownBoardId) && (!ready || can.create(ownBoard).ok) && (
1007
+ <Button type="button" onClick={createOwnBoard} disabled={!ready}>Create my board</Button>
1007
1008
  )}
1008
- <label>
1009
- Board
1010
- <select value={board?._id ?? ""} onChange={(event) => setPickedBoardId(event.target.value)} disabled={boards.length === 0}>
1011
- {boards.map((board) => <option key={board._id} value={board._id}>{board.name ?? board._id}</option>)}
1012
- </select>
1013
- </label>
1009
+ <Field label="Board">
1010
+ {(id, describedBy) => (
1011
+ <Select
1012
+ id={id}
1013
+ describedBy={describedBy}
1014
+ value={board?._id ?? ""}
1015
+ onChange={setPickedBoardId}
1016
+ disabled={boards.length === 0}
1017
+ options={boards.map((board) => ({ value: board._id, label: board.name ?? board._id }))}
1018
+ />
1019
+ )}
1020
+ </Field>
1014
1021
  {board && <BoardContents board={board} />}
1015
1022
  </section>
1016
1023
  );
@@ -1028,11 +1035,11 @@ const sharedWithMe = boards.filter((board) => board.creatorHandle !== me?.userHa
1028
1035
  // …
1029
1036
  {sharedWithMe.length > 0 && (
1030
1037
  <section>
1031
- <h2>Shared with you</h2>
1038
+ <Heading level={2}>Shared with you</Heading>
1032
1039
  {sharedWithMe.map((board) => (
1033
- <button key={board._id} type="button" onClick={() => setPickedBoardId(board._id)}>
1034
- {board.name ?? board._id} <ViewerTag userHandle={board.creatorHandle} />
1035
- </button>
1040
+ <RowCard key={board._id} onClick={() => setPickedBoardId(board._id)}>
1041
+ <Text>{board.name ?? board._id}</Text> <ViewerTag userHandle={board.creatorHandle} />
1042
+ </RowCard>
1036
1043
  ))}
1037
1044
  </section>
1038
1045
  )}
@@ -1085,14 +1092,12 @@ export function chat(doc, oldDoc, user, ctx) {
1085
1092
  }
1086
1093
 
1087
1094
  if (doc.type === "message") {
1088
- // Author fixed at create; ownership immutable. On update a non-author may
1089
- // only append one legitimate ImgGen version — the platform predicate
1090
- // ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
1095
+ // Author fixed at create and kept for good; the author edits their own.
1091
1096
  if (oldDoc === null) {
1092
1097
  if (doc.userHandle !== user.userHandle) throw { forbidden: "not author" };
1093
1098
  } else if (doc.userHandle !== oldDoc.userHandle) {
1094
1099
  throw { forbidden: "cannot change author" };
1095
- } else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.userHandle !== user.userHandle) {
1100
+ } else if (oldDoc.userHandle !== user.userHandle) {
1096
1101
  throw { forbidden: "not author" };
1097
1102
  }
1098
1103
  ctx.requireAccess(doc.channelId);
@@ -1137,13 +1142,13 @@ App.jsx — show only channels the viewer is in (`access.hasChannel`), gate the
1137
1142
  {viewer && <Compose onSend={send} />}
1138
1143
  =======
1139
1144
  {/* sidebar lists only the channels the viewer is in */}
1140
- <nav>{myChannels.map((ch) => <button key={ch._id} onClick={() => setChannelId(ch._id)}>{ch.name}</button>)}</nav>
1145
+ <nav>{myChannels.map((ch) => <Button key={ch._id} type="button" size="sm" variant={ch._id === channelId ? "solid" : "quiet"} tone="info" onClick={() => setChannelId(ch._id)}>{ch.name}</Button>)}</nav>
1141
1146
 
1142
1147
  {/* compose is shown only when the viewer is a member of the selected channel */}
1143
1148
  {channelId && canPost.ok ? (
1144
1149
  <Compose onSend={(text) => database.put({ type: "message", channelId, userHandle: me.userHandle, text })} />
1145
1150
  ) : (
1146
- <p>{canPost.reason || "Pick a channel"}</p>
1151
+ <Text muted>{canPost.reason || "Pick a channel"}</Text>
1147
1152
  )}
1148
1153
 
1149
1154
  {/* any member of this channel may invite a peer */}
@@ -1203,7 +1208,7 @@ App.jsx — anonymous-friendly submit form, owner-only config panel, role-gated
1203
1208
 
1204
1209
  ```jsx
1205
1210
  <<<<<<< SEARCH
1206
- <input value={answer} onChange={(e) => setAnswer(e.target.value)} />
1211
+ <Field label="Your answer">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={answer} onChange={setAnswer} />}</Field>
1207
1212
  =======
1208
1213
  {/* anyone (signed in OR anonymous) can submit — only stamp authorHandle when present */}
1209
1214
  {canSubmit.ok ? (
@@ -1211,10 +1216,10 @@ App.jsx — anonymous-friendly submit form, owner-only config panel, role-gated
1211
1216
  e.preventDefault();
1212
1217
  database.put({ type: "survey-response", question: "q1", answer, ...(me && { authorHandle: me.userHandle }) });
1213
1218
  }}>
1214
- <input value={answer} onChange={(e) => setAnswer(e.target.value)} />
1215
- <button type="submit">Submit</button>
1219
+ <Field label="Your answer">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={answer} onChange={setAnswer} />}</Field>
1220
+ <Button type="submit">Submit</Button>
1216
1221
  </form>
1217
- ) : <p>{canSubmit.reason}</p>}
1222
+ ) : <Text muted>{canSubmit.reason}</Text>}
1218
1223
 
1219
1224
  {/* owner-only admin to wire up the feedback-team role */}
1220
1225
  {canConfigure.ok && <ConfigPanel />}
@@ -1242,8 +1247,38 @@ export function guestbook(doc, oldDoc, user, ctx) {
1242
1247
 
1243
1248
  In `App.jsx`, gate the form on `useVibe("guestbook").can.create({ type: "entry" }).ok` (true for anon here) and stamp `authorHandle: me?.userHandle` only when signed in. Without `allowAnonymous: true` the runtime rejects the null-user write even though the function didn't throw — so the guestbook would silently require login, the exact miss to avoid.
1244
1249
 
1250
+ The app before the edit, a note form wrapped in a sign-in wall:
1251
+
1252
+ ```jsx
1253
+ import { useState } from "react";
1254
+ import { useFireproof } from "use-fireproof";
1255
+ import { Field, Input, Stack } from "@vibes.diy/pouf";
1256
+ import { SignInGate } from "./SignInGate.jsx";
1257
+
1258
+ export default function App() {
1259
+ const { database } = useFireproof("notes");
1260
+ const [message, setMessage] = useState("");
1261
+ return (
1262
+ <Stack>
1263
+ <SignInGate>
1264
+ <Field label="Your note">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={message} onChange={setMessage} />}</Field>
1265
+ </SignInGate>
1266
+ </Stack>
1267
+ );
1268
+ }
1269
+ ```
1270
+
1245
1271
  App.jsx — gate the form on `can.create({ type: "entry" }).ok`, which is true even when nobody is signed in:
1246
1272
 
1273
+ ```jsx
1274
+ <<<<<<< SEARCH
1275
+ import { Field, Input, Stack } from "@vibes.diy/pouf";
1276
+ =======
1277
+ import { Button, Field, Input, RowCard, Stack, Text } from "@vibes.diy/pouf";
1278
+ import { useVibe } from "use-vibes";
1279
+ >>>>>>> REPLACE
1280
+ ```
1281
+
1247
1282
  ```jsx
1248
1283
  <<<<<<< SEARCH
1249
1284
  const { database } = useFireproof("notes");
@@ -1260,7 +1295,7 @@ App.jsx — gate the form on `can.create({ type: "entry" }).ok`, which is true e
1260
1295
  ```jsx
1261
1296
  <<<<<<< SEARCH
1262
1297
  <SignInGate>
1263
- <input value={message} onChange={(e) => setMessage(e.target.value)} />
1298
+ <Field label="Your note">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={message} onChange={setMessage} />}</Field>
1264
1299
  </SignInGate>
1265
1300
  =======
1266
1301
  {canSign.ok ? (
@@ -1270,11 +1305,11 @@ App.jsx — gate the form on `can.create({ type: "entry" }).ok`, which is true e
1270
1305
  database.put({ type: "entry", message, createdAt: Date.now(), ...(me && { authorHandle: me.userHandle }) });
1271
1306
  setMessage("");
1272
1307
  }}>
1273
- <input value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Leave a note" />
1274
- <button type="submit">Sign</button>
1308
+ <Field label="Your note">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={message} onChange={setMessage} placeholder="Leave a note" />}</Field>
1309
+ <Button type="submit">Sign</Button>
1275
1310
  </form>
1276
- ) : <p>{canSign.reason}</p>}
1311
+ ) : <Text muted>{canSign.reason}</Text>}
1277
1312
 
1278
- {entries.map((e) => <li key={e._id}>{e.message} — {e.authorHandle || "anonymous"}</li>)}
1313
+ {entries.map((e) => <RowCard key={e._id}><Text>{e.message} — {e.authorHandle || "anonymous"}</Text></RowCard>)}
1279
1314
  >>>>>>> REPLACE
1280
1315
  ```
package/llms/backend.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export const backendConfig = {
2
2
  name: "backend",
3
3
  label: "backend-js",
4
- description: "server-side backend.js file: answer HTTP/webhook requests at the app's /_api URL, react to committed data changes, and run scheduled jobs — its writes go through the app's own access rules; reads a web page, feed or API the person names and hands it to the page through /_api; sends a record on to another service through a Zapier Catch Hook when the person asks for Zapier",
5
- cues: ["zapier", "catch hook"],
4
+ description: "server-side backend.js file: answer HTTP/webhook requests at the app's /_api URL, react to committed data changes, and run scheduled jobs — its writes go through the app's own access rules; reads a web page, feed or API the person names and hands it to the page through /_api; emails, notifies and reminds people through ctx.notify by handle — a weekly digest, a summary for the owner, a Monday email to the manager — and invites a recipient who is not in the app yet; and sends a record to a tool the person names (Slack, Google Sheets, a CRM, Zapier) through a Zapier Catch Hook the owner connects",
5
+ cues: ["email", "notify", "remind", "digest", "zapier", "catch hook", "webhook", "slack", "sheets", "crm"],
6
6
  };
7
7
  //# sourceMappingURL=backend.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"backend.js","sourceRoot":"","sources":["../../jsr/llms/backend.ts"],"names":[],"mappings":"AASA,MAAM,CAAC,MAAM,aAAa,GAAc;IACtC,IAAI,EAAE,SAAS;IACf,KAAK,EAAE,YAAY;IACnB,WAAW,EACT,uXAAuX;IAOzX,IAAI,EAAE,CAAC,QAAQ,EAAE,YAAY,CAAC;CAC/B,CAAC"}
1
+ {"version":3,"file":"backend.js","sourceRoot":"","sources":["../../jsr/llms/backend.ts"],"names":[],"mappings":"AAYA,MAAM,CAAC,MAAM,aAAa,GAAc;IACtC,IAAI,EAAE,SAAS;IACf,KAAK,EAAE,YAAY;IACnB,WAAW,EACT,ylBAAylB;IAO3lB,IAAI,EAAE,CAAC,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,YAAY,EAAE,SAAS,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,CAAC;CAC3G,CAAC"}
package/llms/backend.md CHANGED
@@ -24,7 +24,9 @@ create or mention `backend.js`.
24
24
  "form POST", "Stripe/GitHub/Sonos sends us…". Something **external → us**.
25
25
  - **`onChange`** — "when X is created", "after a write", "notify", "send an
26
26
  email", "aggregate", "moderate", "sync to…". An **internal write → a side
27
- effect**.
27
+ effect**: `ctx.notify` for every person the app emails, notifies or reminds,
28
+ and a Zapier Catch Hook for a tool the person names (Slack, Google Sheets, a
29
+ CRM).
28
30
  - **`scheduled`** — "every N minutes", "poll", "check periodically", "cron",
29
31
  "cleanup", "digest". **Timer-driven**, no user in the loop.
30
32
 
@@ -308,32 +310,38 @@ const gh = await ctx.github.fetch("/repos/acme/site/issues", { method: "POST", b
308
310
  CORS headers (`api.github.com` is seeded; additions are a PR to the platform
309
311
  repo).
310
312
 
311
- **Sending a record to another service goes through Zapier.**
312
- `hooks.zapier.com` is on that list for a `POST` to a Catch Hook, so an app can
313
- hand a new record to any tool Zapier reaches — Clio, Slack, Google Sheets,
314
- email — without holding that tool's credentials. The owner makes a Zap whose
315
- trigger is "Webhooks by Zapier → Catch Hook", saves the hook URL as a secret
316
- (`vibes-diy secrets set ZAPIER_HOOK_URL ...`), and the backend posts JSON to
317
- `ctx.secrets.ZAPIER_HOOK_URL`. The URL **is** the credential: never write it
318
- into a document or a response body. Treat any 2xx as accepted and mark the
319
- record sent; on anything else **throw** — a handler that returns normally has
320
- acknowledged the event, so the platform will not call it again for that write,
321
- while a thrown `onChange` is re-delivered with backoff (a handful of tries over
322
- a few minutes) and a run that never succeeds lands in the app's logs.
323
-
324
- **Zapier is the last door, never the first.**
325
- Reach for it only when the person names Zapier — a Zap, a Catch Hook, a hook URL
326
- they already made — or when the destination has no other admitted door. A public
327
- read, a CORS-open endpoint, the connected GitHub repo, or simply keeping the
328
- record in this app and showing it on the owner's screen all come first, and a
329
- record the app already holds needs no hook at all. On a build that was just made,
330
- say what the app does on its own and leave the Zap as a next step the owner can
331
- ask for.
313
+ **Emailing, notifying or reminding a person is `ctx.notify`.**
314
+ `ctx.notify` reaches a person in the app by handle — the owner, a member, a
315
+ manager — on their bell, their push and their email, so "email the manager
316
+ every Monday", "email me a weekly digest" and "send the owner a summary" are
317
+ each a `scheduled` handler that calls `ctx.notify`. A recipient who is not in
318
+ the app yet joins it through the app's invite: the person picker takes their
319
+ handle, or takes their email address and mails them an invite, and once they
320
+ arrive every notification reaches them. Keep the record in the app too, so the app's
321
+ own screen shows what went out.
322
+
323
+ **A Catch Hook carries a record to a tool the person names.** When the ask
324
+ names Slack, Google Sheets, a CRM, Airtable, Notion, Clio, HubSpot or Zapier
325
+ itself, `hooks.zapier.com` is on the curated list for a `POST` to a Catch Hook, so
326
+ the app hands the new record to that tool without holding its credentials.
327
+ The owner makes a Zap whose trigger is "Webhooks by Zapier → Catch Hook",
328
+ saves the hook URL as a secret (`vibes-diy secrets set ZAPIER_HOOK_URL ...`),
329
+ and the backend posts JSON to `ctx.secrets.ZAPIER_HOOK_URL`. The URL **is**
330
+ the credential, so it stays in the secret, out of every document and response
331
+ body. Write that path gated on the secret
332
+ (`if (!ctx.secrets.ZAPIER_HOOK_URL) return;`), so the app works fully before
333
+ any Zap exists, and say in your reply that connecting a Zap to the hook turns
334
+ that delivery on. Treat any 2xx as accepted and mark the record sent; on
335
+ anything else **throw** — a handler that returns normally has acknowledged
336
+ the event, so the platform will not call it again for that write, while a
337
+ thrown `onChange` is re-delivered with backoff (a handful of tries over a few
338
+ minutes) and a run that never succeeds lands in the app's logs.
332
339
 
333
340
  ```js
334
341
  export async function onChange(event, ctx) {
335
342
  const doc = event.doc;
336
343
  if (event.deleted || doc.type !== "intake" || doc.sentToZapier) return;
344
+ if (!ctx.secrets.ZAPIER_HOOK_URL) return; // no Zap connected yet: the record stays in the app
337
345
  const res = await ctx.fetch(ctx.secrets.ZAPIER_HOOK_URL, {
338
346
  method: "POST",
339
347
  headers: { "content-type": "application/json" },
@@ -589,7 +597,7 @@ export async function scheduled(event, ctx) {
589
597
  ```jsx
590
598
  import React from "react";
591
599
  import { useFireproof } from "use-fireproof";
592
- import { Badge, Card, CardContent } from "@vibes.diy/look";
600
+ import { Badge, Heading, RowCard, Stack, Text } from "@vibes.diy/pouf";
593
601
 
594
602
  export default function App() {
595
603
  const { useLiveQuery } = useFireproof("nodeReleases");
@@ -600,26 +608,26 @@ export default function App() {
600
608
 
601
609
  return (
602
610
  <main className="min-h-screen bg-background text-foreground font-sans mx-auto max-w-2xl p-6">
603
- <h1 className="text-3xl font-bold">Node.js releases</h1>
604
- {status?.state === "egress-denied" && (
605
- <Badge variant="secondary" role="status" className="my-4">
606
- showing saved data — live refresh unavailable
607
- </Badge>
608
- )}
609
- <ul className="mt-6 space-y-3">
610
- {releases.map((release) => (
611
- <li key={release._id}>
612
- <Card>
613
- <CardContent>
614
- <a className="font-bold underline" href={release.url} target="_blank" rel="noreferrer">
615
- {release.title}
616
- </a>
617
- <time className="ml-3 text-muted-foreground">{release.publishedAt.slice(0, 10)}</time>
618
- </CardContent>
619
- </Card>
620
- </li>
621
- ))}
622
- </ul>
611
+ <Stack gap={5}>
612
+ <Heading level={1}>Node.js releases</Heading>
613
+ {status?.state === "egress-denied" && (
614
+ <div role="status">
615
+ <Badge tone="info">showing saved data — live refresh unavailable</Badge>
616
+ </div>
617
+ )}
618
+ <Stack gap={3}>
619
+ {releases.map((release) => (
620
+ <RowCard key={release._id}>
621
+ <a className="font-bold underline" href={release.url} target="_blank" rel="noreferrer">
622
+ {release.title}
623
+ </a>
624
+ <Text size="sm" muted>
625
+ {release.publishedAt.slice(0, 10)}
626
+ </Text>
627
+ </RowCard>
628
+ ))}
629
+ </Stack>
630
+ </Stack>
623
631
  </main>
624
632
  );
625
633
  }