@vibes.diy/prompts 14.3.64 → 14.3.66

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,322 @@
1
+ # Pouf Support
2
+
3
+ A help desk where questions come in as tickets and get answered, chased and closed: a small shop's customer questions, an IT volunteer queue, a landlord's repair requests, a club's member help. Reach for it whenever the app's core is requests that each carry a status (open, waiting on the asker, solved) and get worked through by replying. Tickets, their status and every reply persist, so the queue is where it was left on the next visit and for everyone working 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
+ Two views, one job each. The queue opens with the `Heading` and a muted count of open tickets, a `Log a ticket` button (the queue's one lead) that opens a `Dialog` with the intake form, and a search `Input` and status `Select`, each in a `Field`. Each ticket is a clickable `RowCard` with its subject, a muted time and its status as a `Badge` toned by meaning (`info` open, `idle` waiting on the asker, `up` solved), plus a `warn` badge when urgent; an `Empty` stands in when nothing matches. Opening a ticket swaps in the ticket view: a quiet `IconButton` to go back, the subject as the `Heading`, the asker as a `ViewerTag`, the request and each reply on a `Card`, and a reply form validated in plain state (`Field error`, at least four characters) whose `Send reply` is that view's lead: a reply from the desk moves the ticket to waiting on the asker, and a reply from the asker (`me.userHandle` matching the ticket's `authorHandle`) moves it back to open. A quiet `Set waiting` and an `up`-toned `Mark solved` change the status directly, each confirmed with a `toast`. Every write waits on its `can` verdict from `useVibe("support")`: the intake and reply forms show a `Skeleton` inside an `aria-busy` wrapper while the verdict resolves and a sentence in the app's own words when that write is closed to this visitor, and the status buttons render only where `can.edit(ticket).ok`.
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, Button, Card, Empty, Field, Heading, Icon, IconButton, Input, Row, RowCard, Skeleton, Spacer, Stack, Text, Textarea } from "@vibes.diy/pouf";
17
+ import { Checkbox, Dialog, Select, Toaster, toast } from "@vibes.diy/pouf/interactive";
18
+
19
+ const STATUS = {
20
+ open: { label: "Open", tone: "info" },
21
+ waiting: { label: "Waiting on them", tone: "idle" },
22
+ solved: { label: "Solved", tone: "up" },
23
+ };
24
+
25
+ const FILTERS = [
26
+ { value: "all", label: "All tickets" },
27
+ { value: "open", label: "Open" },
28
+ { value: "waiting", label: "Waiting on them" },
29
+ { value: "solved", label: "Solved" },
30
+ ];
31
+
32
+ const when = (ms) => new Date(ms).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
33
+
34
+ function Author({ handle }) {
35
+ const { ViewerTag } = useViewer();
36
+ return handle ? <ViewerTag userHandle={handle} /> : <Text>A guest</Text>;
37
+ }
38
+
39
+ /** Three states for any create form: resolving, closed to this visitor, open. */
40
+ function Gate({ verdict, closed, children }) {
41
+ if (verdict.pending)
42
+ return (
43
+ <div aria-busy="true">
44
+ <Skeleton variant="card" />
45
+ </div>
46
+ );
47
+ if (!verdict.ok) return <Text muted>{closed}</Text>;
48
+ return children;
49
+ }
50
+
51
+ function Badges({ ticket }) {
52
+ return (
53
+ <Row gap={2}>
54
+ <Badge tone={STATUS[ticket.status].tone}>{STATUS[ticket.status].label}</Badge>
55
+ {ticket.urgent && <Badge tone="warn">Urgent</Badge>}
56
+ </Row>
57
+ );
58
+ }
59
+
60
+ function TicketView({ ticket, replies, database, can, me, onBack }) {
61
+ const [draft, setDraft] = useState("");
62
+ const [error, setError] = useState(undefined);
63
+ const canReply = can.create({ type: "reply", ticketId: ticket._id, authorHandle: me?.userHandle });
64
+ const canSetStatus = can.edit(ticket).ok;
65
+
66
+ async function changeStatus(status) {
67
+ await database.put({ ...ticket, status });
68
+ toast.success(status === "solved" ? "Marked solved" : "Waiting on them");
69
+ }
70
+
71
+ async function sendReply(e) {
72
+ e.preventDefault();
73
+ const text = draft.trim();
74
+ if (text.length < 4) {
75
+ setError("Write at least four characters.");
76
+ return;
77
+ }
78
+ setError(undefined);
79
+ await database.put({ type: "reply", ticketId: ticket._id, text, authorHandle: me?.userHandle, createdAt: Date.now() });
80
+ // The asker replying puts the ball back with the desk; anyone else replying waits on the asker.
81
+ const next = me?.userHandle && me.userHandle === ticket.authorHandle ? "open" : "waiting";
82
+ if (canSetStatus && ticket.status !== next) await database.put({ ...ticket, status: next });
83
+ // Cleared only once the write lands, so a local failure keeps the typed words.
84
+ setDraft("");
85
+ toast.success("Reply sent");
86
+ }
87
+
88
+ return (
89
+ <Stack gap={5}>
90
+ <Stack gap={2}>
91
+ <Row gap={2} wrap={false}>
92
+ <IconButton size="sm" onClick={onBack} label="Back to the queue" icon={<Icon name="prev" size="sm" />} />
93
+ <Heading level={1}>{ticket.subject}</Heading>
94
+ </Row>
95
+ <Badges ticket={ticket} />
96
+ </Stack>
97
+
98
+ <Card variant="tight">
99
+ <Stack gap={3}>
100
+ <Row gap={2} wrap={false}>
101
+ <Author handle={ticket.authorHandle} />
102
+ <Spacer />
103
+ <Text size="sm" muted>
104
+ {when(ticket.createdAt)}
105
+ </Text>
106
+ </Row>
107
+ <Text>{ticket.body}</Text>
108
+ </Stack>
109
+ </Card>
110
+
111
+ {replies.map((r) => (
112
+ <Card key={r._id} variant="tight">
113
+ <Stack gap={2}>
114
+ <Row gap={2} wrap={false}>
115
+ <Author handle={r.authorHandle} />
116
+ <Spacer />
117
+ <Text size="sm" muted>
118
+ {when(r.createdAt)}
119
+ </Text>
120
+ </Row>
121
+ <Text>{r.text}</Text>
122
+ </Stack>
123
+ </Card>
124
+ ))}
125
+
126
+ <Gate verdict={canReply} closed="Replies open up once you're on the help desk team.">
127
+ <form onSubmit={sendReply} noValidate>
128
+ <Stack gap={3}>
129
+ <Field label="Reply" hint="They see this on their ticket." error={error}>
130
+ {(id, describedBy) => (
131
+ <Textarea
132
+ id={id}
133
+ describedBy={describedBy}
134
+ value={draft}
135
+ onChange={(value) => {
136
+ setDraft(value);
137
+ if (error) setError(undefined);
138
+ }}
139
+ rows={4}
140
+ invalid={error !== undefined}
141
+ />
142
+ )}
143
+ </Field>
144
+ <Row gap={2} justify="end">
145
+ <Button type="button" variant="quiet" disabled={draft === ""} onClick={() => setDraft("")}>
146
+ Clear draft
147
+ </Button>
148
+ <Button type="submit">Send reply</Button>
149
+ </Row>
150
+ </Stack>
151
+ </form>
152
+ </Gate>
153
+
154
+ {canSetStatus && (
155
+ <Card variant="tight">
156
+ <Row justify="between">
157
+ <Text muted>{ticket.status === "solved" ? "This one is closed." : "Still needs attention."}</Text>
158
+ <Row gap={2}>
159
+ <Button size="sm" variant="quiet" disabled={ticket.status === "waiting"} onClick={() => changeStatus("waiting")}>
160
+ Set waiting
161
+ </Button>
162
+ <Button size="sm" tone="up" disabled={ticket.status === "solved"} onClick={() => changeStatus("solved")}>
163
+ <Icon name="ok" size="sm" /> Mark solved
164
+ </Button>
165
+ </Row>
166
+ </Row>
167
+ </Card>
168
+ )}
169
+ </Stack>
170
+ );
171
+ }
172
+
173
+ export default function App() {
174
+ const { database, useLiveQuery } = useFireproof("support");
175
+ const { can, me } = useVibe("support");
176
+ const { docs: ticketDocs } = useLiveQuery("type", { key: "ticket" });
177
+ const { docs: replies } = useLiveQuery("type", { key: "reply" });
178
+ const tickets = [...ticketDocs].sort((a, b) => b.createdAt - a.createdAt);
179
+
180
+ const [query, setQuery] = useState("");
181
+ const [status, setStatus] = useState("all");
182
+ const [openId, setOpenId] = useState(undefined);
183
+ const [logging, setLogging] = useState(false);
184
+ const [subject, setSubject] = useState("");
185
+ const [body, setBody] = useState("");
186
+ const [urgent, setUrgent] = useState(false);
187
+ const [errors, setErrors] = useState({});
188
+ // The verdict for the doc logTicket writes, carrying the same fields it stamps.
189
+ const canLog = can.create({ type: "ticket", status: "open", authorHandle: me?.userHandle });
190
+
191
+ const needle = query.trim().toLowerCase();
192
+ const shown = tickets.filter(
193
+ (t) => (status === "all" || t.status === status) && (needle === "" || `${t.subject} ${t.body}`.toLowerCase().includes(needle))
194
+ );
195
+ const openCount = tickets.filter((t) => t.status === "open").length;
196
+ const current = tickets.find((t) => t._id === openId);
197
+
198
+ async function logTicket(e) {
199
+ e.preventDefault();
200
+ const found = {
201
+ subject: subject.trim() === "" ? "Add a subject." : undefined,
202
+ body: body.trim() === "" ? "Say what's going on." : undefined,
203
+ };
204
+ setErrors(found);
205
+ if (found.subject || found.body) return;
206
+ await database.put({
207
+ type: "ticket",
208
+ subject: subject.trim(),
209
+ body: body.trim(),
210
+ urgent,
211
+ status: "open",
212
+ authorHandle: me?.userHandle,
213
+ createdAt: Date.now(),
214
+ });
215
+ setSubject("");
216
+ setBody("");
217
+ setUrgent(false);
218
+ setLogging(false);
219
+ toast.success("Ticket logged");
220
+ }
221
+
222
+ return (
223
+ <main className="mx-auto max-w-2xl px-4 py-8">
224
+ {current ? (
225
+ <TicketView
226
+ ticket={current}
227
+ replies={replies.filter((r) => r.ticketId === current._id).sort((a, b) => a.createdAt - b.createdAt)}
228
+ database={database}
229
+ can={can}
230
+ me={me}
231
+ onBack={() => setOpenId(undefined)}
232
+ />
233
+ ) : (
234
+ <Stack gap={5}>
235
+ <Row justify="between" align="top">
236
+ <Stack gap={1}>
237
+ <Heading level={1}>Help desk</Heading>
238
+ <Text muted>{openCount === 1 ? "1 open ticket" : `${openCount} open tickets`}</Text>
239
+ </Stack>
240
+ <Dialog title="Log a ticket" open={logging} onOpenChange={setLogging} trigger={<Button>Log a ticket</Button>}>
241
+ <Gate verdict={canLog} closed="Logging a ticket opens up once you've signed in.">
242
+ <form onSubmit={logTicket} noValidate>
243
+ <Stack gap={3}>
244
+ <Field label="Subject" error={errors.subject}>
245
+ {(id, describedBy) => (
246
+ <Input
247
+ id={id}
248
+ describedBy={describedBy}
249
+ value={subject}
250
+ onChange={setSubject}
251
+ placeholder="Order arrived damaged"
252
+ invalid={errors.subject !== undefined}
253
+ />
254
+ )}
255
+ </Field>
256
+ <Field label="What's going on?" error={errors.body}>
257
+ {(id, describedBy) => (
258
+ <Textarea id={id} describedBy={describedBy} value={body} onChange={setBody} rows={4} invalid={errors.body !== undefined} />
259
+ )}
260
+ </Field>
261
+ <Checkbox checked={urgent} onChange={(checked) => setUrgent(checked === true)} label="This is urgent" />
262
+ <Row justify="end">
263
+ <Button type="submit">Log ticket</Button>
264
+ </Row>
265
+ </Stack>
266
+ </form>
267
+ </Gate>
268
+ </Dialog>
269
+ </Row>
270
+
271
+ <Stack gap={3}>
272
+ <Field label="Search tickets">
273
+ {(id, describedBy) => (
274
+ <Input id={id} describedBy={describedBy} value={query} onChange={setQuery} placeholder="Subject or words" inputMode="search" />
275
+ )}
276
+ </Field>
277
+ <Field label="Status">
278
+ {(id, describedBy) => <Select id={id} describedBy={describedBy} value={status} onChange={setStatus} options={FILTERS} />}
279
+ </Field>
280
+ </Stack>
281
+
282
+ {shown.length === 0 ? (
283
+ <Empty icon="search" title={tickets.length === 0 ? "No tickets yet" : "No tickets found"}>
284
+ {tickets.length === 0 ? "New questions land here as they come in." : "Clear a filter or try a broader search."}
285
+ </Empty>
286
+ ) : (
287
+ <Stack gap={2}>
288
+ {shown.map((t) => (
289
+ <RowCard key={t._id} onClick={() => setOpenId(t._id)}>
290
+ <Stack gap={2}>
291
+ <Row gap={2} wrap={false}>
292
+ <Text truncate>{t.subject}</Text>
293
+ <Spacer />
294
+ <Text size="sm" muted>
295
+ {when(t.createdAt)}
296
+ </Text>
297
+ </Row>
298
+ <Badges ticket={t} />
299
+ </Stack>
300
+ </RowCard>
301
+ ))}
302
+ </Stack>
303
+ )}
304
+ </Stack>
305
+ )}
306
+ <div className="pouf-toasts">
307
+ <Toaster />
308
+ </div>
309
+ </main>
310
+ );
311
+ }
312
+ ```
313
+
314
+ ## Adapting
315
+
316
+ - Rename tickets to the app's own noun (`request`, `repair`, `question`) in the `type` key, the headings and the copy; keep one document per ticket and one per reply, linked by `ticketId`.
317
+ - Keep status on the ticket document and tone its `Badge` by meaning: `info` for open, `idle` for waiting, `up` for solved, `warn` for urgent; a new status is one more entry in `STATUS` and `FILTERS`.
318
+ - Validate in plain state: check the trimmed value on submit, set the `Field`'s `error`, and clear it as the person types.
319
+ - Assigning a ticket is a `HandleInput` from `useViewer()` writing an `assigneeHandle`, shown as a `ViewerTag`.
320
+ - 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.
321
+
322
+ Adapted from 1st-Pouf (MIT) https://1st-pouf.worksonmy.dev/r/support.json
@@ -0,0 +1,2 @@
1
+ import type { LlmConfig } from "./types.js";
2
+ export declare const poufTestimonialsConfig: LlmConfig;
@@ -0,0 +1,6 @@
1
+ export const poufTestimonialsConfig = {
2
+ name: "pouf-testimonials",
3
+ label: "Pouf testimonials block",
4
+ description: "Testimonials section with category filter, story carousel and reported outcomes",
5
+ };
6
+ //# sourceMappingURL=pouf-testimonials.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pouf-testimonials.js","sourceRoot":"","sources":["../../jsr/llms/pouf-testimonials.ts"],"names":[],"mappings":"AAMA,MAAM,CAAC,MAAM,sBAAsB,GAAc;IAC/C,IAAI,EAAE,mBAAmB;IACzB,KAAK,EAAE,yBAAyB;IAChC,WAAW,EAAE,iFAAiF;CAC/F,CAAC"}
@@ -0,0 +1,156 @@
1
+ # Pouf Testimonials
2
+
3
+ A section of client stories: the reviews on a cleaning business's site, quotes from a design studio's clients, what students said about a course, thank-you notes for a wedding band. Reach for it when the app shows a handful of quotes the owner has gathered, one at a time with the outcome each person reported, and lets the reader filter them by kind of work and step through them. The stories are written into the page by the owner, so the block keeps nothing; `## Adapting` covers collecting new ones from visitors.
4
+
5
+ Built from @vibes.diy/pouf components; persisted state lives in Fireproof.
6
+
7
+ ## The block
8
+
9
+ One view with one job: read what clients say. An `Eyebrow`, the `Heading` and a muted story count open it, then a `Segmented` filter (`tone="info"`) narrows the stories by service and returns to the first. `Grid cols="sidebar"` sets the quote beside its outcome. The story sits on a tilted `Card`, its quote and attribution inside a polite live region so a screen reader hears each new story (the step buttons stay outside it): a decorative `orange` `Blob` and an `info` `Badge` for the service, the quote as a `Heading level={2}`, the client as an `Avatar` (decorative `orange`, initials) with their name and role in `Text`, and two `IconButton`s to step back and forward. `Next story` is the view's one lead (`variant="solid"`); `Previous story` stays quiet. Beside it, the reported outcome is a `Stat` toned `up`, and a row of `Dot`s marks the reader's place (`info` for the current story, `idle` for the rest) above a muted "2 of 4". Nothing is written: the filter and position are page state.
10
+
11
+ ### `App.jsx`
12
+ ```jsx
13
+ import React, { useState } from "react";
14
+ import { Badge, Blob, Card, Dot, Eyebrow, Grid, Heading, Icon, IconButton, Row, Segmented, Stack, Stat, Text } from "@vibes.diy/pouf";
15
+ import { Avatar } from "@vibes.diy/pouf/interactive";
16
+
17
+ // The owner's own collection of quotes, written into the page.
18
+ const STORIES = [
19
+ {
20
+ id: "harlow",
21
+ service: "planting",
22
+ quote: "They turned a bare clay slope into a bed that flowers from March to October, and told us exactly what to cut back when.",
23
+ name: "Harlow Reyes",
24
+ role: "Homeowner, Riverside",
25
+ result: "Flowering 8 months a year",
26
+ },
27
+ {
28
+ id: "ines",
29
+ service: "patios",
30
+ quote: "The new terrace drains properly for the first time in twenty years. We eat outside most evenings now.",
31
+ name: "Inès Moreau",
32
+ role: "Café owner, Old Town",
33
+ result: "12 extra outdoor tables",
34
+ },
35
+ {
36
+ id: "tomasz",
37
+ service: "upkeep",
38
+ quote: "One visit a month and the garden looks like we spend every weekend in it. We don't.",
39
+ name: "Tomasz Wilk",
40
+ role: "Two-garden household",
41
+ result: "Weekends back",
42
+ },
43
+ {
44
+ id: "amara",
45
+ service: "planting",
46
+ quote: "They planted for the shade we actually have instead of the sun we wished for. Nothing has died since.",
47
+ name: "Amara Osei",
48
+ role: "Allotment group lead",
49
+ result: "Zero losses in two winters",
50
+ },
51
+ ];
52
+ const SERVICES = [
53
+ { value: "all", label: "All" },
54
+ { value: "planting", label: "Planting" },
55
+ { value: "patios", label: "Patios" },
56
+ { value: "upkeep", label: "Upkeep" },
57
+ ];
58
+ const serviceLabel = (value) => SERVICES.find((s) => s.value === value)?.label ?? value;
59
+ const initials = (name) =>
60
+ name
61
+ .split(" ")
62
+ .map((part) => part[0])
63
+ .join("")
64
+ .slice(0, 2);
65
+
66
+ export default function App() {
67
+ const [service, setService] = useState("all");
68
+ const [index, setIndex] = useState(0);
69
+
70
+ const shown = service === "all" ? STORIES : STORIES.filter((s) => s.service === service);
71
+ const pos = index % shown.length;
72
+ const story = shown[pos];
73
+
74
+ function pick(next) {
75
+ setService(next);
76
+ // A new filter starts on its first story, not on a position the old list had.
77
+ setIndex(0);
78
+ }
79
+
80
+ function move(by) {
81
+ setIndex((i) => (i + by + shown.length) % shown.length);
82
+ }
83
+
84
+ return (
85
+ <main className="mx-auto max-w-5xl px-4 py-8">
86
+ <Stack gap={6}>
87
+ <Stack gap={2}>
88
+ <Eyebrow>Client stories</Eyebrow>
89
+ <Heading level={1}>Gardens people actually use</Heading>
90
+ <Text muted>{STORIES.length} stories from the last two seasons.</Text>
91
+ </Stack>
92
+
93
+ <Segmented label="Filter stories by service" tone="info" value={service} onChange={pick} options={SERVICES} />
94
+
95
+ <Grid cols="sidebar" gap={5}>
96
+ <Card motion="tilt-left">
97
+ <Stack gap={5}>
98
+ {/* Only the story is announced; the step buttons stay outside the live region. */}
99
+ <div aria-live="polite" aria-atomic="true">
100
+ <Stack gap={5}>
101
+ <Row justify="between" wrap={false}>
102
+ <Blob icon="comment" tone="orange" />
103
+ <Badge tone="info">{serviceLabel(story.service)}</Badge>
104
+ </Row>
105
+ <blockquote className="m-0">
106
+ <Heading level={2}>“{story.quote}”</Heading>
107
+ </blockquote>
108
+ <Row gap={3} wrap={false}>
109
+ <Avatar fallback={initials(story.name)} tone="orange" />
110
+ <Stack gap={1}>
111
+ <Text>{story.name}</Text>
112
+ <Text size="sm" muted>
113
+ {story.role}
114
+ </Text>
115
+ </Stack>
116
+ </Row>
117
+ </Stack>
118
+ </div>
119
+ <Row gap={2} justify="end" wrap={false}>
120
+ <IconButton label="Previous story" icon={<Icon name="prev" size="sm" />} disabled={shown.length < 2} onClick={() => move(-1)} />
121
+ <IconButton label="Next story" variant="solid" icon={<Icon name="next" size="sm" />} disabled={shown.length < 2} onClick={() => move(1)} />
122
+ </Row>
123
+ </Stack>
124
+ </Card>
125
+
126
+ <Stack gap={3}>
127
+ <Stat label="Reported outcome" value={story.result} icon="up" tone="up" />
128
+ <Card variant="tight">
129
+ <Stack gap={2}>
130
+ <Row gap={2} wrap={false}>
131
+ {shown.map((s, i) => (
132
+ <Dot key={s.id} tone={i === pos ? "info" : "idle"} />
133
+ ))}
134
+ </Row>
135
+ <Text size="sm" muted>
136
+ {pos + 1} of {shown.length}
137
+ </Text>
138
+ </Stack>
139
+ </Card>
140
+ </Stack>
141
+ </Grid>
142
+ </Stack>
143
+ </main>
144
+ );
145
+ }
146
+ ```
147
+
148
+ ## Adapting
149
+
150
+ - Change the stories, not the shape: each is `{ id, service, quote, name, role, result }`, the filter options come from the services you name, and a filter change always returns to the first story.
151
+ - Keep exactly one lead: `Next story` is solid, everything else is quiet or a meaning tone. A page that also asks the reader to act (book a call, get a quote) moves the lead to that button and makes `Next story` quiet again.
152
+ - Quote people with their permission and only what they agreed to share; a role and a neighbourhood are enough, never a street address or a phone number.
153
+ - To collect stories from visitors, save one `story` document per submission in Fireproof with the author's `authorHandle`, show the author with `useViewer()`'s `ViewerTag` rather than a typed name, keep new submissions out of the carousel until the owner approves them (a `status` field), and gate the form on `useVibe(dbName).can.create(draft)` with a `requestLogin()` ask for visitors who have not signed in.
154
+ - Each story's photo, when there is one, goes in `Avatar`'s `src` with the initials as its fallback.
155
+
156
+ Adapted from 1st-Pouf (MIT) https://1st-pouf.worksonmy.dev/r/testimonials.json
@@ -0,0 +1,2 @@
1
+ import type { LlmConfig } from "./types.js";
2
+ export declare const poufTodoConfig: LlmConfig;
@@ -0,0 +1,6 @@
1
+ export const poufTodoConfig = {
2
+ name: "pouf-todo",
3
+ label: "Pouf todo block",
4
+ description: "Checklist UI to add, filter, tick off and clear tasks",
5
+ };
6
+ //# sourceMappingURL=pouf-todo.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pouf-todo.js","sourceRoot":"","sources":["../../jsr/llms/pouf-todo.ts"],"names":[],"mappings":"AASA,MAAM,CAAC,MAAM,cAAc,GAAc;IACvC,IAAI,EAAE,WAAW;IACjB,KAAK,EAAE,iBAAiB;IACxB,WAAW,EAAE,uDAAuD;CACrE,CAAC"}