@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,317 @@
1
+ # Pouf Inbox
2
+
3
+ A shared mailbox of notes that people send in and a few people read, answer and file away: a residents' committee inbox, workshop feedback, a club's suggestion box, a family's requests for the weekend. Reach for it whenever the app's core is messages that arrive one at a time, each with a subject, and get opened, replied to and archived. Notes and replies persist, so the inbox, what has been read and what has been archived are 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
+ Two views, one job each. The list opens with the `Heading` and a muted unread count, a `Write a note` button (the list's one lead) that opens a `Dialog` with the compose form, a search `Input` in a `Field`, and a `Segmented` (`tone="info"`) between the inbox and the archive. Each note is a clickable `RowCard` with its subject, a muted time, a muted first line of its body and a `Dot tone="info"` while unread; an `Empty` stands in when nothing matches. Opening a note marks it read and swaps in the reading view: a quiet `IconButton` to go back, the subject as the `Heading`, the sender as a `ViewerTag` (kept out of the clickable row, which is itself a button) and the time, the body, each reply on a `Card`, and a reply form whose `Send reply` is that view's lead, beside a quiet archive button and a quiet delete button inside a `Confirm` wearing `tone="down"`; finished actions say so with a `toast`. Every write waits on its `can` verdict from `useVibe("inbox")`: the compose 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; marking read and archiving happen only when `can.edit(note).ok`; and delete renders only where `can.delete(note).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 { Button, Card, Dot, Empty, Field, Heading, Icon, IconButton, Input, Row, RowCard, Segmented, Skeleton, Spacer, Stack, Text, Textarea } from "@vibes.diy/pouf";
17
+ import { Confirm, Dialog, Toaster, toast } from "@vibes.diy/pouf/interactive";
18
+
19
+ const BOXES = [
20
+ { value: "inbox", label: "Inbox" },
21
+ { value: "archive", label: "Archived" },
22
+ ];
23
+
24
+ const when = (ms) => new Date(ms).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
25
+
26
+ function Author({ handle }) {
27
+ const { ViewerTag } = useViewer();
28
+ return handle ? <ViewerTag userHandle={handle} /> : <Text>A guest</Text>;
29
+ }
30
+
31
+ /** Three states for any create form: resolving, closed to this visitor, open. */
32
+ function Gate({ verdict, closed, children }) {
33
+ if (verdict.pending)
34
+ return (
35
+ <div aria-busy="true">
36
+ <Skeleton variant="card" />
37
+ </div>
38
+ );
39
+ if (!verdict.ok) return <Text muted>{closed}</Text>;
40
+ return children;
41
+ }
42
+
43
+ function Reader({ note, replies, database, can, me, onBack }) {
44
+ const [draft, setDraft] = useState("");
45
+ const [error, setError] = useState(undefined);
46
+ const canReply = can.create({ type: "reply", noteId: note._id, authorHandle: me?.userHandle });
47
+ const canFile = can.edit(note).ok;
48
+
49
+ async function sendReply(e) {
50
+ e.preventDefault();
51
+ const text = draft.trim();
52
+ if (text === "") {
53
+ setError("Write a reply first.");
54
+ return;
55
+ }
56
+ setError(undefined);
57
+ await database.put({ type: "reply", noteId: note._id, text, authorHandle: me?.userHandle, createdAt: Date.now() });
58
+ // Cleared only once the write lands, so a local failure keeps the typed words.
59
+ setDraft("");
60
+ toast.success("Reply sent");
61
+ }
62
+
63
+ async function toggleArchive() {
64
+ await database.put({ ...note, archived: !note.archived });
65
+ toast(note.archived ? "Moved back to the inbox" : "Archived");
66
+ onBack();
67
+ }
68
+
69
+ async function remove() {
70
+ const gone = [note, ...replies].filter((d) => can.delete(d).ok);
71
+ await Promise.all(gone.map((d) => database.del(d._id)));
72
+ toast.success("Note deleted");
73
+ onBack();
74
+ }
75
+
76
+ return (
77
+ <Stack gap={5}>
78
+ <Row gap={2} wrap={false}>
79
+ <IconButton size="sm" onClick={onBack} label="Back to the inbox" icon={<Icon name="prev" size="sm" />} />
80
+ <Heading level={1}>{note.subject}</Heading>
81
+ </Row>
82
+
83
+ <Card>
84
+ <Stack gap={3}>
85
+ <Row gap={2} wrap={false}>
86
+ <Author handle={note.authorHandle} />
87
+ <Spacer />
88
+ <Text size="sm" muted>
89
+ {when(note.createdAt)}
90
+ </Text>
91
+ </Row>
92
+ <Text>{note.body}</Text>
93
+ <Row gap={2}>
94
+ {canFile && (
95
+ <Button size="sm" variant="quiet" onClick={toggleArchive}>
96
+ {note.archived ? "Move to inbox" : "Archive"}
97
+ </Button>
98
+ )}
99
+ {can.delete(note).ok && (
100
+ <Confirm
101
+ title="Delete this note?"
102
+ body="The note and its replies are removed for everyone. This can't be undone."
103
+ confirmLabel="Delete note"
104
+ cancelLabel="Keep it"
105
+ tone="down"
106
+ onConfirm={remove}
107
+ >
108
+ <Button size="sm" variant="quiet">
109
+ Delete
110
+ </Button>
111
+ </Confirm>
112
+ )}
113
+ </Row>
114
+ </Stack>
115
+ </Card>
116
+
117
+ {replies.map((r) => (
118
+ <Card key={r._id} variant="tight">
119
+ <Stack gap={2}>
120
+ <Row gap={2} wrap={false}>
121
+ <Author handle={r.authorHandle} />
122
+ <Spacer />
123
+ <Text size="sm" muted>
124
+ {when(r.createdAt)}
125
+ </Text>
126
+ </Row>
127
+ <Text>{r.text}</Text>
128
+ </Stack>
129
+ </Card>
130
+ ))}
131
+
132
+ <Gate verdict={canReply} closed="Replies open up once you're on the committee.">
133
+ <form onSubmit={sendReply} noValidate>
134
+ <Stack gap={3}>
135
+ <Field label="Your reply" error={error}>
136
+ {(id, describedBy) => (
137
+ <Textarea
138
+ id={id}
139
+ describedBy={describedBy}
140
+ value={draft}
141
+ onChange={(value) => {
142
+ setDraft(value);
143
+ if (error) setError(undefined);
144
+ }}
145
+ rows={3}
146
+ invalid={error !== undefined}
147
+ />
148
+ )}
149
+ </Field>
150
+ <Row justify="end">
151
+ <Button type="submit">Send reply</Button>
152
+ </Row>
153
+ </Stack>
154
+ </form>
155
+ </Gate>
156
+ </Stack>
157
+ );
158
+ }
159
+
160
+ export default function App() {
161
+ const { database, useLiveQuery } = useFireproof("inbox");
162
+ const { can, me } = useVibe("inbox");
163
+ const { docs: noteDocs } = useLiveQuery("type", { key: "note" });
164
+ const { docs: replies } = useLiveQuery("type", { key: "reply" });
165
+ const notes = [...noteDocs].sort((a, b) => b.createdAt - a.createdAt);
166
+
167
+ const [box, setBox] = useState("inbox");
168
+ const [query, setQuery] = useState("");
169
+ const [openId, setOpenId] = useState(undefined);
170
+ const [composing, setComposing] = useState(false);
171
+ const [subject, setSubject] = useState("");
172
+ const [body, setBody] = useState("");
173
+ const [errors, setErrors] = useState({});
174
+ // The verdict for the doc sendNote writes, carrying the same fields it stamps.
175
+ const canSend = can.create({ type: "note", authorHandle: me?.userHandle });
176
+
177
+ const needle = query.trim().toLowerCase();
178
+ const shown = notes.filter(
179
+ (n) => Boolean(n.archived) === (box === "archive") && (needle === "" || `${n.subject} ${n.body}`.toLowerCase().includes(needle))
180
+ );
181
+ const unread = notes.filter((n) => !n.read && !n.archived).length;
182
+ const current = notes.find((n) => n._id === openId);
183
+
184
+ function open(note) {
185
+ setOpenId(note._id);
186
+ if (!note.read && can.edit(note).ok) database.put({ ...note, read: true });
187
+ }
188
+
189
+ async function sendNote(e) {
190
+ e.preventDefault();
191
+ const found = {
192
+ subject: subject.trim() === "" ? "Give the note a subject." : undefined,
193
+ body: body.trim() === "" ? "Write a few words." : undefined,
194
+ };
195
+ setErrors(found);
196
+ if (found.subject || found.body) return;
197
+ await database.put({
198
+ type: "note",
199
+ subject: subject.trim(),
200
+ body: body.trim(),
201
+ read: false,
202
+ archived: false,
203
+ authorHandle: me?.userHandle,
204
+ createdAt: Date.now(),
205
+ });
206
+ setSubject("");
207
+ setBody("");
208
+ setComposing(false);
209
+ toast.success("Note sent");
210
+ }
211
+
212
+ return (
213
+ <main className="mx-auto max-w-2xl px-4 py-8">
214
+ {current ? (
215
+ <Reader
216
+ note={current}
217
+ replies={replies.filter((r) => r.noteId === current._id).sort((a, b) => a.createdAt - b.createdAt)}
218
+ database={database}
219
+ can={can}
220
+ me={me}
221
+ onBack={() => setOpenId(undefined)}
222
+ />
223
+ ) : (
224
+ <Stack gap={5}>
225
+ <Row justify="between" align="top">
226
+ <Stack gap={1}>
227
+ <Heading level={1}>Committee inbox</Heading>
228
+ <Text muted>{unread === 1 ? "1 unread note" : `${unread} unread notes`}</Text>
229
+ </Stack>
230
+ <Dialog
231
+ title="Write to the committee"
232
+ open={composing}
233
+ onOpenChange={setComposing}
234
+ trigger={<Button>Write a note</Button>}
235
+ >
236
+ <Gate verdict={canSend} closed="Sending notes opens up once you've signed in as a resident.">
237
+ <form onSubmit={sendNote} noValidate>
238
+ <Stack gap={3}>
239
+ <Field label="Subject" error={errors.subject}>
240
+ {(id, describedBy) => (
241
+ <Input
242
+ id={id}
243
+ describedBy={describedBy}
244
+ value={subject}
245
+ onChange={setSubject}
246
+ placeholder="Bike shed lock"
247
+ invalid={errors.subject !== undefined}
248
+ />
249
+ )}
250
+ </Field>
251
+ <Field label="Message" error={errors.body}>
252
+ {(id, describedBy) => (
253
+ <Textarea id={id} describedBy={describedBy} value={body} onChange={setBody} rows={4} invalid={errors.body !== undefined} />
254
+ )}
255
+ </Field>
256
+ <Row justify="end">
257
+ <Button type="submit">Send note</Button>
258
+ </Row>
259
+ </Stack>
260
+ </form>
261
+ </Gate>
262
+ </Dialog>
263
+ </Row>
264
+
265
+ <Stack gap={3}>
266
+ <Field label="Search notes">
267
+ {(id, describedBy) => (
268
+ <Input id={id} describedBy={describedBy} value={query} onChange={setQuery} placeholder="Subject or words" inputMode="search" />
269
+ )}
270
+ </Field>
271
+ <Segmented label="Show" tone="info" value={box} onChange={setBox} options={BOXES} />
272
+ </Stack>
273
+
274
+ {shown.length === 0 ? (
275
+ <Empty icon={needle ? "search" : "mail"} title={needle ? "No matches" : "Nothing here"}>
276
+ {needle ? "Try other words, or look in the other box." : "New notes land here as they arrive."}
277
+ </Empty>
278
+ ) : (
279
+ <Stack gap={2}>
280
+ {shown.map((n) => (
281
+ <RowCard key={n._id} onClick={() => open(n)}>
282
+ <Stack gap={1}>
283
+ <Row gap={2} wrap={false}>
284
+ {!n.read && <Dot tone="info" />}
285
+ <Text truncate>{n.subject}</Text>
286
+ <Spacer />
287
+ <Text size="sm" muted>
288
+ {when(n.createdAt)}
289
+ </Text>
290
+ </Row>
291
+ <Text size="sm" muted truncate>
292
+ {n.body}
293
+ </Text>
294
+ </Stack>
295
+ </RowCard>
296
+ ))}
297
+ </Stack>
298
+ )}
299
+ </Stack>
300
+ )}
301
+ <div className="pouf-toasts">
302
+ <Toaster />
303
+ </div>
304
+ </main>
305
+ );
306
+ }
307
+ ```
308
+
309
+ ## Adapting
310
+
311
+ - Rename notes to the app's own noun (`request`, `suggestion`, `feedback`) in the `type` key, the headings and the copy; keep one document per note and one per reply, linked by `noteId`.
312
+ - Read and archived are fields on the note, shared by everyone who answers the inbox; read state that belongs to each person is a `read:<note>:<person>` document instead.
313
+ - A label or folder is one more field and one more `Segmented` option (or a `Select` once there are more than four).
314
+ - Senders and repliers are always `ViewerTag` with the stored handle; never type a name.
315
+ - 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.
316
+
317
+ Adapted from 1st-Pouf (MIT) https://1st-pouf.worksonmy.dev/r/inbox.json
@@ -0,0 +1,2 @@
1
+ import type { LlmConfig } from "./types.js";
2
+ export declare const poufInventoryConfig: LlmConfig;
@@ -0,0 +1,6 @@
1
+ export const poufInventoryConfig = {
2
+ name: "pouf-inventory",
3
+ label: "Pouf inventory block",
4
+ description: "Stock list with low-stock alerts, receiving and per-item details",
5
+ };
6
+ //# sourceMappingURL=pouf-inventory.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pouf-inventory.js","sourceRoot":"","sources":["../../jsr/llms/pouf-inventory.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,345 @@
1
+ # Pouf Inventory
2
+
3
+ A stock list that says what is ready to sell and what needs reordering: a candle shop's shelves, a food pantry's cupboard, a makerspace's supplies, a café's dry goods. Reach for it whenever the app's core is items counted on hand, each with a level at which it should be restocked, and deliveries that bring the counts back up. Items and every delivery persist, so the stockroom reads the same on the next visit and for everyone keeping 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: know the stock and top it up. The `Heading` and a muted line open the page beside a quiet `Add item` button that opens a `Dialog` with the intake form. Three `Stat`s give the headline figures, each toned by meaning (`info` stock value, `warn` running low, `down` out of stock). A search `Input` in a `Field` and a `Segmented` stock filter (`tone="info"`) sit above a `Grid cols="sidebar"`: a sortable `Table` on a flush `Card` whose rows open an item, and the item's `Card` with its status `Badge` (`up` in stock, `warn` low, `down` out), its `Metric`s and a receive form whose `Receive` button is the view's one lead. Every write waits on its `can` verdict from `useVibe("inventory")`: the intake 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, and the receive form follows the same three states on `can.create` of the delivery it writes. Stock is never a number rewritten in place: each delivery is its own `movement` document, and on hand is the item's opening count plus the sum of its movements, so two people receiving at once both land.
10
+
11
+ ### `App.jsx`
12
+ ```jsx
13
+ import React, { useState } from "react";
14
+ import { useFireproof } from "use-fireproof";
15
+ import { useVibe } from "use-vibes";
16
+ import {
17
+ Badge,
18
+ Button,
19
+ Card,
20
+ Empty,
21
+ Field,
22
+ Grid,
23
+ Heading,
24
+ Input,
25
+ Metric,
26
+ NumberInput,
27
+ Row,
28
+ Segmented,
29
+ Skeleton,
30
+ Stack,
31
+ Stat,
32
+ Table,
33
+ Text,
34
+ } from "@vibes.diy/pouf";
35
+ import { Dialog, Toaster, toast } from "@vibes.diy/pouf/interactive";
36
+
37
+ const NUMBER = new Intl.NumberFormat();
38
+ const MONEY = new Intl.NumberFormat(undefined, { style: "currency", currency: "USD" });
39
+
40
+ const FILTERS = [
41
+ { value: "all", label: "All" },
42
+ { value: "low", label: "Low" },
43
+ { value: "out", label: "Out" },
44
+ ];
45
+
46
+ const EMPTY_FORM = { name: "", sku: "", opening: "0", reorderAt: "5", price: "" };
47
+
48
+ function level(stock, reorderAt) {
49
+ if (stock <= 0) return { key: "out", label: "Out of stock", tone: "down" };
50
+ if (stock <= reorderAt) return { key: "low", label: "Low stock", tone: "warn" };
51
+ return { key: "ok", label: "In stock", tone: "up" };
52
+ }
53
+
54
+ /** A whole number of zero or more from a NumberInput string, or undefined. */
55
+ function count(text) {
56
+ const n = Number(text);
57
+ return text.trim() !== "" && Number.isInteger(n) && n >= 0 ? n : undefined;
58
+ }
59
+
60
+ /** Three states for any write surface: resolving, closed to this visitor, open. */
61
+ function Gate({ verdict, closed, children }) {
62
+ if (verdict.pending)
63
+ return (
64
+ <div aria-busy="true">
65
+ <Skeleton variant="card" />
66
+ </div>
67
+ );
68
+ if (!verdict.ok) return <Text muted>{closed}</Text>;
69
+ return children;
70
+ }
71
+
72
+ function ItemCard({ item, stock, database, can, me }) {
73
+ const [qty, setQty] = useState("12");
74
+ const [error, setError] = useState(undefined);
75
+ const [saving, setSaving] = useState(false);
76
+ const status = level(stock, item.reorderAt);
77
+ // The delivery this form writes: one document per movement, never a rewrite of a count.
78
+ const movementDraft = { type: "movement", itemId: item._id, authorHandle: me?.userHandle };
79
+
80
+ async function receive(e) {
81
+ e.preventDefault();
82
+ const n = count(qty);
83
+ if (n === undefined || n === 0) {
84
+ setError("Enter how many arrived.");
85
+ return;
86
+ }
87
+ setError(undefined);
88
+ setSaving(true);
89
+ try {
90
+ await database.put({ ...movementDraft, qty: n, createdAt: Date.now() });
91
+ toast.success(`Received ${NUMBER.format(n)} × ${item.name}`);
92
+ } finally {
93
+ setSaving(false);
94
+ }
95
+ }
96
+
97
+ return (
98
+ <Card>
99
+ <Stack gap={4}>
100
+ <Row justify="between" align="top">
101
+ <Stack gap={1}>
102
+ <Text size="sm" muted mono>
103
+ {item.sku || "No SKU"}
104
+ </Text>
105
+ <Heading level={2}>{item.name}</Heading>
106
+ </Stack>
107
+ <Badge tone={status.tone}>{status.label}</Badge>
108
+ </Row>
109
+ <Row gap={4}>
110
+ <Metric label="On hand" value={NUMBER.format(stock)} />
111
+ <Metric label="Reorder at" value={NUMBER.format(item.reorderAt)} />
112
+ <Metric label="Unit price" value={MONEY.format(item.price)} />
113
+ </Row>
114
+ <Gate verdict={can.create(movementDraft)} closed="Receiving deliveries is for the people who keep this stockroom.">
115
+ <form onSubmit={receive} noValidate>
116
+ <Stack gap={3}>
117
+ <Field label="Units arrived" error={error}>
118
+ {(id, describedBy) => (
119
+ <NumberInput
120
+ id={id}
121
+ describedBy={describedBy}
122
+ value={qty}
123
+ onChange={(value) => {
124
+ setQty(value);
125
+ if (error) setError(undefined);
126
+ }}
127
+ min={1}
128
+ invalid={error !== undefined}
129
+ />
130
+ )}
131
+ </Field>
132
+ <Button type="submit" block loading={saving}>
133
+ Receive
134
+ </Button>
135
+ </Stack>
136
+ </form>
137
+ </Gate>
138
+ </Stack>
139
+ </Card>
140
+ );
141
+ }
142
+
143
+ export default function App() {
144
+ const { database, useLiveQuery } = useFireproof("inventory");
145
+ const { can, me } = useVibe("inventory");
146
+ const { docs } = useLiveQuery("type", { key: "item" });
147
+ const { docs: movements } = useLiveQuery("type", { key: "movement" });
148
+ const items = [...docs].sort((a, b) => a.name.localeCompare(b.name));
149
+ // On hand = the count the item was added with, plus every delivery (and minus every use) since.
150
+ const moved = {};
151
+ for (const m of movements) moved[m.itemId] = (moved[m.itemId] ?? 0) + m.qty;
152
+ const onHand = (item) => item.opening + (moved[item._id] ?? 0);
153
+ const levelOf = (item) => level(onHand(item), item.reorderAt);
154
+
155
+ const [query, setQuery] = useState("");
156
+ const [filter, setFilter] = useState("all");
157
+ const [selectedId, setSelectedId] = useState(undefined);
158
+ const [adding, setAdding] = useState(false);
159
+ const [form, setForm] = useState(EMPTY_FORM);
160
+ const [errors, setErrors] = useState({});
161
+ // The verdict for the doc addItem writes, carrying the same fields it stamps.
162
+ const canAdd = can.create({ type: "item", authorHandle: me?.userHandle });
163
+
164
+ const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
165
+ const needle = query.trim().toLowerCase();
166
+ const visible = items.filter(
167
+ (item) =>
168
+ (filter === "all" || levelOf(item).key === filter) && (needle === "" || `${item.name} ${item.sku}`.toLowerCase().includes(needle))
169
+ );
170
+ const selected = visible.find((item) => item._id === selectedId) ?? visible[0];
171
+ const lowCount = items.filter((item) => levelOf(item).key === "low").length;
172
+ const outCount = items.filter((item) => levelOf(item).key === "out").length;
173
+ const value = items.reduce((sum, item) => sum + Math.max(onHand(item), 0) * item.price, 0);
174
+
175
+ async function addItem(e) {
176
+ e.preventDefault();
177
+ const price = Number(form.price);
178
+ const found = {
179
+ name: form.name.trim() === "" ? "Name the item." : undefined,
180
+ opening: count(form.opening) === undefined ? "Enter a whole number of zero or more." : undefined,
181
+ reorderAt: count(form.reorderAt) === undefined ? "Enter a whole number of zero or more." : undefined,
182
+ price: form.price.trim() === "" || !(price >= 0) ? "Enter a price of zero or more." : undefined,
183
+ };
184
+ setErrors(found);
185
+ if (Object.values(found).some(Boolean)) return;
186
+ const { id } = await database.put({
187
+ type: "item",
188
+ name: form.name.trim(),
189
+ sku: form.sku.trim(),
190
+ opening: count(form.opening),
191
+ reorderAt: count(form.reorderAt),
192
+ price,
193
+ authorHandle: me?.userHandle,
194
+ createdAt: Date.now(),
195
+ });
196
+ setForm(EMPTY_FORM);
197
+ setAdding(false);
198
+ // Clear the search and filter so the new item is on screen, then open it.
199
+ setQuery("");
200
+ setFilter("all");
201
+ setSelectedId(id);
202
+ toast.success("Item added");
203
+ }
204
+
205
+ return (
206
+ <main className="mx-auto max-w-5xl px-4 py-8">
207
+ <Stack gap={5}>
208
+ <Row justify="between" align="top">
209
+ <Stack gap={1}>
210
+ <Heading level={1}>Stockroom</Heading>
211
+ <Text muted>What's ready to sell and what needs reordering.</Text>
212
+ </Stack>
213
+ <Dialog
214
+ title="Add an item"
215
+ open={adding}
216
+ onOpenChange={setAdding}
217
+ trigger={
218
+ <Button variant="quiet" size="sm">
219
+ Add item
220
+ </Button>
221
+ }
222
+ >
223
+ <Gate verdict={canAdd} closed="Adding items opens up once you're keeping this stockroom.">
224
+ <form onSubmit={addItem} noValidate>
225
+ <Stack gap={3}>
226
+ <Field label="Name" error={errors.name}>
227
+ {(id, describedBy) => (
228
+ <Input id={id} describedBy={describedBy} value={form.name} onChange={set("name")} invalid={errors.name !== undefined} />
229
+ )}
230
+ </Field>
231
+ <Field label="SKU" hint="Optional, for your own labels.">
232
+ {(id, describedBy) => <Input id={id} describedBy={describedBy} value={form.sku} onChange={set("sku")} mono />}
233
+ </Field>
234
+ <Field label="On hand now" hint="The count you start from; deliveries add to it." error={errors.opening}>
235
+ {(id, describedBy) => (
236
+ <NumberInput
237
+ id={id}
238
+ describedBy={describedBy}
239
+ value={form.opening}
240
+ onChange={set("opening")}
241
+ min={0}
242
+ invalid={errors.opening !== undefined}
243
+ />
244
+ )}
245
+ </Field>
246
+ <Field label="Reorder at" hint="Flagged low at or below this count." error={errors.reorderAt}>
247
+ {(id, describedBy) => (
248
+ <NumberInput
249
+ id={id}
250
+ describedBy={describedBy}
251
+ value={form.reorderAt}
252
+ onChange={set("reorderAt")}
253
+ min={0}
254
+ invalid={errors.reorderAt !== undefined}
255
+ />
256
+ )}
257
+ </Field>
258
+ <Field label="Unit price" error={errors.price}>
259
+ {(id, describedBy) => (
260
+ <NumberInput
261
+ id={id}
262
+ describedBy={describedBy}
263
+ value={form.price}
264
+ onChange={set("price")}
265
+ min={0}
266
+ step={0.5}
267
+ invalid={errors.price !== undefined}
268
+ />
269
+ )}
270
+ </Field>
271
+ <Row justify="end">
272
+ <Button type="submit">Save item</Button>
273
+ </Row>
274
+ </Stack>
275
+ </form>
276
+ </Gate>
277
+ </Dialog>
278
+ </Row>
279
+
280
+ <Grid cols={3} gap={3}>
281
+ <Stat label="Stock value" value={MONEY.format(value)} icon="card" tone="info" />
282
+ <Stat label="Running low" value={NUMBER.format(lowCount)} icon="warn" tone="warn" />
283
+ <Stat label="Out of stock" value={NUMBER.format(outCount)} icon="fail" tone="down" />
284
+ </Grid>
285
+
286
+ <Stack gap={3}>
287
+ <Field label="Search stock">
288
+ {(id, describedBy) => (
289
+ <Input id={id} describedBy={describedBy} value={query} onChange={setQuery} placeholder="Item name or SKU" inputMode="search" />
290
+ )}
291
+ </Field>
292
+ <Segmented label="Stock status" tone="info" value={filter} onChange={setFilter} options={FILTERS} />
293
+ </Stack>
294
+
295
+ {visible.length === 0 ? (
296
+ <Empty icon="search" title={items.length === 0 ? "Nothing in stock yet" : "No items match"}>
297
+ {items.length === 0 ? "Add the first item to start counting." : "Try another search or stock filter."}
298
+ </Empty>
299
+ ) : (
300
+ <Grid cols="sidebar" gap={4}>
301
+ <Card variant="flush">
302
+ <Table
303
+ rows={visible}
304
+ getKey={(item) => item._id}
305
+ onRowClick={(item) => setSelectedId(item._id)}
306
+ getRowLabel={(item) => `Open ${item.name}`}
307
+ columns={[
308
+ { key: "name", header: "Item", truncate: true, render: (item) => item.name, sort: (a, b) => a.name.localeCompare(b.name) },
309
+ { key: "sku", header: "SKU", mono: true, render: (item) => item.sku },
310
+ {
311
+ key: "status",
312
+ header: "Status",
313
+ render: (item) => <Badge tone={levelOf(item).tone}>{levelOf(item).label}</Badge>,
314
+ },
315
+ {
316
+ key: "stock",
317
+ header: "On hand",
318
+ align: "right",
319
+ render: (item) => NUMBER.format(onHand(item)),
320
+ sort: (a, b) => onHand(a) - onHand(b),
321
+ },
322
+ ]}
323
+ />
324
+ </Card>
325
+ <ItemCard key={selected._id} item={selected} stock={onHand(selected)} database={database} can={can} me={me} />
326
+ </Grid>
327
+ )}
328
+ </Stack>
329
+ <div className="pouf-toasts">
330
+ <Toaster />
331
+ </div>
332
+ </main>
333
+ );
334
+ }
335
+ ```
336
+
337
+ ## Adapting
338
+
339
+ - Rename items to the app's own noun (`product`, `supply`, `ingredient`) in the `type` key, the headings and the copy; keep one document per item and one per movement, linked by `itemId`.
340
+ - Tone the status by meaning: `up` in stock, `warn` at or below the reorder level, `down` out; the `Stat`s above follow the same tones.
341
+ - Selling or using stock is one more `movement` with a negative `qty`, written by a second form behind the same `can.create(movementDraft)` verdict; never rewrite a count on the item, because sync keeps only the last write and a concurrent delivery would vanish.
342
+ - A category is one more `Field` and property, filtered with a `Select` beside the search, not a second database.
343
+ - 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.
344
+
345
+ Adapted from 1st-Pouf (MIT) https://1st-pouf.worksonmy.dev/r/inventory.json
@@ -0,0 +1,2 @@
1
+ import type { LlmConfig } from "./types.js";
2
+ export declare const poufKanbanConfig: LlmConfig;