@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,330 @@
1
+ # Pouf CRM
2
+
3
+ A customer list that remembers where every relationship stands: a design studio's clients, a bakery's wholesale accounts, a freelancer's leads, a nonprofit's donors. Reach for it whenever the app's core is people or companies the person keeps in touch with, each carrying a stage (a lead, active, at risk), a few account details and a running trail of notes. Customers, their stage and every note persist, so the list is where it was left on the next visit and for everyone working the accounts.
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 customer list opens with the `Heading` and a muted count, an `Add customer` button (the list's one lead) that opens a `Dialog` with the intake form, a search `Input` in a `Field` and a `Segmented` stage filter (`tone="info"`). Each customer is a clickable `RowCard` with the name, the company in muted `Text` and the stage as a `Badge` toned by meaning (`info` lead, `up` active, `warn` at risk); an `Empty` stands in when nothing matches. Opening a customer swaps in the customer view: a quiet `IconButton` to go back, the name as the `Heading`, a `Card` of `Metric`s (account value, email, added), the stage as a `Select` in a `Field`, then the notes, each on a `Card` with its author as a `ViewerTag`, and a note form whose `Save note` is that view's lead. Every write waits on its `can` verdict from `useVibe("crm")`: the intake and note 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 stage `Select` is disabled unless `can.edit(customer).ok`, explaining why in its hint only once the verdict has resolved to a denial.
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 {
17
+ Badge,
18
+ Button,
19
+ Card,
20
+ Empty,
21
+ Field,
22
+ Heading,
23
+ Icon,
24
+ IconButton,
25
+ Input,
26
+ Metric,
27
+ NumberInput,
28
+ Row,
29
+ RowCard,
30
+ Segmented,
31
+ Skeleton,
32
+ Spacer,
33
+ Stack,
34
+ Text,
35
+ Textarea,
36
+ } from "@vibes.diy/pouf";
37
+ import { Dialog, Select, Toaster, toast } from "@vibes.diy/pouf/interactive";
38
+
39
+ const STAGES = {
40
+ lead: { label: "Lead", tone: "info" },
41
+ active: { label: "Active", tone: "up" },
42
+ "at-risk": { label: "At risk", tone: "warn" },
43
+ };
44
+ const STAGE_OPTIONS = Object.entries(STAGES).map(([value, s]) => ({ value, label: s.label }));
45
+ const FILTERS = [{ value: "all", label: "All" }, ...STAGE_OPTIONS];
46
+
47
+ const money = new Intl.NumberFormat(undefined, { style: "currency", currency: "USD", maximumFractionDigits: 0 });
48
+ const day = (ms) => new Date(ms).toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" });
49
+
50
+ function Author({ handle }) {
51
+ const { ViewerTag } = useViewer();
52
+ return handle ? <ViewerTag userHandle={handle} /> : <Text>A guest</Text>;
53
+ }
54
+
55
+ /** Three states for any create form: resolving, closed to this visitor, open. */
56
+ function Gate({ verdict, closed, children }) {
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>{closed}</Text>;
64
+ return children;
65
+ }
66
+
67
+ function StageBadge({ stage }) {
68
+ return <Badge tone={STAGES[stage].tone}>{STAGES[stage].label}</Badge>;
69
+ }
70
+
71
+ function CustomerView({ customer, notes, database, can, me, onBack }) {
72
+ const [draft, setDraft] = useState("");
73
+ const [error, setError] = useState(undefined);
74
+ const canNote = can.create({ type: "note", customerId: customer._id, authorHandle: me?.userHandle });
75
+ // Kept whole: `pending` (still resolving) and a real denial read differently.
76
+ const canEdit = can.edit(customer);
77
+
78
+ async function changeStage(stage) {
79
+ await database.put({ ...customer, stage });
80
+ toast.success(`Moved to ${STAGES[stage].label.toLowerCase()}`);
81
+ }
82
+
83
+ async function saveNote(e) {
84
+ e.preventDefault();
85
+ const text = draft.trim();
86
+ if (text === "") {
87
+ setError("Write the note first.");
88
+ return;
89
+ }
90
+ setError(undefined);
91
+ await database.put({ type: "note", customerId: customer._id, text, authorHandle: me?.userHandle, createdAt: Date.now() });
92
+ // Cleared only once the write lands, so a local failure keeps the typed words.
93
+ setDraft("");
94
+ }
95
+
96
+ return (
97
+ <Stack gap={5}>
98
+ <Stack gap={2}>
99
+ <Row gap={2} wrap={false}>
100
+ <IconButton size="sm" onClick={onBack} label="Back to customers" icon={<Icon name="prev" size="sm" />} />
101
+ <Heading level={1}>{customer.name}</Heading>
102
+ </Row>
103
+ <Row gap={2}>
104
+ <Text muted>{customer.company || "No company yet"}</Text>
105
+ <StageBadge stage={customer.stage} />
106
+ </Row>
107
+ </Stack>
108
+
109
+ <Card variant="tight">
110
+ <Row gap={4}>
111
+ <Metric label="Account value" value={money.format(customer.value)} />
112
+ <Metric label="Email" value={customer.email || undefined} num={false} />
113
+ <Metric label="Added" value={day(customer.createdAt)} num={false} />
114
+ </Row>
115
+ </Card>
116
+
117
+ <Field label="Stage" hint={!canEdit.pending && !canEdit.ok ? "Only people who manage this account can move it." : undefined}>
118
+ {(id, describedBy) => (
119
+ <Select id={id} describedBy={describedBy} value={customer.stage} onChange={changeStage} options={STAGE_OPTIONS} disabled={!canEdit.ok} />
120
+ )}
121
+ </Field>
122
+
123
+ <Stack gap={3}>
124
+ <Heading level={2}>Notes</Heading>
125
+ {notes.length === 0 && <Text muted>No notes yet. Capture the next step below.</Text>}
126
+ {notes.map((n) => (
127
+ <Card key={n._id} variant="tight">
128
+ <Stack gap={2}>
129
+ <Row gap={2} wrap={false}>
130
+ <Author handle={n.authorHandle} />
131
+ <Spacer />
132
+ <Text size="sm" muted>
133
+ {day(n.createdAt)}
134
+ </Text>
135
+ </Row>
136
+ <Text>{n.text}</Text>
137
+ </Stack>
138
+ </Card>
139
+ ))}
140
+ <Gate verdict={canNote} closed="Notes open up once you're on the accounts team.">
141
+ <form onSubmit={saveNote} noValidate>
142
+ <Stack gap={3}>
143
+ <Field label="Add a note" error={error}>
144
+ {(id, describedBy) => (
145
+ <Textarea
146
+ id={id}
147
+ describedBy={describedBy}
148
+ value={draft}
149
+ onChange={(value) => {
150
+ setDraft(value);
151
+ if (error) setError(undefined);
152
+ }}
153
+ placeholder="Sent the proposal, follow up Friday"
154
+ rows={3}
155
+ invalid={error !== undefined}
156
+ />
157
+ )}
158
+ </Field>
159
+ <Row justify="end">
160
+ <Button type="submit">Save note</Button>
161
+ </Row>
162
+ </Stack>
163
+ </form>
164
+ </Gate>
165
+ </Stack>
166
+ </Stack>
167
+ );
168
+ }
169
+
170
+ export default function App() {
171
+ const { database, useLiveQuery } = useFireproof("crm");
172
+ const { can, me } = useVibe("crm");
173
+ const { docs: customerDocs } = useLiveQuery("type", { key: "customer" });
174
+ const { docs: notes } = useLiveQuery("type", { key: "note" });
175
+ const customers = [...customerDocs].sort((a, b) => a.name.localeCompare(b.name));
176
+
177
+ const [query, setQuery] = useState("");
178
+ const [filter, setFilter] = useState("all");
179
+ const [openId, setOpenId] = useState(undefined);
180
+ const [adding, setAdding] = useState(false);
181
+ const [form, setForm] = useState({ name: "", company: "", email: "", value: "", stage: "lead" });
182
+ const [errors, setErrors] = useState({});
183
+ // The verdict for the doc addCustomer writes, carrying the same fields it stamps.
184
+ const canAdd = can.create({ type: "customer", authorHandle: me?.userHandle });
185
+
186
+ const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
187
+ const needle = query.trim().toLowerCase();
188
+ const shown = customers.filter(
189
+ (c) =>
190
+ (filter === "all" || c.stage === filter) &&
191
+ (needle === "" || `${c.name} ${c.company} ${c.email}`.toLowerCase().includes(needle))
192
+ );
193
+ const current = customers.find((c) => c._id === openId);
194
+
195
+ async function addCustomer(e) {
196
+ e.preventDefault();
197
+ const value = form.value.trim() === "" ? 0 : Number(form.value);
198
+ const found = {
199
+ name: form.name.trim() === "" ? "Add a name." : undefined,
200
+ value: Number.isFinite(value) && value >= 0 ? undefined : "Enter an amount of zero or more.",
201
+ };
202
+ setErrors(found);
203
+ if (found.name || found.value) return;
204
+ await database.put({
205
+ type: "customer",
206
+ name: form.name.trim(),
207
+ company: form.company.trim(),
208
+ email: form.email.trim(),
209
+ value,
210
+ stage: form.stage,
211
+ authorHandle: me?.userHandle,
212
+ createdAt: Date.now(),
213
+ });
214
+ setForm({ name: "", company: "", email: "", value: "", stage: "lead" });
215
+ setAdding(false);
216
+ toast.success("Customer added");
217
+ }
218
+
219
+ return (
220
+ <main className="mx-auto max-w-2xl px-4 py-8">
221
+ {current ? (
222
+ <CustomerView
223
+ customer={current}
224
+ notes={notes.filter((n) => n.customerId === current._id).sort((a, b) => a.createdAt - b.createdAt)}
225
+ database={database}
226
+ can={can}
227
+ me={me}
228
+ onBack={() => setOpenId(undefined)}
229
+ />
230
+ ) : (
231
+ <Stack gap={5}>
232
+ <Row justify="between" align="top">
233
+ <Stack gap={1}>
234
+ <Heading level={1}>Customers</Heading>
235
+ <Text muted>{customers.length === 1 ? "1 customer" : `${customers.length} customers`}</Text>
236
+ </Stack>
237
+ <Dialog title="Add a customer" open={adding} onOpenChange={setAdding} trigger={<Button>Add customer</Button>}>
238
+ <Gate verdict={canAdd} closed="Adding customers opens up once you're on the accounts team.">
239
+ <form onSubmit={addCustomer} noValidate>
240
+ <Stack gap={3}>
241
+ <Field label="Name" error={errors.name}>
242
+ {(id, describedBy) => (
243
+ <Input id={id} describedBy={describedBy} value={form.name} onChange={set("name")} invalid={errors.name !== undefined} />
244
+ )}
245
+ </Field>
246
+ <Field label="Company">
247
+ {(id, describedBy) => <Input id={id} describedBy={describedBy} value={form.company} onChange={set("company")} />}
248
+ </Field>
249
+ <Field label="Email">
250
+ {(id, describedBy) => (
251
+ <Input id={id} describedBy={describedBy} value={form.email} onChange={set("email")} type="email" inputMode="email" />
252
+ )}
253
+ </Field>
254
+ <Field label="Account value" hint="In dollars a year." error={errors.value}>
255
+ {(id, describedBy) => (
256
+ <NumberInput
257
+ id={id}
258
+ describedBy={describedBy}
259
+ value={form.value}
260
+ onChange={set("value")}
261
+ min={0}
262
+ step={100}
263
+ invalid={errors.value !== undefined}
264
+ />
265
+ )}
266
+ </Field>
267
+ <Field label="Stage">
268
+ {(id, describedBy) => (
269
+ <Select id={id} describedBy={describedBy} value={form.stage} onChange={set("stage")} options={STAGE_OPTIONS} />
270
+ )}
271
+ </Field>
272
+ <Row justify="end">
273
+ <Button type="submit">Save customer</Button>
274
+ </Row>
275
+ </Stack>
276
+ </form>
277
+ </Gate>
278
+ </Dialog>
279
+ </Row>
280
+
281
+ <Stack gap={3}>
282
+ <Field label="Search customers">
283
+ {(id, describedBy) => (
284
+ <Input id={id} describedBy={describedBy} value={query} onChange={setQuery} placeholder="Name, company or email" inputMode="search" />
285
+ )}
286
+ </Field>
287
+ <Segmented label="Stage" tone="info" value={filter} onChange={setFilter} options={FILTERS} />
288
+ </Stack>
289
+
290
+ {shown.length === 0 ? (
291
+ <Empty icon="users" title={customers.length === 0 ? "No customers yet" : "No customers found"}>
292
+ {customers.length === 0 ? "Add the first one to start the list." : "Try a broader search or another stage."}
293
+ </Empty>
294
+ ) : (
295
+ <Stack gap={2}>
296
+ {shown.map((c) => (
297
+ <RowCard key={c._id} onClick={() => setOpenId(c._id)}>
298
+ <Row gap={3} wrap={false}>
299
+ <Stack gap={1}>
300
+ <Text truncate>{c.name}</Text>
301
+ <Text size="sm" muted truncate>
302
+ {c.company || "No company yet"}
303
+ </Text>
304
+ </Stack>
305
+ <Spacer />
306
+ <StageBadge stage={c.stage} />
307
+ </Row>
308
+ </RowCard>
309
+ ))}
310
+ </Stack>
311
+ )}
312
+ </Stack>
313
+ )}
314
+ <div className="pouf-toasts">
315
+ <Toaster />
316
+ </div>
317
+ </main>
318
+ );
319
+ }
320
+ ```
321
+
322
+ ## Adapting
323
+
324
+ - Rename customers to the app's own noun (`client`, `account`, `donor`) in the `type` key, the headings and the copy; keep one document per customer and one per note, linked by `customerId`.
325
+ - Keep the stage on the customer document and tone its `Badge` by meaning: `info` for a lead, `up` for active, `warn` for at risk, `idle` for a lost or paused account; a new stage is one more entry in `STAGES`.
326
+ - An account owner is a `HandleInput` from `useViewer()` writing an `ownerHandle`, shown on the customer view as a `ViewerTag`.
327
+ - A pipeline total is a `Stat` above the list summing `value` over the active customers, with its own meaning tone.
328
+ - 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.
329
+
330
+ Adapted from 1st-Pouf (MIT) https://1st-pouf.worksonmy.dev/r/crm.json
@@ -0,0 +1,2 @@
1
+ import type { LlmConfig } from "./types.js";
2
+ export declare const poufDashboardConfig: LlmConfig;
@@ -0,0 +1,6 @@
1
+ export const poufDashboardConfig = {
2
+ name: "pouf-dashboard",
3
+ label: "Pouf dashboard block",
4
+ description: "Sales dashboard with figures, a range chart and a sortable table",
5
+ };
6
+ //# sourceMappingURL=pouf-dashboard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pouf-dashboard.js","sourceRoot":"","sources":["../../jsr/llms/pouf-dashboard.ts"],"names":[],"mappings":"AAMA,MAAM,CAAC,MAAM,mBAAmB,GAAc;IAC5C,IAAI,EAAE,gBAAgB;IACtB,KAAK,EAAE,sBAAsB;IAC7B,WAAW,EAAE,kEAAkE;CAChF,CAAC"}
@@ -0,0 +1,269 @@
1
+ # Pouf Dashboard
2
+
3
+ An overview page that turns the records an app keeps into a few headline figures, a chart and a table: a candle shop's sales, a bake sale's takings, a market stall's week, a club's ticket sales. Reach for it whenever the app's core is "how are we doing", answered from things people log. The figures are never typed in by hand: each sale is its own document, and every number, bar and row on the page is computed from those documents when it renders, so the dashboard is current on the next visit and on every device that opens the app.
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: read the business at a glance. The `Heading` and a muted line open the page, beside a `Segmented` range picker (`tone="info"`) that scopes the money taken, the sales count, the chart and the table. Three `Stat`s follow in a `Grid`, each toned by meaning (`up` for money taken, `info` for a plain count, `warn` for sales still waiting on payment); the unpaid count ignores the range on purpose, because a sale still owed does not stop being owed when it scrolls out of the week. Under them a `Card` holds the `AreaChart` from `@vibes.diy/pouf/charts`, one bucket per day, week or month, its one series toned `up` like the money-taken `Stat` it breaks down. The `Grid cols="sidebar"` below puts the range's latest sales in a `Table` on the main side (sortable columns, the status as a `Badge` read from the stored field: `up` paid, `warn` awaiting payment) and the `Record a sale` form on the side, whose button is the view's one lead and shows `loading` while the write lands. A sale still awaiting payment gets a quiet `Mark paid` in its row, showing `loading` until its write lands. Every write waits on its `can` verdict from `useVibe("sales")`, the database the sales live in: the form shows a `Skeleton` inside an `aria-busy` wrapper while the verdict resolves, a `Sign in to record sales` lead wired to `useViewer().requestLogin()` for a visitor who has not signed in, and a sentence in the app's own words when recording is closed to a signed-in visitor; `Mark paid` renders only where `can.edit(sale).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 {
17
+ Badge,
18
+ Button,
19
+ Card,
20
+ Empty,
21
+ Field,
22
+ Grid,
23
+ Heading,
24
+ Input,
25
+ NumberInput,
26
+ Row,
27
+ Segmented,
28
+ Skeleton,
29
+ Stack,
30
+ Stat,
31
+ Table,
32
+ Text,
33
+ } from "@vibes.diy/pouf";
34
+ import { Select, Toaster, toast } from "@vibes.diy/pouf/interactive";
35
+ import { AreaChart } from "@vibes.diy/pouf/charts";
36
+
37
+ const MONEY = new Intl.NumberFormat(undefined, { style: "currency", currency: "USD" });
38
+ const RANGES = [
39
+ { value: "week", label: "Week" },
40
+ { value: "month", label: "Month" },
41
+ { value: "year", label: "Year" },
42
+ ];
43
+ const STATES = [
44
+ { value: "paid", label: "Paid" },
45
+ { value: "pending", label: "Awaiting payment" },
46
+ ];
47
+ const STATE_BADGE = { paid: { label: "Paid", tone: "up" }, pending: { label: "Awaiting payment", tone: "warn" } };
48
+ // Sorting the status column puts what still needs chasing first.
49
+ const STATE_RANK = { pending: 0, paid: 1 };
50
+ const plural = (n, one, many) => (n === 1 ? `1 ${one}` : `${n} ${many}`);
51
+
52
+ /** The chart's buckets for a range: 7 days, the last 4 weeks, or the last 12 months, oldest first. */
53
+ function bucketsFor(range, now) {
54
+ const today = new Date(now);
55
+ today.setHours(0, 0, 0, 0);
56
+ if (range === "year") {
57
+ return Array.from({ length: 12 }, (_, i) => {
58
+ const start = new Date(today.getFullYear(), today.getMonth() - 11 + i, 1);
59
+ const end = new Date(today.getFullYear(), today.getMonth() - 10 + i, 1);
60
+ return { label: start.toLocaleDateString(undefined, { month: "short" }), start: start.getTime(), end: end.getTime() };
61
+ });
62
+ }
63
+ const size = range === "week" ? 1 : 7;
64
+ const count = range === "week" ? 7 : 4;
65
+ return Array.from({ length: count }, (_, i) => {
66
+ const start = new Date(today);
67
+ start.setDate(today.getDate() - (count - 1 - i) * size - (size - 1));
68
+ const end = new Date(start);
69
+ end.setDate(start.getDate() + size);
70
+ const label =
71
+ range === "week" ? start.toLocaleDateString(undefined, { weekday: "short" }) : start.toLocaleDateString(undefined, { month: "short", day: "numeric" });
72
+ return { label, start: start.getTime(), end: end.getTime() };
73
+ });
74
+ }
75
+
76
+ export default function App() {
77
+ const { database, useLiveQuery } = useFireproof("sales");
78
+ const { docs: sales } = useLiveQuery("type", { key: "sale" });
79
+ const { can, me } = useVibe("sales");
80
+ const { requestLogin } = useViewer();
81
+
82
+ const [range, setRange] = useState("week");
83
+ const [item, setItem] = useState("");
84
+ const [amount, setAmount] = useState("");
85
+ const [state, setState] = useState("paid");
86
+ const [errors, setErrors] = useState({});
87
+ const [saving, setSaving] = useState(false);
88
+ // The sale whose Mark paid write is in flight, so its button can't fire twice.
89
+ const [markingId, setMarkingId] = useState(undefined);
90
+
91
+ // Every figure below is derived from the sale documents; nothing is stored as a total.
92
+ const buckets = bucketsFor(range, Date.now());
93
+ const from = buckets[0].start;
94
+ const to = buckets[buckets.length - 1].end;
95
+ const inRange = sales.filter((s) => s.createdAt >= from && s.createdAt < to);
96
+ const takings = inRange.filter((s) => s.state === "paid").reduce((n, s) => n + s.amount, 0);
97
+ const awaiting = sales.filter((s) => s.state === "pending");
98
+ const chart = buckets.map((b) => ({
99
+ label: b.label,
100
+ total: sales.filter((s) => s.state === "paid" && s.createdAt >= b.start && s.createdAt < b.end).reduce((n, s) => n + s.amount, 0),
101
+ }));
102
+ const recent = [...inRange].sort((a, b) => b.createdAt - a.createdAt).slice(0, 8);
103
+
104
+ // The verdict for the doc recordSale writes, carrying the same fields it stamps.
105
+ const canRecord = can.create({ type: "sale", authorHandle: me?.userHandle });
106
+
107
+ async function recordSale(e) {
108
+ e.preventDefault();
109
+ const next = {};
110
+ const value = Number(amount);
111
+ if (item.trim() === "") next.item = "Say what sold.";
112
+ if (amount.trim() === "" || !Number.isFinite(value) || value <= 0) next.amount = "Enter an amount above zero.";
113
+ setErrors(next);
114
+ if (Object.keys(next).length > 0) return;
115
+ setSaving(true);
116
+ try {
117
+ await database.put({ type: "sale", item: item.trim(), amount: value, state, authorHandle: me?.userHandle, createdAt: Date.now() });
118
+ // Cleared only once the write lands, so a local failure keeps what was typed.
119
+ setItem("");
120
+ setAmount("");
121
+ setState("paid");
122
+ toast.success("Sale recorded");
123
+ } finally {
124
+ setSaving(false);
125
+ }
126
+ }
127
+
128
+ async function markPaid(sale) {
129
+ setMarkingId(sale._id);
130
+ try {
131
+ await database.put({ ...sale, state: "paid" });
132
+ } finally {
133
+ setMarkingId(undefined);
134
+ }
135
+ }
136
+
137
+ let form;
138
+ if (canRecord.pending)
139
+ form = (
140
+ <div aria-busy="true">
141
+ <Skeleton variant="card" />
142
+ </div>
143
+ );
144
+ else if (!me)
145
+ form = (
146
+ <Card>
147
+ <Stack gap={3}>
148
+ <Text muted>Sign in to log a sale, so it reaches everyone on the shop's team.</Text>
149
+ <Button block onClick={requestLogin}>
150
+ Sign in to record sales
151
+ </Button>
152
+ </Stack>
153
+ </Card>
154
+ );
155
+ else if (!canRecord.ok) form = <Text muted>You're seeing the shop's numbers. Recording sales opens up once you're on the team.</Text>;
156
+ else
157
+ form = (
158
+ <Card>
159
+ <form onSubmit={recordSale} noValidate>
160
+ <Stack gap={3}>
161
+ <Heading level={2}>Record a sale</Heading>
162
+ <Field label="What sold" error={errors.item}>
163
+ {(id, describedBy) => (
164
+ <Input id={id} describedBy={describedBy} value={item} onChange={setItem} placeholder="Fig & cedar candle" autoComplete="off" invalid={errors.item !== undefined} />
165
+ )}
166
+ </Field>
167
+ <Field label="Amount" error={errors.amount}>
168
+ {(id, describedBy) => (
169
+ <NumberInput id={id} describedBy={describedBy} value={amount} onChange={setAmount} min={0} step={1} placeholder="24" invalid={errors.amount !== undefined} />
170
+ )}
171
+ </Field>
172
+ <Field label="Payment">
173
+ {(id, describedBy) => <Select id={id} describedBy={describedBy} value={state} onChange={setState} options={STATES} />}
174
+ </Field>
175
+ <Button type="submit" block loading={saving}>
176
+ Record sale
177
+ </Button>
178
+ </Stack>
179
+ </form>
180
+ </Card>
181
+ );
182
+
183
+ return (
184
+ <main className="mx-auto max-w-6xl px-4 py-8">
185
+ <Stack gap={5}>
186
+ <Row justify="between">
187
+ <Stack gap={1}>
188
+ <Heading level={1}>Shop overview</Heading>
189
+ <Text muted>{plural(sales.length, "sale", "sales")} recorded</Text>
190
+ </Stack>
191
+ <Segmented label="Range" tone="info" value={range} onChange={setRange} options={RANGES} />
192
+ </Row>
193
+
194
+ <Grid cols={3}>
195
+ <Stat label="Taken" value={MONEY.format(takings)} icon="up" tone="up" />
196
+ <Stat label="Sales" value={String(inRange.length)} icon="cart" tone="info" />
197
+ <Stat label="Awaiting payment" value={String(awaiting.length)} icon="clock" tone="warn" />
198
+ </Grid>
199
+
200
+ <Card>
201
+ <Stack gap={4}>
202
+ <Heading level={2}>Money taken</Heading>
203
+ <AreaChart data={chart} dataKey="label" height={220} series={[{ key: "total", label: "Taken", tone: "up" }]} />
204
+ </Stack>
205
+ </Card>
206
+
207
+ <Grid cols="sidebar">
208
+ <Card>
209
+ <Stack gap={4}>
210
+ <Heading level={2}>Latest sales</Heading>
211
+ {recent.length === 0 ? (
212
+ <Empty icon="cart" title={sales.length === 0 ? "No sales yet" : "No sales in this range"}>
213
+ {sales.length === 0 ? "Record the first one and the figures above fill in." : "Pick a longer range to see earlier sales."}
214
+ </Empty>
215
+ ) : (
216
+ <Table
217
+ rows={recent}
218
+ getKey={(s) => s._id}
219
+ columns={[
220
+ { key: "item", header: "Item", render: (s) => <Text truncate>{s.item}</Text>, sort: (a, b) => a.item.localeCompare(b.item) },
221
+ {
222
+ key: "amount",
223
+ header: "Amount",
224
+ align: "right",
225
+ mono: true,
226
+ render: (s) => <Text num>{MONEY.format(s.amount)}</Text>,
227
+ sort: (a, b) => a.amount - b.amount,
228
+ },
229
+ {
230
+ key: "state",
231
+ header: "Status",
232
+ align: "right",
233
+ render: (s) => (
234
+ <Row gap={2} justify="end" wrap={false}>
235
+ <Badge tone={STATE_BADGE[s.state]?.tone ?? "warn"}>{STATE_BADGE[s.state]?.label ?? "Awaiting payment"}</Badge>
236
+ {s.state === "pending" && can.edit(s).ok && (
237
+ <Button size="sm" variant="quiet" loading={markingId === s._id} onClick={() => markPaid(s)}>
238
+ Mark paid
239
+ </Button>
240
+ )}
241
+ </Row>
242
+ ),
243
+ sort: (a, b) => (STATE_RANK[a.state] ?? 0) - (STATE_RANK[b.state] ?? 0),
244
+ },
245
+ ]}
246
+ />
247
+ )}
248
+ </Stack>
249
+ </Card>
250
+ {form}
251
+ </Grid>
252
+ </Stack>
253
+ <div className="pouf-toasts">
254
+ <Toaster />
255
+ </div>
256
+ </main>
257
+ );
258
+ }
259
+ ```
260
+
261
+ ## Adapting
262
+
263
+ - Rename the record to the app's own noun (`order`, `ticket`, `donation`) in the `type` key, the form and the copy; keep one document per event and let every figure, bucket and row be computed from them, never a stored running total that two devices could overwrite.
264
+ - A new figure is one more `Stat` with a meaning tone (`up` for good news, `down` for refunds or losses, `warn` for things to chase) and a reducer over the same documents; keep three or four in the `Grid`, and never repeat a figure the chart or a badge already shows.
265
+ - More series on the chart are more entries in `series`, each with an explicit tone and a matching field on every `chart` row; switch to `BarChart` for counts or `LineChart` for a trend without a fill.
266
+ - Sales hold who sold what, so `access.js` and the app's sharing decide who can read them: keep the figures team-only if they are private, and keep customer names or emails off these documents altogether.
267
+ - 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.
268
+
269
+ Adapted from 1st-Pouf (MIT) https://1st-pouf.worksonmy.dev/r/dashboard.json
@@ -0,0 +1,2 @@
1
+ import type { LlmConfig } from "./types.js";
2
+ export declare const poufEditorialConfig: LlmConfig;
@@ -0,0 +1,6 @@
1
+ export const poufEditorialConfig = {
2
+ name: "pouf-editorial",
3
+ label: "Pouf editorial block",
4
+ description: "Story pipeline moving drafts through scheduled to published",
5
+ };
6
+ //# sourceMappingURL=pouf-editorial.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pouf-editorial.js","sourceRoot":"","sources":["../../jsr/llms/pouf-editorial.ts"],"names":[],"mappings":"AAMA,MAAM,CAAC,MAAM,mBAAmB,GAAc;IAC5C,IAAI,EAAE,gBAAgB;IACtB,KAAK,EAAE,sBAAsB;IAC7B,WAAW,EAAE,6DAA6D;CAC3E,CAAC"}