@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.
- package/llms/index.d.ts +29 -1
- package/llms/index.js +84 -0
- package/llms/index.js.map +1 -1
- package/llms/pouf-blog.d.ts +2 -0
- package/llms/pouf-blog.js +6 -0
- package/llms/pouf-blog.js.map +1 -0
- package/llms/pouf-blog.md +305 -0
- package/llms/pouf-booking.d.ts +2 -0
- package/llms/pouf-booking.js +6 -0
- package/llms/pouf-booking.js.map +1 -0
- package/llms/pouf-booking.md +397 -0
- package/llms/pouf-calendar.d.ts +2 -0
- package/llms/pouf-calendar.js +6 -0
- package/llms/pouf-calendar.js.map +1 -0
- package/llms/pouf-calendar.md +352 -0
- package/llms/pouf-chat.d.ts +2 -0
- package/llms/pouf-chat.js +6 -0
- package/llms/pouf-chat.js.map +1 -0
- package/llms/pouf-chat.md +220 -0
- package/llms/pouf-contact.d.ts +2 -0
- package/llms/pouf-contact.js +6 -0
- package/llms/pouf-contact.js.map +1 -0
- package/llms/pouf-contact.md +231 -0
- package/llms/pouf-course.d.ts +2 -0
- package/llms/pouf-course.js +6 -0
- package/llms/pouf-course.js.map +1 -0
- package/llms/pouf-course.md +299 -0
- package/llms/pouf-crm.d.ts +2 -0
- package/llms/pouf-crm.js +6 -0
- package/llms/pouf-crm.js.map +1 -0
- package/llms/pouf-crm.md +330 -0
- package/llms/pouf-dashboard.d.ts +2 -0
- package/llms/pouf-dashboard.js +6 -0
- package/llms/pouf-dashboard.js.map +1 -0
- package/llms/pouf-dashboard.md +269 -0
- package/llms/pouf-editorial.d.ts +2 -0
- package/llms/pouf-editorial.js +6 -0
- package/llms/pouf-editorial.js.map +1 -0
- package/llms/pouf-editorial.md +247 -0
- package/llms/pouf-event.d.ts +2 -0
- package/llms/pouf-event.js +6 -0
- package/llms/pouf-event.js.map +1 -0
- package/llms/pouf-event.md +237 -0
- package/llms/pouf-feed.d.ts +2 -0
- package/llms/pouf-feed.js +6 -0
- package/llms/pouf-feed.js.map +1 -0
- package/llms/pouf-feed.md +249 -0
- package/llms/pouf-game.d.ts +2 -0
- package/llms/pouf-game.js +6 -0
- package/llms/pouf-game.js.map +1 -0
- package/llms/pouf-game.md +273 -0
- package/llms/pouf-inbox.d.ts +2 -0
- package/llms/pouf-inbox.js +6 -0
- package/llms/pouf-inbox.js.map +1 -0
- package/llms/pouf-inbox.md +317 -0
- package/llms/pouf-inventory.d.ts +2 -0
- package/llms/pouf-inventory.js +6 -0
- package/llms/pouf-inventory.js.map +1 -0
- package/llms/pouf-inventory.md +345 -0
- package/llms/pouf-kanban.d.ts +2 -0
- package/llms/pouf-kanban.js +6 -0
- package/llms/pouf-kanban.js.map +1 -0
- package/llms/pouf-kanban.md +235 -0
- package/llms/pouf-landing.d.ts +2 -0
- package/llms/pouf-landing.js +6 -0
- package/llms/pouf-landing.js.map +1 -0
- package/llms/pouf-landing.md +222 -0
- package/llms/pouf-login.d.ts +2 -0
- package/llms/pouf-login.js +6 -0
- package/llms/pouf-login.js.map +1 -0
- package/llms/pouf-login.md +162 -0
- package/llms/pouf-music.d.ts +2 -0
- package/llms/pouf-music.js +6 -0
- package/llms/pouf-music.js.map +1 -0
- package/llms/pouf-music.md +190 -0
- package/llms/pouf-onboarding.d.ts +2 -0
- package/llms/pouf-onboarding.js +6 -0
- package/llms/pouf-onboarding.js.map +1 -0
- package/llms/pouf-onboarding.md +227 -0
- package/llms/pouf-pricing.d.ts +2 -0
- package/llms/pouf-pricing.js +6 -0
- package/llms/pouf-pricing.js.map +1 -0
- package/llms/pouf-pricing.md +193 -0
- package/llms/pouf-profile.d.ts +2 -0
- package/llms/pouf-profile.js +6 -0
- package/llms/pouf-profile.js.map +1 -0
- package/llms/pouf-profile.md +177 -0
- package/llms/pouf-quiz.d.ts +2 -0
- package/llms/pouf-quiz.js +6 -0
- package/llms/pouf-quiz.js.map +1 -0
- package/llms/pouf-quiz.md +267 -0
- package/llms/pouf-settings.d.ts +2 -0
- package/llms/pouf-settings.js +6 -0
- package/llms/pouf-settings.js.map +1 -0
- package/llms/pouf-settings.md +220 -0
- package/llms/pouf-storefront.d.ts +2 -0
- package/llms/pouf-storefront.js +6 -0
- package/llms/pouf-storefront.js.map +1 -0
- package/llms/pouf-storefront.md +293 -0
- package/llms/pouf-support.d.ts +2 -0
- package/llms/pouf-support.js +6 -0
- package/llms/pouf-support.js.map +1 -0
- package/llms/pouf-support.md +322 -0
- package/llms/pouf-testimonials.d.ts +2 -0
- package/llms/pouf-testimonials.js +6 -0
- package/llms/pouf-testimonials.js.map +1 -0
- package/llms/pouf-testimonials.md +156 -0
- package/llms/pouf-todo.d.ts +2 -0
- package/llms/pouf-todo.js +6 -0
- package/llms/pouf-todo.js.map +1 -0
- package/llms/pouf-todo.md +179 -0
- package/llms/pouf-weather.d.ts +2 -0
- package/llms/pouf-weather.js +6 -0
- package/llms/pouf-weather.js.map +1 -0
- package/llms/pouf-weather.md +186 -0
- package/package.json +4 -4
- package/prompts.js +2 -2
- package/prompts.js.map +1 -1
- package/themes/colorsets.d.ts +1 -1
- package/themes/colorsets.js +63 -1
- package/themes/colorsets.js.map +1 -1
- package/themes/index.d.ts +1 -0
- package/themes/index.js +1 -0
- package/themes/index.js.map +1 -1
- package/themes/looks/index.js +6 -2
- package/themes/looks/index.js.map +1 -1
- package/themes/looks/pouf/brief.md +144 -0
- package/themes/looks/pouf/catalog.generated.d.ts +16 -0
- package/themes/looks/pouf/catalog.generated.js +764 -0
- package/themes/looks/pouf/catalog.generated.js.map +1 -0
- package/themes/looks/pouf/variety.d.ts +4 -0
- package/themes/looks/pouf/variety.js +36 -0
- package/themes/looks/pouf/variety.js.map +1 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# Pouf Feed
|
|
2
|
+
|
|
3
|
+
A stream of short posts that people write, like and talk under: a club's news board, a family update wall, a class's shout-outs, a team's wins channel. Reach for it whenever the app's core is people posting a few sentences for each other and reacting to what others posted. Posts, likes and comments persist, so the conversation is waiting 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: the feed. The `Heading` and a muted count open the page; the composer sits on a `Card` with a `Textarea` in a `Field`, and its `Post` button is the view's one lead. Each post is its own `Card`: the author as a `ViewerTag`, a muted time, the words in `Text`, then a `Row` of quiet buttons for like and comments, each with a `label` because the visible child is an icon and a number. Opening the comments shows them indented under the post with a reply composer of their own. Deleting a post is irreversible, so it asks first with a `Confirm` wearing `tone="down"` and says it is done with a `toast`; an `Empty` stands in before the first post. A like is its own document with a deterministic `_id`, so two people liking at once never overwrite a counter. Every write waits on its `can` verdict from `useVibe("feed")`, the database the posts live in: the composer shows a `Skeleton` inside an `aria-busy` wrapper while the verdict resolves and a sentence in the app's own words when posting is closed to this visitor; a like button is disabled unless its like can be created (or, to unlike, deleted); the reply composer follows the same three states; and the delete button renders only where `can.delete(post).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, Empty, Field, Heading, Icon, IconButton, Input, Row, Skeleton, Spacer, Stack, Text, Textarea } from "@vibes.diy/pouf";
|
|
17
|
+
import { Confirm, Toaster, toast } from "@vibes.diy/pouf/interactive";
|
|
18
|
+
|
|
19
|
+
const when = (ms) => new Date(ms).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
|
20
|
+
|
|
21
|
+
function Author({ handle }) {
|
|
22
|
+
const { ViewerTag } = useViewer();
|
|
23
|
+
return handle ? <ViewerTag userHandle={handle} /> : <Text>A guest</Text>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Post({ post, likes, comments, database, can, me }) {
|
|
27
|
+
const [open, setOpen] = useState(false);
|
|
28
|
+
const [reply, setReply] = useState("");
|
|
29
|
+
|
|
30
|
+
const mine = likes.find((l) => l.authorHandle === me?.userHandle);
|
|
31
|
+
// The like this visitor would write: a deterministic id, so liking twice lands on one document.
|
|
32
|
+
const likeDraft = { _id: `like:${post._id}:${me?.userHandle}`, type: "like", postId: post._id, authorHandle: me?.userHandle };
|
|
33
|
+
const canToggle = mine ? can.delete(mine) : can.create(likeDraft);
|
|
34
|
+
const canReply = can.create({ type: "comment", postId: post._id, authorHandle: me?.userHandle });
|
|
35
|
+
|
|
36
|
+
function toggleLike() {
|
|
37
|
+
if (mine) database.del(mine._id);
|
|
38
|
+
else database.put(likeDraft);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function sendReply(e) {
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
const text = reply.trim();
|
|
44
|
+
if (text === "") return;
|
|
45
|
+
await database.put({ type: "comment", postId: post._id, text, authorHandle: me?.userHandle, createdAt: Date.now() });
|
|
46
|
+
setReply("");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function remove() {
|
|
50
|
+
// The post goes; its likes and comments go too wherever this visitor may remove them.
|
|
51
|
+
const gone = [post, ...likes, ...comments].filter((d) => can.delete(d).ok);
|
|
52
|
+
await Promise.all(gone.map((d) => database.del(d._id)));
|
|
53
|
+
toast.success("Post deleted");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<Card>
|
|
58
|
+
<Stack gap={3}>
|
|
59
|
+
<Row gap={2} wrap={false}>
|
|
60
|
+
<Author handle={post.authorHandle} />
|
|
61
|
+
<Text size="sm" muted>
|
|
62
|
+
{when(post.createdAt)}
|
|
63
|
+
</Text>
|
|
64
|
+
<Spacer />
|
|
65
|
+
{can.delete(post).ok && (
|
|
66
|
+
<Confirm
|
|
67
|
+
title="Delete this post?"
|
|
68
|
+
body="The post is removed for everyone, along with the comments and likes you're allowed to remove. This can't be undone."
|
|
69
|
+
confirmLabel="Delete post"
|
|
70
|
+
cancelLabel="Keep it"
|
|
71
|
+
tone="down"
|
|
72
|
+
onConfirm={remove}
|
|
73
|
+
>
|
|
74
|
+
<IconButton size="sm" label="Delete post" icon={<Icon name="remove" size="sm" />} />
|
|
75
|
+
</Confirm>
|
|
76
|
+
)}
|
|
77
|
+
</Row>
|
|
78
|
+
<Text>{post.text}</Text>
|
|
79
|
+
<Row gap={2}>
|
|
80
|
+
<Button
|
|
81
|
+
size="sm"
|
|
82
|
+
variant="quiet"
|
|
83
|
+
aria-pressed={Boolean(mine)}
|
|
84
|
+
disabled={!canToggle.ok}
|
|
85
|
+
label={mine ? "Unlike this post" : "Like this post"}
|
|
86
|
+
onClick={toggleLike}
|
|
87
|
+
>
|
|
88
|
+
<Icon name={mine ? "heart-filled" : "heart"} size="sm" /> {likes.length}
|
|
89
|
+
</Button>
|
|
90
|
+
<Button
|
|
91
|
+
size="sm"
|
|
92
|
+
variant="quiet"
|
|
93
|
+
aria-expanded={open}
|
|
94
|
+
label={`${open ? "Hide" : "Show"} ${comments.length === 1 ? "1 comment" : `${comments.length} comments`}`}
|
|
95
|
+
onClick={() => setOpen((o) => !o)}
|
|
96
|
+
>
|
|
97
|
+
<Icon name="comment" size="sm" /> {comments.length}
|
|
98
|
+
</Button>
|
|
99
|
+
</Row>
|
|
100
|
+
|
|
101
|
+
{open && (
|
|
102
|
+
// Indented under the author, the way a reply nests under its post.
|
|
103
|
+
<div className="pl-10">
|
|
104
|
+
<Stack gap={3}>
|
|
105
|
+
{comments.map((c) => (
|
|
106
|
+
<Stack key={c._id} gap={1}>
|
|
107
|
+
<Row gap={2} wrap={false}>
|
|
108
|
+
<Author handle={c.authorHandle} />
|
|
109
|
+
<Text size="sm" muted>
|
|
110
|
+
{when(c.createdAt)}
|
|
111
|
+
</Text>
|
|
112
|
+
</Row>
|
|
113
|
+
<Text size="sm">{c.text}</Text>
|
|
114
|
+
</Stack>
|
|
115
|
+
))}
|
|
116
|
+
{canReply.pending ? (
|
|
117
|
+
<div aria-busy="true">
|
|
118
|
+
<Skeleton variant="row" />
|
|
119
|
+
</div>
|
|
120
|
+
) : !canReply.ok ? (
|
|
121
|
+
<Text size="sm" muted>
|
|
122
|
+
Comments open up once you've joined the club.
|
|
123
|
+
</Text>
|
|
124
|
+
) : (
|
|
125
|
+
<form onSubmit={sendReply}>
|
|
126
|
+
<Row gap={2} wrap={false}>
|
|
127
|
+
<div className="min-w-0 flex-1">
|
|
128
|
+
<Input value={reply} onChange={setReply} placeholder="Write a comment" label="Your comment" autoComplete="off" />
|
|
129
|
+
</div>
|
|
130
|
+
<Button size="sm" variant="quiet" type="submit" disabled={reply.trim() === ""}>
|
|
131
|
+
Reply
|
|
132
|
+
</Button>
|
|
133
|
+
</Row>
|
|
134
|
+
</form>
|
|
135
|
+
)}
|
|
136
|
+
</Stack>
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
</Stack>
|
|
140
|
+
</Card>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export default function App() {
|
|
145
|
+
const { database, useLiveQuery } = useFireproof("feed");
|
|
146
|
+
const { can, me } = useVibe("feed");
|
|
147
|
+
const { docs: postDocs } = useLiveQuery("type", { key: "post" });
|
|
148
|
+
const { docs: likes } = useLiveQuery("type", { key: "like" });
|
|
149
|
+
const { docs: comments } = useLiveQuery("type", { key: "comment" });
|
|
150
|
+
const posts = [...postDocs].sort((a, b) => b.createdAt - a.createdAt);
|
|
151
|
+
|
|
152
|
+
const [draft, setDraft] = useState("");
|
|
153
|
+
const [error, setError] = useState(undefined);
|
|
154
|
+
// The verdict for the doc publish writes, carrying the same fields it stamps.
|
|
155
|
+
const canPost = can.create({ type: "post", authorHandle: me?.userHandle });
|
|
156
|
+
|
|
157
|
+
async function publish(e) {
|
|
158
|
+
e.preventDefault();
|
|
159
|
+
const text = draft.trim();
|
|
160
|
+
if (text === "") {
|
|
161
|
+
setError("Write something first.");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
setError(undefined);
|
|
165
|
+
await database.put({ type: "post", text, authorHandle: me?.userHandle, createdAt: Date.now() });
|
|
166
|
+
// Cleared only once the write lands, so a local failure keeps the typed words.
|
|
167
|
+
setDraft("");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<main className="mx-auto max-w-xl px-4 py-8">
|
|
172
|
+
<Stack gap={5}>
|
|
173
|
+
<Stack gap={1}>
|
|
174
|
+
<Heading level={1}>Club news</Heading>
|
|
175
|
+
<Text muted>{posts.length === 1 ? "1 post" : `${posts.length} posts`}</Text>
|
|
176
|
+
</Stack>
|
|
177
|
+
|
|
178
|
+
{canPost.pending ? (
|
|
179
|
+
<div aria-busy="true">
|
|
180
|
+
<Skeleton variant="card" />
|
|
181
|
+
</div>
|
|
182
|
+
) : !canPost.ok ? (
|
|
183
|
+
<Text muted>You're reading along. Posting opens up once you've joined the club.</Text>
|
|
184
|
+
) : (
|
|
185
|
+
<Card>
|
|
186
|
+
<form onSubmit={publish} noValidate>
|
|
187
|
+
<Stack gap={3}>
|
|
188
|
+
<Field label="What's new?" error={error}>
|
|
189
|
+
{(id, describedBy) => (
|
|
190
|
+
<Textarea
|
|
191
|
+
id={id}
|
|
192
|
+
describedBy={describedBy}
|
|
193
|
+
value={draft}
|
|
194
|
+
onChange={(value) => {
|
|
195
|
+
setDraft(value);
|
|
196
|
+
if (error) setError(undefined);
|
|
197
|
+
}}
|
|
198
|
+
placeholder="Finished chapter four, the twist got me"
|
|
199
|
+
rows={2}
|
|
200
|
+
invalid={error !== undefined}
|
|
201
|
+
/>
|
|
202
|
+
)}
|
|
203
|
+
</Field>
|
|
204
|
+
<Row justify="end">
|
|
205
|
+
<Button type="submit">Post</Button>
|
|
206
|
+
</Row>
|
|
207
|
+
</Stack>
|
|
208
|
+
</form>
|
|
209
|
+
</Card>
|
|
210
|
+
)}
|
|
211
|
+
|
|
212
|
+
{posts.length === 0 ? (
|
|
213
|
+
<Empty icon="comment" title="No posts yet">
|
|
214
|
+
The first update you share shows up here.
|
|
215
|
+
</Empty>
|
|
216
|
+
) : (
|
|
217
|
+
<Stack gap={4}>
|
|
218
|
+
{posts.map((post) => (
|
|
219
|
+
<Post
|
|
220
|
+
key={post._id}
|
|
221
|
+
post={post}
|
|
222
|
+
likes={likes.filter((l) => l.postId === post._id)}
|
|
223
|
+
comments={comments.filter((c) => c.postId === post._id).sort((a, b) => a.createdAt - b.createdAt)}
|
|
224
|
+
database={database}
|
|
225
|
+
can={can}
|
|
226
|
+
me={me}
|
|
227
|
+
/>
|
|
228
|
+
))}
|
|
229
|
+
</Stack>
|
|
230
|
+
)}
|
|
231
|
+
</Stack>
|
|
232
|
+
<div className="pouf-toasts">
|
|
233
|
+
<Toaster />
|
|
234
|
+
</div>
|
|
235
|
+
</main>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Adapting
|
|
241
|
+
|
|
242
|
+
- Rename the record to the app's own noun (`update`, `shout-out`, `win`) in the `type` key, the heading and the copy; keep one document per post, per comment and per like.
|
|
243
|
+
- Keep a like as its own document keyed `like:<post>:<person>`; a counter on the post loses likes when two people tap at once.
|
|
244
|
+
- A photo on a post is a `_files` attachment on the post document, shown with `Figure`; a tag is a `Badge` with an explicit meaning tone.
|
|
245
|
+
- Comments and likes another person wrote stay behind when a post goes if this visitor may not delete them; they never show, because the feed only lists children of posts that exist. An access rule that lets a post's author delete what sits under it clears them too.
|
|
246
|
+
- Authors are always `ViewerTag` with the stored handle; never type a name or build an avatar by hand.
|
|
247
|
+
- 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.
|
|
248
|
+
|
|
249
|
+
Adapted from 1st-Pouf (MIT) https://1st-pouf.worksonmy.dev/r/feed.json
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pouf-game.js","sourceRoot":"","sources":["../../jsr/llms/pouf-game.ts"],"names":[],"mappings":"AAMA,MAAM,CAAC,MAAM,cAAc,GAAc;IACvC,IAAI,EAAE,WAAW;IACjB,KAAK,EAAE,iBAAiB;IACxB,WAAW,EAAE,0EAA0E;CACxF,CAAC"}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# Pouf Game
|
|
2
|
+
|
|
3
|
+
A small turn-based game screen with a scoreboard that remembers: a boss fight for friends, a kids' dungeon run, a classroom quiz battle, a quick office break game. Reach for it whenever the app's core is a short run someone plays through, with bars that move, a few items to equip and a high-score table to beat. The run itself lives in the page while it is played; each finished run that someone saves is its own score document, so the leaderboard is there on the next visit and for everyone who 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: play the run, then post the score. An `Eyebrow`, the `Heading` and two `Stat`s (this run's score `info`, the visitor's best `up`) open the page. The fight sits on a `Card`: the boss's health as a `Progress` toned `down`, the player's as `up` (turning `warn` when low), the spell charge as `info`, each with a muted numeric readout, and `Attack` as the view's one lead beside a quiet `Cast spell`. The boss hits back after every move it survives, a cast that only charges included, so attacking and casting both cost health and the scores spread out instead of piling up at one ceiling. The inventory is one clickable `RowCard` per item with a decorative `orange` `Blob`, its name and effect in `Text`, and a `Badge` for its state (`up` equipped, `idle` not). When the run ends the actions give way to the result: `Save score` becomes the lead, with `loading` while the write lands, and a quiet `Play again`. The leaderboard `Table` shows each player's best saved run, the player as a `ViewerTag`. Saving waits on its `can` verdict from `useVibe("arcade")`, the database the scores live in: a `Skeleton` inside an `aria-busy` wrapper while it resolves, a `Sign in to save your score` lead wired to `useViewer().requestLogin()` for a visitor who has not signed in, and a sentence in the app's own words when saving is closed. Anyone can play without signing in. The finished run lives only in the page until it is saved, so if signing in reloads the app that run is gone; the copy says so rather than promising otherwise.
|
|
10
|
+
|
|
11
|
+
### `App.jsx`
|
|
12
|
+
```jsx
|
|
13
|
+
import React, { useState } from "react";
|
|
14
|
+
import { useFireproof } from "use-fireproof";
|
|
15
|
+
import { useViewer, useVibe } from "use-vibes";
|
|
16
|
+
import { Badge, Blob, Button, Card, Empty, Eyebrow, Grid, Heading, Row, RowCard, Skeleton, Stack, Stat, Table, Text } from "@vibes.diy/pouf";
|
|
17
|
+
import { Progress } from "@vibes.diy/pouf/interactive";
|
|
18
|
+
|
|
19
|
+
const BOSS_MAX = 120;
|
|
20
|
+
// The boss's counter-hit: 10 to 16 each turn, less whatever armour blocks.
|
|
21
|
+
const BOSS_HIT = 10;
|
|
22
|
+
const ITEMS = [
|
|
23
|
+
{ id: "sword", name: "Iron sword", effect: "Hits for 14 to 19", icon: "sword", slot: "weapon", atk: 14, cost: 0 },
|
|
24
|
+
{ id: "wand", name: "Clay wand", effect: "Hits for 22 to 27, costs 6 health a swing", icon: "wand", slot: "weapon", atk: 22, cost: 6 },
|
|
25
|
+
{ id: "shield", name: "Oak shield", effect: "Blocks 5 of every counter-hit", icon: "shield", slot: "armor", block: 5 },
|
|
26
|
+
{ id: "charm", name: "Spark charm", effect: "Each cast charges 10 more", icon: "sparkle", slot: "charm", boost: 10 },
|
|
27
|
+
{ id: "potion", name: "Health potion", effect: "Restores 30 health, once", icon: "drop", slot: "potion", heal: 30 },
|
|
28
|
+
];
|
|
29
|
+
const FRESH = { hp: 100, boss: BOSS_MAX, charge: 0, score: 0, gear: { weapon: "sword" }, potionUsed: false };
|
|
30
|
+
|
|
31
|
+
function Player({ handle }) {
|
|
32
|
+
const { ViewerTag } = useViewer();
|
|
33
|
+
return <ViewerTag userHandle={handle} />;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default function App() {
|
|
37
|
+
const { database, useLiveQuery } = useFireproof("arcade");
|
|
38
|
+
const { docs: scores } = useLiveQuery("type", { key: "score" });
|
|
39
|
+
const { can, me } = useVibe("arcade");
|
|
40
|
+
const { requestLogin } = useViewer();
|
|
41
|
+
|
|
42
|
+
// The run is a draft: it lives in the page until it ends and someone saves it.
|
|
43
|
+
const [run, setRun] = useState(FRESH);
|
|
44
|
+
const [saving, setSaving] = useState(false);
|
|
45
|
+
const [savedRun, setSavedRun] = useState(undefined);
|
|
46
|
+
|
|
47
|
+
const over = run.boss <= 0 ? "won" : run.hp <= 0 ? "lost" : undefined;
|
|
48
|
+
const gearOf = (r, slot) => ITEMS.find((i) => i.id === r.gear[slot]);
|
|
49
|
+
|
|
50
|
+
// One score document per saved run; the board keeps each player's best.
|
|
51
|
+
const best = new Map();
|
|
52
|
+
for (const s of scores) if ((best.get(s.authorHandle)?.score ?? -1) < s.score) best.set(s.authorHandle, s);
|
|
53
|
+
const board = [...best.values()]
|
|
54
|
+
.sort((a, b) => b.score - a.score)
|
|
55
|
+
.slice(0, 10)
|
|
56
|
+
.map((s, i) => ({ ...s, rank: i + 1 }));
|
|
57
|
+
const myBest = me ? best.get(me.userHandle)?.score : undefined;
|
|
58
|
+
|
|
59
|
+
// The verdict for the doc saveScore writes, carrying the same fields it stamps.
|
|
60
|
+
const canSave = can.create({ type: "score", authorHandle: me?.userHandle });
|
|
61
|
+
|
|
62
|
+
// The boss strikes back after every move it survives, so neither move is free;
|
|
63
|
+
// winning pays a bonus for the health left over. The dice are rolled before
|
|
64
|
+
// the update so it stays pure.
|
|
65
|
+
function endTurn(r, selfCost, roll) {
|
|
66
|
+
if (r.boss === 0) return { ...r, score: r.score + 500 + r.hp * 10 };
|
|
67
|
+
const counter = Math.max(2, BOSS_HIT + roll - (gearOf(r, "armor")?.block ?? 0));
|
|
68
|
+
return { ...r, hp: Math.max(0, r.hp - counter - selfCost) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Every move reads the latest run through setRun((r) => …), so two quick
|
|
72
|
+
// clicks play two turns instead of one overwriting the other, and a move made
|
|
73
|
+
// after the fight ended changes nothing.
|
|
74
|
+
function attack() {
|
|
75
|
+
const roll = Math.floor(Math.random() * 6);
|
|
76
|
+
const counter = Math.floor(Math.random() * 7);
|
|
77
|
+
setRun((r) => {
|
|
78
|
+
if (r.boss <= 0 || r.hp <= 0) return r;
|
|
79
|
+
const weapon = gearOf(r, "weapon");
|
|
80
|
+
const hit = (weapon?.atk ?? 8) + roll;
|
|
81
|
+
return endTurn({ ...r, boss: Math.max(0, r.boss - hit), score: r.score + hit * 10 }, weapon?.cost ?? 0, counter);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function cast() {
|
|
86
|
+
const blast = 40 + Math.floor(Math.random() * 11);
|
|
87
|
+
const counter = Math.floor(Math.random() * 7);
|
|
88
|
+
setRun((r) => {
|
|
89
|
+
if (r.boss <= 0 || r.hp <= 0) return r;
|
|
90
|
+
const charge = r.charge + 25 + (gearOf(r, "charm")?.boost ?? 0);
|
|
91
|
+
// Charging spends the turn, so the boss still hits back.
|
|
92
|
+
if (charge < 100) return endTurn({ ...r, charge }, 0, counter);
|
|
93
|
+
// A full charge fires the spell: a big hit that scores like any other damage.
|
|
94
|
+
return endTurn({ ...r, charge: 0, boss: Math.max(0, r.boss - blast), score: r.score + blast * 10 }, 0, counter);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function toggleItem(item) {
|
|
99
|
+
setRun((r) => {
|
|
100
|
+
if (item.slot === "potion") return r.potionUsed ? r : { ...r, potionUsed: true, hp: Math.min(100, r.hp + item.heal) };
|
|
101
|
+
const on = r.gear[item.slot] === item.id;
|
|
102
|
+
return { ...r, gear: { ...r.gear, [item.slot]: on ? undefined : item.id } };
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function playAgain() {
|
|
107
|
+
setRun(FRESH);
|
|
108
|
+
setSavedRun(undefined);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function saveScore() {
|
|
112
|
+
setSaving(true);
|
|
113
|
+
try {
|
|
114
|
+
await database.put({ type: "score", score: run.score, result: over, authorHandle: me?.userHandle, createdAt: Date.now() });
|
|
115
|
+
setSavedRun(run);
|
|
116
|
+
} finally {
|
|
117
|
+
setSaving(false);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let ending;
|
|
122
|
+
if (savedRun === run) ending = <Text muted>Saved. Your run is on the board.</Text>;
|
|
123
|
+
else if (canSave.pending)
|
|
124
|
+
ending = (
|
|
125
|
+
<div aria-busy="true">
|
|
126
|
+
<Skeleton variant="row" />
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
else if (!me)
|
|
130
|
+
ending = (
|
|
131
|
+
<Stack gap={2}>
|
|
132
|
+
<Text size="sm" muted>
|
|
133
|
+
Sign in to put this run on the board. Signing in may start a fresh run.
|
|
134
|
+
</Text>
|
|
135
|
+
<Button onClick={requestLogin}>Sign in to save your score</Button>
|
|
136
|
+
</Stack>
|
|
137
|
+
);
|
|
138
|
+
else if (!canSave.ok) ending = <Text muted>The board is closed to new scores right now. Play on for fun.</Text>;
|
|
139
|
+
else
|
|
140
|
+
ending = (
|
|
141
|
+
<Button loading={saving} onClick={saveScore}>
|
|
142
|
+
Save score
|
|
143
|
+
</Button>
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<main className="mx-auto max-w-3xl px-4 py-8">
|
|
148
|
+
<Stack gap={5}>
|
|
149
|
+
<Stack gap={1}>
|
|
150
|
+
<Eyebrow>Cushion Caverns</Eyebrow>
|
|
151
|
+
<Heading level={1}>Boss fight</Heading>
|
|
152
|
+
</Stack>
|
|
153
|
+
|
|
154
|
+
<Grid cols={2}>
|
|
155
|
+
<Stat label="This run" value={run.score.toLocaleString()} icon="target" tone="info" />
|
|
156
|
+
<Stat label="Your best" value={myBest === undefined ? "—" : myBest.toLocaleString()} icon="trophy" tone="up" />
|
|
157
|
+
</Grid>
|
|
158
|
+
|
|
159
|
+
<Card>
|
|
160
|
+
<Stack gap={4}>
|
|
161
|
+
<Stack gap={2}>
|
|
162
|
+
<Row justify="between">
|
|
163
|
+
<Text>The Clay Golem</Text>
|
|
164
|
+
<Text num muted>
|
|
165
|
+
{run.boss} / {BOSS_MAX}
|
|
166
|
+
</Text>
|
|
167
|
+
</Row>
|
|
168
|
+
<Progress value={run.boss} max={BOSS_MAX} tone="down" label="Boss health" />
|
|
169
|
+
</Stack>
|
|
170
|
+
<Stack gap={2}>
|
|
171
|
+
<Row justify="between">
|
|
172
|
+
<Text>Your health</Text>
|
|
173
|
+
<Text num muted>
|
|
174
|
+
{run.hp} / 100
|
|
175
|
+
</Text>
|
|
176
|
+
</Row>
|
|
177
|
+
<Progress value={run.hp} tone={run.hp < 30 ? "warn" : "up"} label="Your health" />
|
|
178
|
+
</Stack>
|
|
179
|
+
<Stack gap={2}>
|
|
180
|
+
<Row justify="between">
|
|
181
|
+
<Text>Spell charge</Text>
|
|
182
|
+
<Text num muted>
|
|
183
|
+
{run.charge} / 100
|
|
184
|
+
</Text>
|
|
185
|
+
</Row>
|
|
186
|
+
<Progress value={run.charge} tone="info" label="Spell charge" />
|
|
187
|
+
</Stack>
|
|
188
|
+
|
|
189
|
+
{over ? (
|
|
190
|
+
<Stack gap={3}>
|
|
191
|
+
<Heading level={2}>{over === "won" ? "The golem crumbles!" : "Knocked out"}</Heading>
|
|
192
|
+
<Row gap={2}>
|
|
193
|
+
{ending}
|
|
194
|
+
<Button variant="quiet" onClick={playAgain}>
|
|
195
|
+
Play again
|
|
196
|
+
</Button>
|
|
197
|
+
</Row>
|
|
198
|
+
</Stack>
|
|
199
|
+
) : (
|
|
200
|
+
<Row gap={2}>
|
|
201
|
+
<Button onClick={attack}>Attack</Button>
|
|
202
|
+
<Button variant="quiet" onClick={cast}>
|
|
203
|
+
Cast spell
|
|
204
|
+
</Button>
|
|
205
|
+
</Row>
|
|
206
|
+
)}
|
|
207
|
+
</Stack>
|
|
208
|
+
</Card>
|
|
209
|
+
|
|
210
|
+
<Card>
|
|
211
|
+
<Stack gap={4}>
|
|
212
|
+
<Heading level={2}>Inventory</Heading>
|
|
213
|
+
<Stack gap={2}>
|
|
214
|
+
{ITEMS.map((item) => {
|
|
215
|
+
const used = item.slot === "potion" && run.potionUsed;
|
|
216
|
+
const equipped = item.slot !== "potion" && run.gear[item.slot] === item.id;
|
|
217
|
+
const state = item.slot === "potion" ? (used ? "Used" : "Use") : equipped ? "Equipped" : "Equip";
|
|
218
|
+
return (
|
|
219
|
+
<RowCard key={item.id} onClick={used || over ? undefined : () => toggleItem(item)}>
|
|
220
|
+
<Row justify="between" wrap={false}>
|
|
221
|
+
<Row gap={3} wrap={false}>
|
|
222
|
+
<Blob icon={item.icon} tone="orange" size="sm" />
|
|
223
|
+
<Stack gap={1}>
|
|
224
|
+
<Text>{item.name}</Text>
|
|
225
|
+
<Text size="sm" muted>
|
|
226
|
+
{item.effect}
|
|
227
|
+
</Text>
|
|
228
|
+
</Stack>
|
|
229
|
+
</Row>
|
|
230
|
+
<Badge tone={equipped ? "up" : "idle"}>{state}</Badge>
|
|
231
|
+
</Row>
|
|
232
|
+
</RowCard>
|
|
233
|
+
);
|
|
234
|
+
})}
|
|
235
|
+
</Stack>
|
|
236
|
+
</Stack>
|
|
237
|
+
</Card>
|
|
238
|
+
|
|
239
|
+
<Card>
|
|
240
|
+
<Stack gap={4}>
|
|
241
|
+
<Heading level={2}>Leaderboard</Heading>
|
|
242
|
+
{board.length === 0 ? (
|
|
243
|
+
<Empty icon="trophy" title="No scores yet">
|
|
244
|
+
Finish a run and save it to take the top spot.
|
|
245
|
+
</Empty>
|
|
246
|
+
) : (
|
|
247
|
+
<Table
|
|
248
|
+
rows={board}
|
|
249
|
+
getKey={(s) => s._id}
|
|
250
|
+
columns={[
|
|
251
|
+
{ key: "rank", header: "#", render: (s) => <Text num>{s.rank}</Text> },
|
|
252
|
+
{ key: "player", header: "Player", render: (s) => <Player handle={s.authorHandle} /> },
|
|
253
|
+
{ key: "score", header: "Best run", align: "right", mono: true, render: (s) => <Text num>{s.score.toLocaleString()}</Text> },
|
|
254
|
+
]}
|
|
255
|
+
/>
|
|
256
|
+
)}
|
|
257
|
+
</Stack>
|
|
258
|
+
</Card>
|
|
259
|
+
</Stack>
|
|
260
|
+
</main>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Adapting
|
|
266
|
+
|
|
267
|
+
- Change the fight, not the shape: the run is one state object, every move updates it through `setRun((r) => …)` and ends in `endTurn` so the boss answers it, and the run only touches the database when it is saved. Keep every move costing something; a free move becomes the only move and every score ties at its ceiling.
|
|
268
|
+
- Keep one score document per saved run and compute the board from them (best per player, top ten); a single "high score" document that every player rewrites loses scores when two runs finish at once.
|
|
269
|
+
- Store the facts of the run that a board might sort or filter on (`score`, `result`, a `level` or a `mode`) on the score document, and show them as extra `Table` columns with `sort`.
|
|
270
|
+
- Player names are always `ViewerTag` with the stored `authorHandle`; never type a name onto a score.
|
|
271
|
+
- 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.
|
|
272
|
+
|
|
273
|
+
Adapted from 1st-Pouf (MIT) https://1st-pouf.worksonmy.dev/r/game.json
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pouf-inbox.js","sourceRoot":"","sources":["../../jsr/llms/pouf-inbox.ts"],"names":[],"mappings":"AAMA,MAAM,CAAC,MAAM,eAAe,GAAc;IACxC,IAAI,EAAE,YAAY;IAClB,KAAK,EAAE,kBAAkB;IACzB,WAAW,EAAE,6DAA6D;CAC3E,CAAC"}
|