@vibes.diy/prompts 14.3.64 → 14.3.65

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 (133) hide show
  1. package/llms/index.d.ts +29 -1
  2. package/llms/index.js +84 -0
  3. package/llms/index.js.map +1 -1
  4. package/llms/pouf-blog.d.ts +2 -0
  5. package/llms/pouf-blog.js +6 -0
  6. package/llms/pouf-blog.js.map +1 -0
  7. package/llms/pouf-blog.md +305 -0
  8. package/llms/pouf-booking.d.ts +2 -0
  9. package/llms/pouf-booking.js +6 -0
  10. package/llms/pouf-booking.js.map +1 -0
  11. package/llms/pouf-booking.md +397 -0
  12. package/llms/pouf-calendar.d.ts +2 -0
  13. package/llms/pouf-calendar.js +6 -0
  14. package/llms/pouf-calendar.js.map +1 -0
  15. package/llms/pouf-calendar.md +352 -0
  16. package/llms/pouf-chat.d.ts +2 -0
  17. package/llms/pouf-chat.js +6 -0
  18. package/llms/pouf-chat.js.map +1 -0
  19. package/llms/pouf-chat.md +220 -0
  20. package/llms/pouf-contact.d.ts +2 -0
  21. package/llms/pouf-contact.js +6 -0
  22. package/llms/pouf-contact.js.map +1 -0
  23. package/llms/pouf-contact.md +231 -0
  24. package/llms/pouf-course.d.ts +2 -0
  25. package/llms/pouf-course.js +6 -0
  26. package/llms/pouf-course.js.map +1 -0
  27. package/llms/pouf-course.md +299 -0
  28. package/llms/pouf-crm.d.ts +2 -0
  29. package/llms/pouf-crm.js +6 -0
  30. package/llms/pouf-crm.js.map +1 -0
  31. package/llms/pouf-crm.md +330 -0
  32. package/llms/pouf-dashboard.d.ts +2 -0
  33. package/llms/pouf-dashboard.js +6 -0
  34. package/llms/pouf-dashboard.js.map +1 -0
  35. package/llms/pouf-dashboard.md +269 -0
  36. package/llms/pouf-editorial.d.ts +2 -0
  37. package/llms/pouf-editorial.js +6 -0
  38. package/llms/pouf-editorial.js.map +1 -0
  39. package/llms/pouf-editorial.md +247 -0
  40. package/llms/pouf-event.d.ts +2 -0
  41. package/llms/pouf-event.js +6 -0
  42. package/llms/pouf-event.js.map +1 -0
  43. package/llms/pouf-event.md +237 -0
  44. package/llms/pouf-feed.d.ts +2 -0
  45. package/llms/pouf-feed.js +6 -0
  46. package/llms/pouf-feed.js.map +1 -0
  47. package/llms/pouf-feed.md +249 -0
  48. package/llms/pouf-game.d.ts +2 -0
  49. package/llms/pouf-game.js +6 -0
  50. package/llms/pouf-game.js.map +1 -0
  51. package/llms/pouf-game.md +273 -0
  52. package/llms/pouf-inbox.d.ts +2 -0
  53. package/llms/pouf-inbox.js +6 -0
  54. package/llms/pouf-inbox.js.map +1 -0
  55. package/llms/pouf-inbox.md +317 -0
  56. package/llms/pouf-inventory.d.ts +2 -0
  57. package/llms/pouf-inventory.js +6 -0
  58. package/llms/pouf-inventory.js.map +1 -0
  59. package/llms/pouf-inventory.md +345 -0
  60. package/llms/pouf-kanban.d.ts +2 -0
  61. package/llms/pouf-kanban.js +6 -0
  62. package/llms/pouf-kanban.js.map +1 -0
  63. package/llms/pouf-kanban.md +235 -0
  64. package/llms/pouf-landing.d.ts +2 -0
  65. package/llms/pouf-landing.js +6 -0
  66. package/llms/pouf-landing.js.map +1 -0
  67. package/llms/pouf-landing.md +222 -0
  68. package/llms/pouf-login.d.ts +2 -0
  69. package/llms/pouf-login.js +6 -0
  70. package/llms/pouf-login.js.map +1 -0
  71. package/llms/pouf-login.md +162 -0
  72. package/llms/pouf-music.d.ts +2 -0
  73. package/llms/pouf-music.js +6 -0
  74. package/llms/pouf-music.js.map +1 -0
  75. package/llms/pouf-music.md +190 -0
  76. package/llms/pouf-onboarding.d.ts +2 -0
  77. package/llms/pouf-onboarding.js +6 -0
  78. package/llms/pouf-onboarding.js.map +1 -0
  79. package/llms/pouf-onboarding.md +227 -0
  80. package/llms/pouf-pricing.d.ts +2 -0
  81. package/llms/pouf-pricing.js +6 -0
  82. package/llms/pouf-pricing.js.map +1 -0
  83. package/llms/pouf-pricing.md +193 -0
  84. package/llms/pouf-profile.d.ts +2 -0
  85. package/llms/pouf-profile.js +6 -0
  86. package/llms/pouf-profile.js.map +1 -0
  87. package/llms/pouf-profile.md +177 -0
  88. package/llms/pouf-quiz.d.ts +2 -0
  89. package/llms/pouf-quiz.js +6 -0
  90. package/llms/pouf-quiz.js.map +1 -0
  91. package/llms/pouf-quiz.md +267 -0
  92. package/llms/pouf-settings.d.ts +2 -0
  93. package/llms/pouf-settings.js +6 -0
  94. package/llms/pouf-settings.js.map +1 -0
  95. package/llms/pouf-settings.md +220 -0
  96. package/llms/pouf-storefront.d.ts +2 -0
  97. package/llms/pouf-storefront.js +6 -0
  98. package/llms/pouf-storefront.js.map +1 -0
  99. package/llms/pouf-storefront.md +293 -0
  100. package/llms/pouf-support.d.ts +2 -0
  101. package/llms/pouf-support.js +6 -0
  102. package/llms/pouf-support.js.map +1 -0
  103. package/llms/pouf-support.md +322 -0
  104. package/llms/pouf-testimonials.d.ts +2 -0
  105. package/llms/pouf-testimonials.js +6 -0
  106. package/llms/pouf-testimonials.js.map +1 -0
  107. package/llms/pouf-testimonials.md +156 -0
  108. package/llms/pouf-todo.d.ts +2 -0
  109. package/llms/pouf-todo.js +6 -0
  110. package/llms/pouf-todo.js.map +1 -0
  111. package/llms/pouf-todo.md +179 -0
  112. package/llms/pouf-weather.d.ts +2 -0
  113. package/llms/pouf-weather.js +6 -0
  114. package/llms/pouf-weather.js.map +1 -0
  115. package/llms/pouf-weather.md +186 -0
  116. package/package.json +4 -4
  117. package/prompts.js +2 -2
  118. package/prompts.js.map +1 -1
  119. package/themes/colorsets.d.ts +1 -1
  120. package/themes/colorsets.js +63 -1
  121. package/themes/colorsets.js.map +1 -1
  122. package/themes/index.d.ts +1 -0
  123. package/themes/index.js +1 -0
  124. package/themes/index.js.map +1 -1
  125. package/themes/looks/index.js +6 -2
  126. package/themes/looks/index.js.map +1 -1
  127. package/themes/looks/pouf/brief.md +144 -0
  128. package/themes/looks/pouf/catalog.generated.d.ts +16 -0
  129. package/themes/looks/pouf/catalog.generated.js +764 -0
  130. package/themes/looks/pouf/catalog.generated.js.map +1 -0
  131. package/themes/looks/pouf/variety.d.ts +4 -0
  132. package/themes/looks/pouf/variety.js +36 -0
  133. package/themes/looks/pouf/variety.js.map +1 -0
@@ -0,0 +1,6 @@
1
+ export const poufKanbanConfig = {
2
+ name: "pouf-kanban",
3
+ label: "Pouf kanban block",
4
+ description: "Kanban board with columns and cards you drag between them",
5
+ };
6
+ //# sourceMappingURL=pouf-kanban.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pouf-kanban.js","sourceRoot":"","sources":["../../jsr/llms/pouf-kanban.ts"],"names":[],"mappings":"AAMA,MAAM,CAAC,MAAM,gBAAgB,GAAc;IACzC,IAAI,EAAE,aAAa;IACnB,KAAK,EAAE,mBAAmB;IAC1B,WAAW,EAAE,2DAA2D;CACzE,CAAC"}
@@ -0,0 +1,235 @@
1
+ # Pouf Kanban
2
+
3
+ A board of cards in columns that move left to right as work gets done: a renovation's to do, doing and done, a sprint board, a hiring pipeline, a content calendar from idea to published. Reach for it whenever the app's core is items that pass through a fixed set of stages and the person wants to see every stage at once. Cards and the column each sits in persist, so the board looks the same on the next visit and for everyone who opens it.
4
+
5
+ Built from @vibes.diy/pouf components; persisted state lives in Fireproof. Every write surface is gated on `useVibe(dbName).can`.
6
+
7
+ ## The block
8
+
9
+ One view with one job: the board. The `Heading` and a muted count open the page, and a small form on a `Card` adds a card to the first column with `Add card` as the view's one lead. The columns sit in a `Grid cols={3}` (stacked on a phone), each with a `Heading level={2}` and a count `Badge` toned by meaning: `idle` for not started, `info` for under way, `up` for done. Each card is a `RowCard` with its title, its author as a `ViewerTag`, a quiet `Move` `IconButton` that opens a `DropdownMenu` of the other columns, and a quiet remove `IconButton` inside a `Confirm` wearing `tone="down"`. A card moves by dragging its title with pointer events, which cover mouse, pen and touch alike: `onPointerDown` captures the pointer, `onPointerMove` asks `document.elementFromPoint` which column is underneath, and `onPointerUp` writes that column to the card's document; only the primary button starts a drag, and `onPointerCancel`, `onLostPointerCapture` and the Escape key end one without moving the card; the `Move` menu does the same move from the keyboard or a screen reader. Every write waits on its `can` verdict from `useVibe("board")`: the add form shows a `Skeleton` inside an `aria-busy` wrapper while the verdict resolves and a sentence in the app's own words when adding is closed to this visitor; a card only drags and its `Move` menu is only enabled when `can.edit(card).ok`; and the remove button renders only where `can.delete(card).ok`.
10
+
11
+ ### `App.jsx`
12
+ ```jsx
13
+ import React, { useEffect, useState } from "react";
14
+ import { useFireproof } from "use-fireproof";
15
+ import { useViewer, useVibe } from "use-vibes";
16
+ import { Badge, Button, Card, Empty, Field, Grid, Heading, Icon, IconButton, Input, Row, RowCard, Skeleton, Stack, Text } from "@vibes.diy/pouf";
17
+ import { Confirm, DropdownMenu, Toaster, toast } from "@vibes.diy/pouf/interactive";
18
+
19
+ const COLUMNS = [
20
+ { key: "todo", title: "To do", tone: "idle" },
21
+ { key: "doing", title: "In progress", tone: "info" },
22
+ { key: "done", title: "Done", tone: "up" },
23
+ ];
24
+
25
+ /** The column under a screen point, read from the `data-column` wrapper around each column. */
26
+ function columnAt(x, y) {
27
+ return document.elementFromPoint(x, y)?.closest("[data-column]")?.getAttribute("data-column") ?? undefined;
28
+ }
29
+
30
+ function Author({ handle }) {
31
+ const { ViewerTag } = useViewer();
32
+ return handle ? <ViewerTag userHandle={handle} /> : <Text size="sm">A guest</Text>;
33
+ }
34
+
35
+ export default function App() {
36
+ const { database, useLiveQuery } = useFireproof("board");
37
+ const { docs } = useLiveQuery("type", { key: "card" });
38
+ const { can, me } = useVibe("board");
39
+ const cards = [...docs].sort((a, b) => (a.movedAt ?? a.createdAt) - (b.movedAt ?? b.createdAt));
40
+
41
+ const [draft, setDraft] = useState("");
42
+ const [error, setError] = useState(undefined);
43
+ // The card being dragged and the column under the pointer, while a drag is live.
44
+ const [drag, setDrag] = useState(undefined);
45
+ const dragging = drag !== undefined;
46
+
47
+ // Escape drops an in-progress drag where it started.
48
+ useEffect(() => {
49
+ if (!dragging) return;
50
+ const onKey = (e) => {
51
+ if (e.key === "Escape") setDrag(undefined);
52
+ };
53
+ window.addEventListener("keydown", onKey);
54
+ return () => window.removeEventListener("keydown", onKey);
55
+ }, [dragging]);
56
+ // The verdict for the doc addCard writes, carrying the same fields it stamps.
57
+ const canAdd = can.create({ type: "card", column: "todo", authorHandle: me?.userHandle });
58
+
59
+ async function addCard(e) {
60
+ e.preventDefault();
61
+ const title = draft.trim();
62
+ if (title === "") {
63
+ setError("Give the card a title first.");
64
+ return;
65
+ }
66
+ setError(undefined);
67
+ await database.put({ type: "card", title, column: "todo", authorHandle: me?.userHandle, createdAt: Date.now() });
68
+ // Cleared only once the write lands, so a local failure keeps the typed words.
69
+ setDraft("");
70
+ }
71
+
72
+ function moveCard(card, column) {
73
+ if (column === card.column) return;
74
+ database.put({ ...card, column, movedAt: Date.now() });
75
+ }
76
+
77
+ async function removeCard(card) {
78
+ await database.del(card._id);
79
+ toast.success(`Removed "${card.title}"`);
80
+ }
81
+
82
+ // Pointer events cover mouse, pen and touch; the handle's touch-none keeps a finger drag from scrolling the page.
83
+ function dragHandlers(card) {
84
+ if (!can.edit(card).ok) return {};
85
+ return {
86
+ onPointerDown: (e) => {
87
+ // Only the primary button (or a touch / pen contact) starts a drag.
88
+ if (e.button !== 0) return;
89
+ e.currentTarget.setPointerCapture(e.pointerId);
90
+ setDrag({ id: card._id, over: card.column });
91
+ },
92
+ onPointerMove: (e) => {
93
+ if (drag?.id !== card._id) return;
94
+ const over = columnAt(e.clientX, e.clientY);
95
+ if (over !== drag.over) setDrag({ id: card._id, over });
96
+ },
97
+ onPointerUp: (e) => {
98
+ if (drag?.id !== card._id) return;
99
+ const over = columnAt(e.clientX, e.clientY);
100
+ setDrag(undefined);
101
+ if (over) moveCard(card, over);
102
+ },
103
+ onPointerCancel: () => setDrag(undefined),
104
+ // Capture can be lost without a pointerup (an alert, a tab switch); the drag ends with it.
105
+ onLostPointerCapture: () => setDrag(undefined),
106
+ };
107
+ }
108
+
109
+ return (
110
+ <main className="mx-auto max-w-5xl px-4 py-8">
111
+ <Stack gap={5}>
112
+ <Stack gap={1}>
113
+ <Heading level={1}>Kitchen renovation</Heading>
114
+ <Text muted>{cards.length === 1 ? "1 card" : `${cards.length} cards`}</Text>
115
+ </Stack>
116
+
117
+ {canAdd.pending ? (
118
+ <div aria-busy="true">
119
+ <Skeleton variant="card" />
120
+ </div>
121
+ ) : !canAdd.ok ? (
122
+ <Text muted>You're viewing the board. Adding cards opens up once you're on the project.</Text>
123
+ ) : (
124
+ <Card>
125
+ <form onSubmit={addCard} noValidate>
126
+ <Row gap={3} align="top">
127
+ <div className="min-w-0 flex-1">
128
+ <Field label="New card" error={error}>
129
+ {(id, describedBy) => (
130
+ <Input
131
+ id={id}
132
+ describedBy={describedBy}
133
+ value={draft}
134
+ onChange={(value) => {
135
+ setDraft(value);
136
+ if (error) setError(undefined);
137
+ }}
138
+ placeholder="Order the tiles"
139
+ autoComplete="off"
140
+ invalid={error !== undefined}
141
+ />
142
+ )}
143
+ </Field>
144
+ </div>
145
+ <Button type="submit">Add card</Button>
146
+ </Row>
147
+ </form>
148
+ </Card>
149
+ )}
150
+
151
+ <Grid cols={3}>
152
+ {COLUMNS.map((col) => {
153
+ const inColumn = cards.filter((c) => c.column === col.key);
154
+ const isTarget = drag !== undefined && drag.over === col.key;
155
+ return (
156
+ <div key={col.key} data-column={col.key} className="min-h-40">
157
+ <Stack gap={3}>
158
+ <Row justify="between">
159
+ <Heading level={2}>{col.title}</Heading>
160
+ <Row gap={2}>
161
+ {isTarget && <Badge tone="info">Drop here</Badge>}
162
+ <Badge tone={col.tone}>{inColumn.length}</Badge>
163
+ </Row>
164
+ </Row>
165
+ {inColumn.length === 0 ? (
166
+ <Empty icon="draft" title="Nothing here">
167
+ Drag a card in, or use its Move menu.
168
+ </Empty>
169
+ ) : (
170
+ inColumn.map((card) => (
171
+ <div key={card._id} className={drag?.id === card._id ? "opacity-50" : undefined}>
172
+ <RowCard>
173
+ <Stack gap={2}>
174
+ <div className="cursor-grab touch-none select-none" {...dragHandlers(card)}>
175
+ <Text>{card.title}</Text>
176
+ </div>
177
+ <Row justify="between" wrap={false}>
178
+ <Author handle={card.authorHandle} />
179
+ <Row gap={1} wrap={false}>
180
+ <DropdownMenu
181
+ label={`Move "${card.title}"`}
182
+ items={COLUMNS.filter((c) => c.key !== card.column).map((c) => ({
183
+ label: `Move to ${c.title}`,
184
+ onClick: () => moveCard(card, c.key),
185
+ }))}
186
+ >
187
+ <IconButton
188
+ size="sm"
189
+ disabled={!can.edit(card).ok}
190
+ label={`Move "${card.title}"`}
191
+ icon={<Icon name="next" size="sm" />}
192
+ />
193
+ </DropdownMenu>
194
+ {can.delete(card).ok && (
195
+ <Confirm
196
+ title="Remove this card?"
197
+ body={`"${card.title}" is taken off the board for everyone. This can't be undone.`}
198
+ confirmLabel="Remove card"
199
+ cancelLabel="Keep it"
200
+ tone="down"
201
+ onConfirm={() => removeCard(card)}
202
+ >
203
+ <IconButton size="sm" label={`Remove "${card.title}"`} icon={<Icon name="remove" size="sm" />} />
204
+ </Confirm>
205
+ )}
206
+ </Row>
207
+ </Row>
208
+ </Stack>
209
+ </RowCard>
210
+ </div>
211
+ ))
212
+ )}
213
+ </Stack>
214
+ </div>
215
+ );
216
+ })}
217
+ </Grid>
218
+ </Stack>
219
+ <div className="pouf-toasts">
220
+ <Toaster />
221
+ </div>
222
+ </main>
223
+ );
224
+ }
225
+ ```
226
+
227
+ ## Adapting
228
+
229
+ - Rename the columns to the app's own stages (`Idea`, `Drafting`, `Published`) in `COLUMNS`, and tone each count by what the stage means: `idle` for not started, `info` or `warn` for under way, `up` for finished.
230
+ - Keep the column on the card's own document; moving a card is one `put` of that document, never a second list of ids per column.
231
+ - Keep the drag on pointer events with `elementFromPoint` and the `Move` menu beside it: the drag serves mouse and touch, the menu serves keyboards and screen readers.
232
+ - A priority or due date is one more field on the card and one more `Badge` with an explicit meaning tone (`warn` for due this week).
233
+ - A new write surface gets its own verdict: `can.create(draft)` with the fields the write stamps, `can.edit(doc)` or `can.delete(doc)` on the document it changes, always on `useVibe` of the database it writes.
234
+
235
+ Adapted from 1st-Pouf (MIT) https://1st-pouf.worksonmy.dev/r/kanban.json
@@ -0,0 +1,2 @@
1
+ import type { LlmConfig } from "./types.js";
2
+ export declare const poufLandingConfig: LlmConfig;
@@ -0,0 +1,6 @@
1
+ export const poufLandingConfig = {
2
+ name: "pouf-landing",
3
+ label: "Pouf landing block",
4
+ description: "Marketing landing page with hero, features, story and a waitlist sign-up",
5
+ };
6
+ //# sourceMappingURL=pouf-landing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pouf-landing.js","sourceRoot":"","sources":["../../jsr/llms/pouf-landing.ts"],"names":[],"mappings":"AAMA,MAAM,CAAC,MAAM,iBAAiB,GAAc;IAC1C,IAAI,EAAE,cAAc;IACpB,KAAK,EAAE,oBAAoB;IAC3B,WAAW,EAAE,0EAA0E;CACxF,CAAC"}
@@ -0,0 +1,222 @@
1
+ # Pouf Landing
2
+
3
+ A one-page marketing site with a waitlist: the launch page for a pottery class, a neighbourhood tool library, a new podcast, a supper club, a side-project app before it opens. Reach for it when the app's job is to explain one thing well (a hero, what it does, a story from someone who tried it) and to let interested people put their name down. Each person on the waitlist is one small document, so the count on the page is real and nobody signs up twice.
4
+
5
+ Built from @vibes.diy/pouf components; persisted state lives in Fireproof. Every write surface is gated on `useVibe(dbName).can`.
6
+
7
+ ## The block
8
+
9
+ One page with one job: get the reader onto the waitlist. A `Navbar` carries the brand (a decorative `orange` `Blob` and the name), in-page links and a quiet `Join` that scrolls to the sign-up. The hero is a `Grid cols="sidebar"`: an `info` `Badge`, the `Heading level={1}` with one word in an `orange` `Highlight`, a muted pitch, the waitlist action and a muted count of who has joined; beside it a `Card` of `Metric`s says what the reader gets. The waitlist action is the page's one lead: `Join the waitlist`, with `loading` from the click until the live query shows the new sign-up, so the button can't reappear and fire twice in between. Once joined it turns into an `up` `Badge` and a quiet `Leave the list`. Three feature `Card`s (each fronted by a decorative `orange` `Blob`, tilted with `motion`) explain how it works, a `Card` holds one story from an early user, and a closing `CTA` on the quiet `surface` tone sends the reader back up to the sign-up with a quiet button, so the page never shows two leads. A `Footer` repeats the in-page links. Joining waits on its `can` verdict from `useVibe("waitlist")`, the database the sign-ups live in: a `Skeleton` inside an `aria-busy` wrapper while it resolves, a `Sign in to join the waitlist` lead wired to `useViewer().requestLogin()` for a visitor who has not signed in, and a neutral sentence in the app's own words when sign-ups are closed. `Leave the list` renders only where `can.delete` allows it. The list keeps who joined and when, never an email address.
10
+
11
+ ### `App.jsx`
12
+ ```jsx
13
+ import React, { useEffect, useState } from "react";
14
+ import { useFireproof } from "use-fireproof";
15
+ import { useViewer, useVibe } from "use-vibes";
16
+ import { Badge, Blob, Button, Card, CTA, Eyebrow, Footer, Grid, Heading, Highlight, Metric, Navbar, Row, Skeleton, Stack, Text } from "@vibes.diy/pouf";
17
+
18
+ const NAME = "The Tool Shed";
19
+ const STEPS = [
20
+ { icon: "search", title: "Find the tool", body: "Browse the drills, ladders, saws and sanders your neighbours have put in the shed." },
21
+ { icon: "calendar", title: "Borrow it for a week", body: "Pick it up on Saturday morning, bring it back the next. No fee, no deposit." },
22
+ { icon: "users", title: "Lend one back", body: "Got a tool that sits in a drawer? Add it, and someone down the road gets the job done." },
23
+ ];
24
+ const LINKS = [
25
+ { label: "How it works", href: "#how" },
26
+ { label: "Stories", href: "#stories" },
27
+ ];
28
+
29
+ const brand = (
30
+ <Row gap={2} wrap={false}>
31
+ <Blob icon="home" tone="orange" size="sm" />
32
+ <Text>{NAME}</Text>
33
+ </Row>
34
+ );
35
+ const people = (n) => (n === 1 ? "1 neighbour has" : `${n} neighbours have`);
36
+
37
+ function scrollToJoin() {
38
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
39
+ document.getElementById("join")?.scrollIntoView({ behavior: reduce ? "auto" : "smooth", block: "nearest" });
40
+ }
41
+
42
+ export default function App() {
43
+ const { database, useLiveQuery } = useFireproof("waitlist");
44
+ const { docs: signups } = useLiveQuery("type", { key: "signup" });
45
+ const { can, me } = useVibe("waitlist");
46
+ const { requestLogin } = useViewer();
47
+ // What an in-flight write is heading for: true while joining, false while leaving.
48
+ const [awaiting, setAwaiting] = useState(undefined);
49
+
50
+ const mine = me ? signups.find((s) => s.authorHandle === me.userHandle) : undefined;
51
+ const joined = Boolean(mine);
52
+ // The write resolves before the live query shows it, so the button stays busy until it does.
53
+ const inFlight = awaiting !== undefined && awaiting !== joined;
54
+ useEffect(() => {
55
+ if (awaiting === joined) setAwaiting(undefined);
56
+ }, [awaiting, joined]);
57
+ // The sign-up this visitor would write: a deterministic id, so joining twice lands on one document.
58
+ const draft = { _id: `signup:${me?.userHandle}`, type: "signup", authorHandle: me?.userHandle };
59
+ const verdict = mine ? can.delete(mine) : can.create(draft);
60
+
61
+ async function join() {
62
+ setAwaiting(true);
63
+ try {
64
+ await database.put({ ...draft, createdAt: Date.now() });
65
+ } catch (err) {
66
+ // A failed write never reaches the live query, so release the button here.
67
+ setAwaiting(undefined);
68
+ throw err;
69
+ }
70
+ }
71
+
72
+ async function leave() {
73
+ setAwaiting(false);
74
+ try {
75
+ await database.del(mine._id);
76
+ } catch (err) {
77
+ setAwaiting(undefined);
78
+ throw err;
79
+ }
80
+ }
81
+
82
+ let action;
83
+ if (verdict.pending)
84
+ action = (
85
+ <div aria-busy="true">
86
+ <Skeleton variant="row" />
87
+ </div>
88
+ );
89
+ else if (!me)
90
+ action = (
91
+ <Button size="lg" onClick={requestLogin}>
92
+ Sign in to join the waitlist
93
+ </Button>
94
+ );
95
+ else if (mine)
96
+ action = (
97
+ <Row gap={3}>
98
+ <Badge tone="up">You're on the list</Badge>
99
+ {verdict.ok && (
100
+ <Button variant="quiet" loading={inFlight} onClick={leave}>
101
+ Leave the list
102
+ </Button>
103
+ )}
104
+ </Row>
105
+ );
106
+ else if (!verdict.ok) action = <Text muted>Sign-ups aren't open right now.</Text>;
107
+ else
108
+ action = (
109
+ <Button size="lg" loading={inFlight} onClick={join}>
110
+ Join the waitlist
111
+ </Button>
112
+ );
113
+
114
+ return (
115
+ <main className="mx-auto max-w-5xl px-4 py-6">
116
+ <Stack gap={6}>
117
+ <Navbar
118
+ brand={brand}
119
+ links={LINKS}
120
+ actions={
121
+ <Button size="sm" variant="quiet" onClick={scrollToJoin}>
122
+ Join
123
+ </Button>
124
+ }
125
+ />
126
+
127
+ <Grid cols="sidebar" gap={5}>
128
+ <Stack gap={4}>
129
+ <Row>
130
+ <Badge tone="info">Opening this spring</Badge>
131
+ </Row>
132
+ <Heading level={1}>
133
+ Borrow the <Highlight tone="orange">drill</Highlight>, skip the shop.
134
+ </Heading>
135
+ <Text muted>A lending library for tools, run by the street. Take what you need for a week, bring it back, and lend what you can.</Text>
136
+ <div id="join">{action}</div>
137
+ <Text size="sm" muted>
138
+ {signups.length === 0 ? "Be the first on the list." : `${people(signups.length)} joined so far.`}
139
+ </Text>
140
+ </Stack>
141
+
142
+ <Card>
143
+ <Stack gap={4}>
144
+ <Stack gap={1}>
145
+ <Eyebrow>At opening</Eyebrow>
146
+ <Heading level={3}>What's in the shed</Heading>
147
+ </Stack>
148
+ <Grid cols={2}>
149
+ <Metric label="Tools" value="140" />
150
+ <Metric label="Lenders" value="32" />
151
+ <Metric label="Loan" value="One week" num={false} />
152
+ <Metric label="Cost" value="Free" num={false} />
153
+ </Grid>
154
+ </Stack>
155
+ </Card>
156
+ </Grid>
157
+
158
+ <section id="how">
159
+ <Stack gap={4}>
160
+ <Stack gap={1}>
161
+ <Eyebrow>How it works</Eyebrow>
162
+ <Heading level={2}>Three steps, one shed</Heading>
163
+ </Stack>
164
+ <Grid cols={3}>
165
+ {STEPS.map((s, i) => (
166
+ <Card key={s.title} motion={i % 2 === 0 ? "tilt-left" : "tilt-right"}>
167
+ <Stack gap={3}>
168
+ <Blob icon={s.icon} tone="orange" />
169
+ <Heading level={3}>{s.title}</Heading>
170
+ <Text size="sm" muted>
171
+ {s.body}
172
+ </Text>
173
+ </Stack>
174
+ </Card>
175
+ ))}
176
+ </Grid>
177
+ </Stack>
178
+ </section>
179
+
180
+ <section id="stories">
181
+ <Card>
182
+ <Stack gap={3}>
183
+ <Eyebrow>From the trial run</Eyebrow>
184
+ <Heading level={2}>“I hung every shelf in the flat with a borrowed drill.”</Heading>
185
+ <Text muted>We ran the shed for a month on one street last autumn. Forty loans, no lost tools, and a lot of finished jobs.</Text>
186
+ <Text size="sm">Priya, who lent her mitre saw</Text>
187
+ </Stack>
188
+ </Card>
189
+ </section>
190
+
191
+ <CTA
192
+ tone="surface"
193
+ title="Be first through the door"
194
+ description="Join the waitlist and we'll open the shed to the list first."
195
+ action={
196
+ <Button size="lg" variant="quiet" onClick={scrollToJoin}>
197
+ Join the waitlist
198
+ </Button>
199
+ }
200
+ />
201
+
202
+ <Footer
203
+ brand={brand}
204
+ tagline="A lending library for tools, run by the street."
205
+ columns={[{ title: "On this page", links: LINKS }]}
206
+ note="Run by volunteers. Tools are lent as they are; check them before you start."
207
+ />
208
+ </Stack>
209
+ </main>
210
+ );
211
+ }
212
+ ```
213
+
214
+ ## Adapting
215
+
216
+ - Change the pitch, not the shape: one hero with the one lead, a few feature `Card`s, one story and a closing `CTA` on `tone="surface"` whose button is quiet and scrolls back to the sign-up. A second solid button anywhere on the page is a second lead.
217
+ - The waitlist keeps one document per person with a deterministic `_id` (`signup:<handle>`), so the count is `signups.length` and joining twice is harmless. To show who joined, list them with `useViewer()`'s `ViewerTag` from the stored `authorHandle`.
218
+ - Never store an email address or phone number on the sign-up: everything in this database is readable by anyone who opens the app. If the organiser needs a way to reach people, keep contact details on a separate document that an access function lets only its owner and the organiser read.
219
+ - Paid plans with a billing-cycle toggle belong to the pricing block; a page that sells a time slot belongs to the booking block. Link to either from the `Navbar` rather than growing this page.
220
+ - A new write surface gets its own verdict: `can.create(draft)` with the fields the write stamps, `can.edit(doc)` or `can.delete(doc)` on the document it changes, always on `useVibe` of the database it writes.
221
+
222
+ Adapted from 1st-Pouf (MIT) https://1st-pouf.worksonmy.dev/r/landing.json
@@ -0,0 +1,2 @@
1
+ import type { LlmConfig } from "./types.js";
2
+ export declare const poufLoginConfig: LlmConfig;
@@ -0,0 +1,6 @@
1
+ export const poufLoginConfig = {
2
+ name: "pouf-login",
3
+ label: "Pouf login block",
4
+ description: "App-local join form for display name and bio; not sign-in auth",
5
+ };
6
+ //# sourceMappingURL=pouf-login.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pouf-login.js","sourceRoot":"","sources":["../../jsr/llms/pouf-login.ts"],"names":[],"mappings":"AASA,MAAM,CAAC,MAAM,eAAe,GAAc;IACxC,IAAI,EAAE,YAAY;IAClB,KAAK,EAAE,kBAAkB;IACzB,WAAW,EAAE,gEAAgE;CAC9E,CAAC"}
@@ -0,0 +1,162 @@
1
+ # Pouf Login
2
+
3
+ The join step a member sees once they are signed in: a running club's "tell us who you are", a writing group's member card, a team space's first-visit profile. Reach for it whenever the app wants its own profile for each person (the name they go by here, a short bio) before they take part. It is **not** a sign-in form: the platform owns sign-in, so this block never asks for an email, a password or a magic link. The visitor signs in through `useViewer().requestLogin()`, and only then fills in the profile the app keeps. Each profile persists, so a member joins once and finds their card on every visit and device.
4
+
5
+ Built from @vibes.diy/pouf components; persisted state lives in Fireproof. Every write surface is gated on `useVibe(dbName).can`.
6
+
7
+ ## The block
8
+
9
+ One centred `Card` with one job: get this person a profile. A `Blob` (`info`, user), the `Heading` and a muted line open it, and a muted count says how many members have joined. A visitor who has not signed in gets a `Sign in to join` button, the card's one lead, wired to `requestLogin()`; there is no email or password field anywhere, because sign-in is the platform's. A signed-in visitor gets the profile form: a display name `Input` and a short-bio `Textarea`, each in its own `Field` with its own error and `invalid` control, validated in plain state on submit; `Join the club` (or `Save profile` once they have one) is the lead and shows `loading` while the write lands. Each profile is one document with a deterministic `_id` per person (`profile:<handle>`), so saving twice updates the same card instead of adding a second. The write waits on its `can` verdict from `useVibe("members")`: `can.create` of the draft for a new profile, `can.edit` of the saved one after that; while the verdict resolves a `Skeleton` sits inside an `aria-busy` wrapper, and a sentence in the app's own words replaces the form when joining is closed to this visitor. A saved profile shows as a `Badge` toned `up` and a `toast`.
10
+
11
+ ### `App.jsx`
12
+ ```jsx
13
+ import React, { useState } from "react";
14
+ import { useFireproof } from "use-fireproof";
15
+ import { useViewer, useVibe } from "use-vibes";
16
+ import { Badge, Blob, Button, Card, Field, Heading, Input, Row, Skeleton, Stack, Text, Textarea } from "@vibes.diy/pouf";
17
+ import { Toaster, toast } from "@vibes.diy/pouf/interactive";
18
+
19
+ const BIO_MAX = 160;
20
+
21
+ function check(form) {
22
+ const name = form.name.trim();
23
+ return {
24
+ name: name.length < 2 ? "Give a name of at least two characters." : name.length > 40 ? "Keep the name under 40 characters." : undefined,
25
+ bio: form.bio.trim().length > BIO_MAX ? `Keep the bio to ${BIO_MAX} characters.` : undefined,
26
+ };
27
+ }
28
+
29
+ function ProfileForm({ saved, database, can, me }) {
30
+ // Prefilled from the saved profile, so editing starts from what is there.
31
+ const [form, setForm] = useState({ name: saved?.name ?? "", bio: saved?.bio ?? "" });
32
+ const [errors, setErrors] = useState({});
33
+ const [saving, setSaving] = useState(false);
34
+ // The profile this form writes: one per person, so a second save lands on the same document.
35
+ const draft = { _id: `profile:${me.userHandle}`, type: "profile", authorHandle: me.userHandle };
36
+ const verdict = saved ? can.edit(saved) : can.create(draft);
37
+
38
+ const set = (key) => (value) => {
39
+ setForm((f) => ({ ...f, [key]: value }));
40
+ if (errors[key]) setErrors((e) => ({ ...e, [key]: undefined }));
41
+ };
42
+
43
+ async function save(e) {
44
+ e.preventDefault();
45
+ const found = check(form);
46
+ setErrors(found);
47
+ if (found.name || found.bio) return;
48
+ setSaving(true);
49
+ try {
50
+ await database.put({ ...saved, ...draft, name: form.name.trim(), bio: form.bio.trim(), joinedAt: saved?.joinedAt ?? Date.now() });
51
+ toast.success(saved ? "Profile saved" : `Welcome, ${form.name.trim()}`);
52
+ } finally {
53
+ setSaving(false);
54
+ }
55
+ }
56
+
57
+ if (verdict.pending)
58
+ return (
59
+ <div aria-busy="true">
60
+ <Skeleton variant="card" />
61
+ </div>
62
+ );
63
+ if (!verdict.ok) return <Text muted>Joining is open to invited members. Ask the organiser to add you.</Text>;
64
+
65
+ return (
66
+ <form onSubmit={save} noValidate>
67
+ <Stack gap={4}>
68
+ {saved && (
69
+ <Row>
70
+ <Badge tone="up">You're a member</Badge>
71
+ </Row>
72
+ )}
73
+ <Field label="Display name" hint="How other members see you here." error={errors.name}>
74
+ {(id, describedBy) => (
75
+ <Input
76
+ id={id}
77
+ describedBy={describedBy}
78
+ value={form.name}
79
+ onChange={set("name")}
80
+ autoComplete="nickname"
81
+ placeholder="Sam from Tuesday runs"
82
+ invalid={errors.name !== undefined}
83
+ />
84
+ )}
85
+ </Field>
86
+ <Field label="A line about you" hint={`Optional, up to ${BIO_MAX} characters.`} error={errors.bio}>
87
+ {(id, describedBy) => (
88
+ <Textarea
89
+ id={id}
90
+ describedBy={describedBy}
91
+ value={form.bio}
92
+ onChange={set("bio")}
93
+ rows={3}
94
+ placeholder="Training for my first half marathon."
95
+ invalid={errors.bio !== undefined}
96
+ />
97
+ )}
98
+ </Field>
99
+ <Button type="submit" block loading={saving}>
100
+ {saved ? "Save profile" : "Join the club"}
101
+ </Button>
102
+ </Stack>
103
+ </form>
104
+ );
105
+ }
106
+
107
+ export default function App() {
108
+ const { database, useLiveQuery } = useFireproof("members");
109
+ const { docs: profiles } = useLiveQuery("type", { key: "profile" });
110
+ const { can, me, ready } = useVibe("members");
111
+ const { requestLogin } = useViewer();
112
+ const saved = me ? profiles.find((p) => p._id === `profile:${me.userHandle}`) : undefined;
113
+
114
+ return (
115
+ <main className="mx-auto max-w-md px-4 py-12">
116
+ <Card>
117
+ <Stack gap={5}>
118
+ <Stack gap={3}>
119
+ <Blob icon="user" tone="info" />
120
+ <Stack gap={1}>
121
+ <Heading level={1}>{saved ? `Hi, ${saved.name}` : "Join the club"}</Heading>
122
+ <Text muted>{saved ? "Update how other members see you." : "Pick the name you go by here and say hello."}</Text>
123
+ <Text size="sm" muted>
124
+ {profiles.length === 1 ? "1 member so far" : `${profiles.length} members so far`}
125
+ </Text>
126
+ </Stack>
127
+ </Stack>
128
+
129
+ {!ready ? (
130
+ <div aria-busy="true">
131
+ <Skeleton variant="card" />
132
+ </div>
133
+ ) : !me ? (
134
+ // Sign-in is the platform's: no email or password here, just the ask.
135
+ <Stack gap={3}>
136
+ <Text muted>Sign in first, then choose your display name.</Text>
137
+ <Button block onClick={requestLogin}>
138
+ Sign in to join
139
+ </Button>
140
+ </Stack>
141
+ ) : (
142
+ <ProfileForm key={saved ? "edit" : "new"} saved={saved} database={database} can={can} me={me} />
143
+ )}
144
+ </Stack>
145
+ </Card>
146
+ <div className="pouf-toasts">
147
+ <Toaster />
148
+ </div>
149
+ </main>
150
+ );
151
+ }
152
+ ```
153
+
154
+ ## Adapting
155
+
156
+ - Never turn this into a credential form: no email, password, magic link or "forgot password". Sign-in is `useViewer().requestLogin()`, and the identity arrives on its own through `me`.
157
+ - Add a profile field (a pace group, a pronoun, a favourite genre) as one more `Field` and one more property on the profile; a fixed set of choices is a `RadioGroup` or `Select`, not free text.
158
+ - Show members to each other with `useViewer()`'s `<ViewerTag userHandle={profile.authorHandle} />` beside the display name, not a hand-made avatar.
159
+ - Keep one profile per person through the deterministic `_id`; a second document per person would split their card in two.
160
+ - A new write surface gets its own verdict: `can.create(draft)` with the fields the write stamps, `can.edit(doc)` or `can.delete(doc)` on the document it changes, always on `useVibe` of the database it writes.
161
+
162
+ Adapted from 1st-Pouf (MIT) https://1st-pouf.worksonmy.dev/r/login.json
@@ -0,0 +1,2 @@
1
+ import type { LlmConfig } from "./types.js";
2
+ export declare const poufMusicConfig: LlmConfig;