@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.
package/llms/access.md CHANGED
@@ -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 && (
@@ -550,9 +549,15 @@ import { useVibe } from "use-vibes";
550
549
  {docs.map((doc) => (
551
550
  <li key={doc._id}>
552
551
  {doc.text}
553
- <button onClick={() => database.put({ ...doc, favorite: !doc.favorite })}>
554
- {doc.favorite ? "★" : "☆"}
555
- </button>
552
+ <IconButton
553
+ size="sm"
554
+ variant={doc.favorite ? "solid" : "quiet"}
555
+ tone="warn"
556
+ aria-pressed={Boolean(doc.favorite)}
557
+ label={doc.favorite ? "Unfavorite" : "Favorite"}
558
+ icon={<Icon name="star" size="sm" />}
559
+ onClick={() => database.put({ ...doc, favorite: !doc.favorite })}
560
+ />
556
561
  </li>
557
562
  ))}
558
563
  </ul>
@@ -564,7 +569,7 @@ import { useVibe } from "use-vibes";
564
569
  >>>>>>> REPLACE
565
570
  ```
566
571
 
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.
572
+ 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
573
 
569
574
  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
575
 
@@ -636,12 +641,12 @@ App.jsx — `useVibe().can` gates every write surface (posts AND owner-only mana
636
641
  import React from "react";
637
642
  import { useFireproof } from "use-fireproof";
638
643
  import { useViewer, useVibe } from "use-vibes";
639
- import { Button, Textarea } from "@vibes.diy/look";
644
+ import { Button, Field, RowCard, Skeleton, Stack, Text, Textarea } from "@vibes.diy/pouf";
640
645
 
641
646
  export default function App() {
642
647
  const { viewer, isViewerPending, ViewerTag } = useViewer();
643
648
  const { database, useLiveQuery, access } = useFireproof("announcements");
644
- const { can } = useVibe("announcements");
649
+ const { can, ready } = useVibe("announcements");
645
650
 
646
651
  const { docs: posts } = useLiveQuery("type", { key: "post" });
647
652
  const [draft, setDraft] = React.useState("");
@@ -654,7 +659,9 @@ export default function App() {
654
659
  // ctx.requireRole("owner"), so this verdict is false for everyone but the owner.
655
660
  const canGrant = can.create({ type: "roleGrant", role: "poster", userHandle: "newUser" });
656
661
 
657
- if (isViewerPending) return null;
662
+ // Hold the page while identity and the access function resolve: every verdict
663
+ // reads pending until then, so the write surface waits on a quiet checking state.
664
+ if (isViewerPending || !ready) return <Skeleton />;
658
665
 
659
666
  async function submitPost() {
660
667
  if (!draft.trim() || !viewer) return;
@@ -669,7 +676,7 @@ export default function App() {
669
676
  }
670
677
 
671
678
  return (
672
- <div>
679
+ <Stack gap={4}>
673
680
  {/* No current-user pill / sign-in button here — that's system chrome in the
674
681
  Vibes Switch (the panel the logo opens). ViewerTag below renders post authors. */}
675
682
 
@@ -681,32 +688,34 @@ export default function App() {
681
688
  submitPost();
682
689
  }}
683
690
  >
684
- <Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className="w-full" />
685
- <Button type="submit">Post</Button>
691
+ <Stack gap={3}>
692
+ <Field label="Announcement">{(id, describedBy) => <Textarea id={id} describedBy={describedBy} value={draft} onChange={setDraft} />}</Field>
693
+ <Button type="submit">Post</Button>
694
+ </Stack>
686
695
  </form>
687
696
  ) : (
688
- viewer && <p className="text-muted-foreground">{canPost.reason}</p>
697
+ viewer && <Text muted>Posting opens up once the owner adds you as a poster.</Text>
689
698
  )}
690
699
 
691
700
  {/* owner-only management — gated on can.* */}
692
701
  {canGrant.ok && (
693
- <Button variant="secondary" onClick={() => database.put({ type: "roleGrant", role: "poster", userHandle: "newUser" })}>
702
+ <Button type="button" variant="quiet" onClick={() => database.put({ type: "roleGrant", role: "poster", userHandle: "newUser" })}>
694
703
  Grant poster role
695
704
  </Button>
696
705
  )}
697
706
 
698
707
  {posts.map((p) => (
699
- <div key={p._id}>
708
+ <RowCard key={p._id}>
700
709
  <ViewerTag userHandle={p.authorHandle} />
701
- <p>{p.body}</p>
710
+ <Text>{p.body}</Text>
702
711
  {can.delete(p).ok && (
703
- <Button variant="destructive" size="sm" onClick={() => database.del(p._id)}>
712
+ <Button type="button" variant="quiet" tone="down" size="sm" onClick={() => database.del(p._id)}>
704
713
  Delete
705
714
  </Button>
706
715
  )}
707
- </div>
716
+ </RowCard>
708
717
  ))}
709
- </div>
718
+ </Stack>
710
719
  );
711
720
  }
712
721
  ```
@@ -722,12 +731,12 @@ const canRevoke = can.delete(grantDoc);
722
731
  {(canAppoint.ok || canRevoke.ok) && (
723
732
  <ModeratorPanel>
724
733
  {canAppoint.ok && (
725
- <Button onClick={() => database.put({ type: "modGrant", role: "moderator", userHandle })}>Appoint</Button>
734
+ <Button type="button" onClick={() => database.put({ type: "modGrant", role: "moderator", userHandle })}>Appoint</Button>
726
735
  )}
727
736
  {canRevoke.ok ? (
728
- <Button variant="destructive" onClick={() => database.del(grantDoc._id)}>Revoke</Button>
737
+ <Button type="button" variant="quiet" tone="down" onClick={() => database.del(grantDoc._id)}>Revoke</Button>
729
738
  ) : (
730
- <p>{canRevoke.reason}</p>
739
+ <Text muted>{canRevoke.reason}</Text>
731
740
  )}
732
741
  </ModeratorPanel>
733
742
  )}
@@ -792,7 +801,7 @@ App.jsx — `access.hasChannel()` filters which channels are visible (display);
792
801
  =======
793
802
  {/* filter to channels the viewer can see — use _id as channel identifier (display only) */}
794
803
  {channels.filter((ch) => access.hasChannel(ch._id)).map((ch) => (
795
- <button key={ch._id} onClick={() => setChannel(ch._id)}>{ch.name}</button>
804
+ <Button key={ch._id} type="button" size="sm" variant={ch._id === channel ? "solid" : "quiet"} tone="info" onClick={() => setChannel(ch._id)}>{ch.name}</Button>
796
805
  ))}
797
806
 
798
807
  {/* gate the write surface on useVibe().can */}
@@ -863,18 +872,18 @@ App.jsx — `access.hasChannel()` shows only the lists the viewer belongs to; `u
863
872
 
864
873
  ```jsx
865
874
  <<<<<<< SEARCH
866
- {viewer && <button onClick={addNote}>+ note</button>}
875
+ {viewer && <Button type="button" onClick={addNote}>Add a note</Button>}
867
876
  =======
868
877
  {/* create list — any signed-in visitor can start their own */}
869
878
  {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>
879
+ <Button type="button" onClick={() => database.put({ type: "list", author: me.userHandle, name: "new list" })}>
880
+ New list
881
+ </Button>
873
882
  )}
874
883
 
875
884
  {visible.map((list) => (
876
885
  <section key={list._id}>
877
- <h3>{list.name}</h3>
886
+ <Heading level={2}>{list.name}</Heading>
878
887
  {/* add-item form is shown only when the access fn would accept the write */}
879
888
  {canAddItem(list) && <AddItemForm listId={list._id} />}
880
889
  {/* invite peer — only shown to members of this list */}
@@ -987,7 +996,7 @@ App.jsx — the same board list serves the creator and the invited recipient. `B
987
996
  ```jsx partial
988
997
  function BoardWorkspace({ ensureDefaultBoard }) {
989
998
  const { useLiveQuery } = useFireproof("boards");
990
- const { me, can } = useVibe("boards");
999
+ const { me, can, ready } = useVibe("boards");
991
1000
  const [pickedBoardId, setPickedBoardId] = React.useState();
992
1001
  const { docs: boards } = useLiveQuery("type", { key: "board" });
993
1002
  const ownBoardId = me?.userHandle ? `default-${me.userHandle}` : undefined;
@@ -1002,15 +1011,21 @@ function BoardWorkspace({ ensureDefaultBoard }) {
1002
1011
  boards[0];
1003
1012
  return (
1004
1013
  <section>
1005
- {ownBoardId && !boards.some((board) => board._id === ownBoardId) && can.create(ownBoard).ok && (
1006
- <button onClick={createOwnBoard}>Create my board</button>
1014
+ {ownBoardId && !boards.some((board) => board._id === ownBoardId) && (!ready || can.create(ownBoard).ok) && (
1015
+ <Button type="button" onClick={createOwnBoard} disabled={!ready}>Create my board</Button>
1007
1016
  )}
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>
1017
+ <Field label="Board">
1018
+ {(id, describedBy) => (
1019
+ <Select
1020
+ id={id}
1021
+ describedBy={describedBy}
1022
+ value={board?._id ?? ""}
1023
+ onChange={setPickedBoardId}
1024
+ disabled={boards.length === 0}
1025
+ options={boards.map((board) => ({ value: board._id, label: board.name ?? board._id }))}
1026
+ />
1027
+ )}
1028
+ </Field>
1014
1029
  {board && <BoardContents board={board} />}
1015
1030
  </section>
1016
1031
  );
@@ -1028,11 +1043,11 @@ const sharedWithMe = boards.filter((board) => board.creatorHandle !== me?.userHa
1028
1043
  // …
1029
1044
  {sharedWithMe.length > 0 && (
1030
1045
  <section>
1031
- <h2>Shared with you</h2>
1046
+ <Heading level={2}>Shared with you</Heading>
1032
1047
  {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>
1048
+ <RowCard key={board._id} onClick={() => setPickedBoardId(board._id)}>
1049
+ <Text>{board.name ?? board._id}</Text> <ViewerTag userHandle={board.creatorHandle} />
1050
+ </RowCard>
1036
1051
  ))}
1037
1052
  </section>
1038
1053
  )}
@@ -1137,13 +1152,13 @@ App.jsx — show only channels the viewer is in (`access.hasChannel`), gate the
1137
1152
  {viewer && <Compose onSend={send} />}
1138
1153
  =======
1139
1154
  {/* 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>
1155
+ <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
1156
 
1142
1157
  {/* compose is shown only when the viewer is a member of the selected channel */}
1143
1158
  {channelId && canPost.ok ? (
1144
1159
  <Compose onSend={(text) => database.put({ type: "message", channelId, userHandle: me.userHandle, text })} />
1145
1160
  ) : (
1146
- <p>{canPost.reason || "Pick a channel"}</p>
1161
+ <Text muted>{canPost.reason || "Pick a channel"}</Text>
1147
1162
  )}
1148
1163
 
1149
1164
  {/* any member of this channel may invite a peer */}
@@ -1203,7 +1218,7 @@ App.jsx — anonymous-friendly submit form, owner-only config panel, role-gated
1203
1218
 
1204
1219
  ```jsx
1205
1220
  <<<<<<< SEARCH
1206
- <input value={answer} onChange={(e) => setAnswer(e.target.value)} />
1221
+ <Field label="Your answer">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={answer} onChange={setAnswer} />}</Field>
1207
1222
  =======
1208
1223
  {/* anyone (signed in OR anonymous) can submit — only stamp authorHandle when present */}
1209
1224
  {canSubmit.ok ? (
@@ -1211,10 +1226,10 @@ App.jsx — anonymous-friendly submit form, owner-only config panel, role-gated
1211
1226
  e.preventDefault();
1212
1227
  database.put({ type: "survey-response", question: "q1", answer, ...(me && { authorHandle: me.userHandle }) });
1213
1228
  }}>
1214
- <input value={answer} onChange={(e) => setAnswer(e.target.value)} />
1215
- <button type="submit">Submit</button>
1229
+ <Field label="Your answer">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={answer} onChange={setAnswer} />}</Field>
1230
+ <Button type="submit">Submit</Button>
1216
1231
  </form>
1217
- ) : <p>{canSubmit.reason}</p>}
1232
+ ) : <Text muted>{canSubmit.reason}</Text>}
1218
1233
 
1219
1234
  {/* owner-only admin to wire up the feedback-team role */}
1220
1235
  {canConfigure.ok && <ConfigPanel />}
@@ -1242,8 +1257,38 @@ export function guestbook(doc, oldDoc, user, ctx) {
1242
1257
 
1243
1258
  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
1259
 
1260
+ The app before the edit, a note form wrapped in a sign-in wall:
1261
+
1262
+ ```jsx
1263
+ import { useState } from "react";
1264
+ import { useFireproof } from "use-fireproof";
1265
+ import { Field, Input, Stack } from "@vibes.diy/pouf";
1266
+ import { SignInGate } from "./SignInGate.jsx";
1267
+
1268
+ export default function App() {
1269
+ const { database } = useFireproof("notes");
1270
+ const [message, setMessage] = useState("");
1271
+ return (
1272
+ <Stack>
1273
+ <SignInGate>
1274
+ <Field label="Your note">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={message} onChange={setMessage} />}</Field>
1275
+ </SignInGate>
1276
+ </Stack>
1277
+ );
1278
+ }
1279
+ ```
1280
+
1245
1281
  App.jsx — gate the form on `can.create({ type: "entry" }).ok`, which is true even when nobody is signed in:
1246
1282
 
1283
+ ```jsx
1284
+ <<<<<<< SEARCH
1285
+ import { Field, Input, Stack } from "@vibes.diy/pouf";
1286
+ =======
1287
+ import { Button, Field, Input, RowCard, Stack, Text } from "@vibes.diy/pouf";
1288
+ import { useVibe } from "use-vibes";
1289
+ >>>>>>> REPLACE
1290
+ ```
1291
+
1247
1292
  ```jsx
1248
1293
  <<<<<<< SEARCH
1249
1294
  const { database } = useFireproof("notes");
@@ -1260,7 +1305,7 @@ App.jsx — gate the form on `can.create({ type: "entry" }).ok`, which is true e
1260
1305
  ```jsx
1261
1306
  <<<<<<< SEARCH
1262
1307
  <SignInGate>
1263
- <input value={message} onChange={(e) => setMessage(e.target.value)} />
1308
+ <Field label="Your note">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={message} onChange={setMessage} />}</Field>
1264
1309
  </SignInGate>
1265
1310
  =======
1266
1311
  {canSign.ok ? (
@@ -1270,11 +1315,11 @@ App.jsx — gate the form on `can.create({ type: "entry" }).ok`, which is true e
1270
1315
  database.put({ type: "entry", message, createdAt: Date.now(), ...(me && { authorHandle: me.userHandle }) });
1271
1316
  setMessage("");
1272
1317
  }}>
1273
- <input value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Leave a note" />
1274
- <button type="submit">Sign</button>
1318
+ <Field label="Your note">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={message} onChange={setMessage} placeholder="Leave a note" />}</Field>
1319
+ <Button type="submit">Sign</Button>
1275
1320
  </form>
1276
- ) : <p>{canSign.reason}</p>}
1321
+ ) : <Text muted>{canSign.reason}</Text>}
1277
1322
 
1278
- {entries.map((e) => <li key={e._id}>{e.message} — {e.authorHandle || "anonymous"}</li>)}
1323
+ {entries.map((e) => <RowCard key={e._id}><Text>{e.message} — {e.authorHandle || "anonymous"}</Text></RowCard>)}
1279
1324
  >>>>>>> REPLACE
1280
1325
  ```
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
  }
package/llms/bluesky.md CHANGED
@@ -264,6 +264,7 @@ account of the visitor's involved.
264
264
  ### `App.jsx`
265
265
  ```jsx app=bluesky-browser
266
266
  import React, { useState } from "react";
267
+ import { Button, Field, Input, Row, RowCard, Stack } from "@vibes.diy/pouf";
267
268
 
268
269
  export default function App() {
269
270
  const [handle, setHandle] = useState("bsky.app");
@@ -296,8 +297,10 @@ export default function App() {
296
297
 
297
298
  return (
298
299
  <div>
299
- <input value={handle} onChange={(e) => setHandle(e.target.value)} placeholder="alice.bsky.social" />
300
- <button onClick={() => load()}>Look up</button>
300
+ <Row gap={2} align="top">
301
+ <Field label="Bluesky handle">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={handle} onChange={setHandle} placeholder="alice.bsky.social" />}</Field>
302
+ <Button type="button" onClick={() => load()}>Look up</Button>
303
+ </Row>
301
304
  {problem && <p role="alert">{problem}</p>}
302
305
 
303
306
  {profile && (
@@ -314,20 +317,19 @@ export default function App() {
314
317
 
315
318
  <section>
316
319
  <h3>Following</h3>
317
- <ul>
320
+ <Stack gap={2}>
318
321
  {people.map((person) => (
319
- <li key={person.did}>
320
- <button
321
- onClick={() => {
322
- setHandle(person.handle);
323
- load(person.handle);
324
- }}
325
- >
326
- {person.displayName} (@{person.handle})
327
- </button>
328
- </li>
322
+ <RowCard
323
+ key={person.did}
324
+ onClick={() => {
325
+ setHandle(person.handle);
326
+ load(person.handle);
327
+ }}
328
+ >
329
+ {person.displayName} (@{person.handle})
330
+ </RowCard>
329
331
  ))}
330
- </ul>
332
+ </Stack>
331
333
  </section>
332
334
 
333
335
  <section>
@@ -0,0 +1,2 @@
1
+ import type { LlmConfig } from "./types.js";
2
+ export declare const boardGamesConfig: LlmConfig;
@@ -0,0 +1,29 @@
1
+ export const boardGamesConfig = {
2
+ name: "board-games",
3
+ label: "Board, tile and card games",
4
+ description: "Board, tile and card games played one tap, guess or card at a time: bingo cards dealt per player from a shared pool, " +
5
+ "daily word guesses with an on-screen keyboard and a share grid, crosswords, word searches, grouping puzzles, letter hives, " +
6
+ "sudoku, minesweeper, 2048, checkers, chess, go, hex, mancala, solitaire and other card games with hidden hands, trivia rooms " +
7
+ "and dice scoresheets with house rules. Covers SVG boards in the app's theme tokens (square, hex, ring, graph and stacked " +
8
+ "shapes), seeded deals and daily seeds, win-line detection, turns, undo, AI opponents, and who sees what — public tables, " +
9
+ "private hands, members-only clubs.",
10
+ cues: [
11
+ "bingo",
12
+ "word game",
13
+ "wordle",
14
+ "crossword",
15
+ "word search",
16
+ "sudoku",
17
+ "chess",
18
+ "checkers",
19
+ "solitaire",
20
+ "card game",
21
+ "board game",
22
+ "deck of cards",
23
+ "puzzle board",
24
+ "minesweeper",
25
+ "mancala",
26
+ "tic-tac-toe",
27
+ ],
28
+ };
29
+ //# sourceMappingURL=board-games.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"board-games.js","sourceRoot":"","sources":["../../jsr/llms/board-games.ts"],"names":[],"mappings":"AAKA,MAAM,CAAC,MAAM,gBAAgB,GAAc;IACzC,IAAI,EAAE,aAAa;IACnB,KAAK,EAAE,4BAA4B;IACnC,WAAW,EACT,uHAAuH;QACvH,6HAA6H;QAC7H,+HAA+H;QAC/H,2HAA2H;QAC3H,2HAA2H;QAC3H,oCAAoC;IACtC,IAAI,EAAE;QACJ,OAAO;QACP,WAAW;QACX,QAAQ;QACR,WAAW;QACX,aAAa;QACb,QAAQ;QACR,OAAO;QACP,UAAU;QACV,WAAW;QACX,WAAW;QACX,YAAY;QACZ,eAAe;QACf,cAAc;QACd,aAAa;QACb,SAAS;QACT,aAAa;KACd;CACF,CAAC"}