@vibes.diy/prompts 14.3.76 → 14.3.78

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.
@@ -1,220 +0,0 @@
1
- # ImgGen Component
2
-
3
- Generate and edit images from a text prompt. Each generated image lands as a file ref on the doc — display reads the platform-minted URL via `_files`.
4
-
5
- **When to use it:** reach for `<ImgGen>` when a picture adds real value — items whose identity includes an image (cards, creatures, products, covers), user-personalized art (avatars, portraits from a photo), or apps whose whole point is imagery. Most text/data apps (lists, trackers, notes, forms) need **no** images — leave the import unused rather than decorating with gratuitous pictures.
6
-
7
- ## Basic Usage
8
-
9
- Start with a minimal image generation component:
10
-
11
- ### `App.jsx`
12
- ```jsx
13
- import React from "react";
14
- import { useFireproof } from "use-fireproof";
15
- import { ImgGen } from "use-vibes";
16
-
17
- export default function App() {
18
- return (
19
- <div>
20
- <h2>Image Generator</h2>
21
- <ImgGen prompt="A sunset over mountains" />
22
- </div>
23
- );
24
- }
25
- ```
26
-
27
- `<ImgGen>` stores generated versions in its component-owned media database, associated
28
- with the host database name and `_id`. Read a saved image through
29
- `useImgGen({ _id, database })` from `use-vibes`: its `document` composes current media
30
- versions and legacy host versions. From that composed document, select
31
- `document.versions?.[document.currentVersion ?? 0]`, then
32
- `document._files?.[version.id]`. The returned file ref supplies both `.url` for display
33
- and `.file()` for a transformation input, so a ref you have in hand is ready to pass
34
- straight to `images`. Ordinary host records hold the app's own data; the image hook
35
- supplies the associated generated output.
36
-
37
- For example, refining a saved pottery illustration keeps its original ID and uses its
38
- actual output as the input to a separate result ID:
39
-
40
- ```jsx
41
- import { ImgGen, useImgGen } from "use-vibes";
42
-
43
- function GlazingPreview({ database }) {
44
- const { document: original, pending, error } = useImgGen({ _id: "vessel-original", database });
45
- const version = original?.versions?.[original.currentVersion ?? 0];
46
- const source = version?.id ? original?._files?.[version.id] : undefined;
47
- const sourceReady = source !== undefined;
48
- return <section>
49
- <ImgGen _id="vessel-original" database={database} showControls={false} />
50
- {sourceReady ? <ImgGen _id="vessel-glazed" database={database}
51
- prompt="Add a blue glaze while preserving this vessel's shape and composition"
52
- images={[source]} /> : <p>{error ? error.message : pending ? "Loading saved source…" : "Source image unavailable"}</p>}
53
- </section>;
54
- }
55
- ```
56
-
57
- This is the same `_files`-shape contract documented in `fireproof.md`'s "Working with Files" section — read it first if you have not seen the platform's file/URL story.
58
-
59
- ## Editing an Uploaded Image
60
-
61
- Pass a `File` object via `images` to run img2img. Adding a file picker that feeds into ImgGen:
62
-
63
- ### `App.jsx`
64
- ```jsx
65
- <<<<<<< SEARCH
66
- export default function App() {
67
- return (
68
- <div>
69
- <h2>Image Generator</h2>
70
- =======
71
- export default function App() {
72
- const [file, setFile] = React.useState(null);
73
-
74
- return (
75
- <div>
76
- <h2>Image Generator</h2>
77
- <input type="file" accept="image/*" onChange={(e) => setFile(e.target.files[0])} />
78
- {file && <ImgGen prompt="Make it look like a watercolor painting" images={[file]} />}
79
- >>>>>>> REPLACE
80
- ```
81
-
82
- The input image is automatically resized (max 1024px) and compressed as JPEG before sending.
83
-
84
- `images` accepts **either** a raw `File` (fresh from an `<input type="file">`) **or** a stored Fireproof file ref — anything shaped like `{ file: () => Promise<File> }` passes straight through. So if the app has already saved the upload onto a doc, feed it back directly:
85
-
86
- ```jsx
87
- // doc was saved earlier with: database.put({ ..., _files: { photo: file } })
88
- <ImgGen prompt="Turn this pet into a cute cartoon character" images={[doc._files.photo]} _id={doc._id} database={database} />
89
- ```
90
-
91
- Do **not** invent wrappers like `images={x?.file ? something : undefined}` — pass the `_files` entry itself. If the stored ref might be absent, gate the whole `<ImgGen>` mount on it instead: `{doc._files?.photo && <ImgGen images={[doc._files.photo]} ... />}`.
92
-
93
- **Displaying an img2img result later** (gallery, detail view, reload): render by `_id` and do NOT re-pass `images` — `<ImgGen _id={doc._id} database={database} showControls={false} />` shows the stored version. ImgGen records which input produced each version, so re-passing that SAME input (the doc's own stored source, e.g. `images={[doc._files.photo]}` on every render) — or `_id` alone — just displays the saved result and will not force a fresh, billed generation. Passing a genuinely DIFFERENT input to the same `_id` DOES generate a new version — that's a requested img2img edit, not a reload: a new upload, a different stored ref, or a PRIOR OUTPUT fed back in to refine it. To force a new version from the CURRENT input, use the regenerate control (or set `generationId`). Generate once with `images`, display forever with `_id` alone.
94
-
95
- Do **not** set `model` for img2img: when an input image is present the platform automatically selects its image-**edit** default, which is tuned to produce edits faithful to the source. An explicit `model` override bypasses that routing — only use one when the app has a specific, stated reason.
96
-
97
- ## Restyling a Captured Photo
98
-
99
- A photo booth, a style camera or an avatar-from-a-selfie app keeps the camera frame on the shot's own record and gives every styled result its own `_id` (bump it to start a fresh attempt). The styled picture's frame is simply `<ImgGen>`, shown at full size: ImgGen paints the saved result when there is one, and otherwise its own progress bar, its own message when a picture needs another try, and — in the Vibes iPhone app — the **Create with Image Playground** button that a picture started by the camera (rather than a tap) waits on. The person sees each of those right in the frame. Your own words about the shot sit beside or below the frame.
100
-
101
- Mount that same `<ImgGen>` wherever an unstyled shot is shown in full — the fresh-capture card and the detail view — so a shot saved in one visit styles the next time it opens. Gallery tiles read the saved result through `useImgGen` and show the original until it arrives.
102
-
103
- ```jsx
104
- import { ImgGen, useImgGen } from "use-vibes";
105
-
106
- function StyledShot({ shot, database }) {
107
- const { document: styled } = useImgGen({ _id: shot.styledId, database });
108
- const version = styled?.versions?.[styled.currentVersion ?? 0];
109
- const ready = Boolean(version?.id && styled?._files?.[version.id]);
110
- return (
111
- <figure>
112
- <div className="aspect-square w-full overflow-hidden rounded-md">
113
- {shot._files?.original && (
114
- <ImgGen _id={shot.styledId} database={database} prompt={shot.prompt}
115
- images={[shot._files.original]} alt={`${shot.style} version`} className="h-full w-full object-contain" />
116
- )}
117
- </div>
118
- <figcaption>{ready ? shot.style : `Styling as ${shot.style}. Your original is saved.`}</figcaption>
119
- </figure>
120
- );
121
- }
122
- ```
123
-
124
- ## Loading a Specific Doc
125
-
126
- Load a previously generated image by `_id` — if the doc has a `prompt` but no `_files` yet, the component generates one: `<ImgGen _id="my-image-id" database={database} />`
127
-
128
- ## Illustrations That Are Part of an Item's Identity
129
-
130
- When an item's picture is intrinsic to what it is — a card in a deck, a creature in a bestiary, a product in a catalog, a character in a cast — render `<ImgGen>` **by default** for every such item that lacks an image, so the illustration appears as soon as the item does. Because ImgGen auto-generates when the target doc has a `prompt` but no `_files` yet, mounting it directly on the item is all it takes:
131
-
132
- ```jsx
133
- <ImgGen
134
- _id={card._id}
135
- database={database}
136
- prompt={`Illustration of ${card.title}. ${card.description ?? ""}`}
137
- />
138
- ```
139
-
140
- Let the first picture arrive with the item itself, so every card opens already illustrated and the app reads as a picture app from the first glance. For these items, buttons and `showControls` are for *regenerating* an image that already exists. Pictures that are extras rather than identity — an avatar upload, a one-off hero graphic, and the text-first snippets below — come from an explicit action the person chooses.
141
-
142
- ## Pictures on Request for Text-First Snippets
143
-
144
- When each item's substance is its words — a generated joke, a short verse, a pun, a one-liner — give every snippet its own picture button and save the picture on that snippet's record. Tapping the button shows a "Making a picture…" state until the image opens in its place. ImgGen keeps the picture under the snippet's own `_id` in `database`, so it is there after a reload, viewing it again costs nothing, and a snippet with a saved picture shows it straight away. Keep the snippet's words in a field like `text`; a `prompt` field on the record is ImgGen's cue to draw right away.
145
-
146
- ```jsx
147
- import { ImgGen, useImgGen } from "use-vibes";
148
-
149
- function PunCard({ pun, database }) {
150
- const [wanted, setWanted] = React.useState(false);
151
- const { document: saved } = useImgGen({ _id: pun._id, database });
152
- const hasPicture = Object.keys(saved?._files ?? {}).length > 0;
153
- return (
154
- <li>
155
- <p>{pun.text}</p>
156
- {wanted || hasPicture ? (
157
- <figure>
158
- <ImgGen _id={pun._id} database={database} prompt={`A cheerful cartoon of this pun: ${pun.text}`} />
159
- {hasPicture ? null : <figcaption>Making a picture…</figcaption>}
160
- </figure>
161
- ) : (
162
- <button onClick={() => setWanted(true)}>Picture this</button>
163
- )}
164
- </li>
165
- );
166
- }
167
- ```
168
-
169
- ## Gallery Pattern
170
-
171
- Keep gallery entries as app-owned records with the saved image's host `_id`. Query those
172
- records with `useLiveQuery`, then let each `<ImgGen>` read its associated media. This also
173
- keeps the app's title, caption and other editable fields separate from image versions:
174
-
175
- ```jsx
176
- function Gallery() {
177
- const { database, useLiveQuery } = useFireproof("artwork");
178
- const { docs } = useLiveQuery("type", { key: "artwork" });
179
- return <div>{docs.map(doc => <figure key={doc._id}>
180
- <ImgGen _id={doc._id} database={database} showControls={false} />
181
- <figcaption>{doc.caption}</figcaption>
182
- </figure>)}</div>;
183
- }
184
- ```
185
-
186
- ## Caching and Versions
187
-
188
- - Same prompt produces a deterministic `_id` (hash-based), so results are cached across reloads.
189
- - Each image has a regenerate button that appends a new version (writes a new `_files.v<N>` entry).
190
- - Prev / next controls navigate between stored versions.
191
- - Set `showControls={false}` to hide regenerate and version navigation.
192
-
193
- ## Choosing a Model
194
-
195
- Override the model per component: `<ImgGen prompt="An astronaut riding a horse" model="openai/gpt-5-image-mini" />`
196
-
197
- Model ids follow the `provider/model-name` form from the platform's model catalog. Unknown ids surface as an error in the component's error UI.
198
-
199
- #### Props
200
-
201
- - `prompt`: text prompt (required unless `_id` is provided)
202
- - `images`: array of `File` objects **or stored `_files` refs** for img2img (uses first image; only pass while generating — display stored results via `_id`)
203
- - `_id`: load a specific doc instead of generating
204
- - `database`: Fireproof db name or instance (default `"ImgGen"`)
205
- - `className`, `alt`, `style`: standard image styling
206
- - `showControls`: toggle regenerate + version nav (default `true`)
207
- - `model`: override the image-gen model for this component
208
-
209
- ## Generated-image privacy
210
-
211
- Generated images and prompts use a component-owned media database, and by
212
- default each image follows the record it was generated for: whoever can read
213
- that record can read its images, and the person who generated an image always
214
- can. Describe generated image history as visible to the readers of the record
215
- it belongs to, plus whoever generated each image.
216
-
217
- Publish a separate record containing only the chosen image File and the intended
218
- public facts. Public viewers render its `_files` URL with `<img>`; keep generation
219
- and revision controls on the creator's draft. Share the same `useImgGen` hook
220
- result between preview, download and publication so they select the same version.
@@ -1,59 +0,0 @@
1
- # useVibe Hook — write gating
2
-
3
- `useVibe(dbName)` is how you gate write surfaces. It asks the platform whether a candidate document would be accepted, so the UI's enabled/disabled state matches what the server will actually allow. You never re-implement the answer in the app; you ask, and you render the verdict.
4
-
5
- ```jsx
6
- const { me, can, ready } = useVibe("comments");
7
- ```
8
-
9
- Pass the Fireproof database name you are writing to. You get:
10
-
11
- - `can.create(draft)` / `can.edit(doc)` / `can.delete(doc)` → `{ ok: boolean, pending?: true, reason?: string }`. **The verdict has three states, not two** — allowed, still resolving, and denied — so branch on `pending` before `ok`.
12
- - `ready` — `false` until identity and the verdict have resolved. It is the same moment `pending` marks, so either one can hold the surface.
13
- - `me` — `{ userHandle, displayName? } | null` (null = anonymous). For display only.
14
-
15
- ## The three-way branch
16
-
17
- While `pending` is set, show a quiet checking state — never a sign-in prompt, never the raw `reason` token, and never a blank space where the write surface was. A pending verdict means "we don't know yet", not "you may not"; it resolves on its own within a moment, so anything that reads as a refusal is wrong for that moment, and hiding the surface with nothing in its place makes the app look broken.
18
-
19
- On a real denial (`!ok` and no `pending`), explain it in your app's own words — `reason` is a machine token, not display copy. Let it *inform* the sentence you write ("Sign in to post here"), and never render it directly: a token like `pending` printed into the page is internal vocabulary leaking at a user.
20
-
21
- Never stamp author fields from `me` while the verdict is pending — `me` is null until identity resolves, and a write that skips the gate lands authorless.
22
-
23
- **Build the candidate from the doc you'll actually write.** `can.create(draft)` is evaluated against `draft`, so `draft` must carry the fields the write carries — `authorHandle`, the id of the object the record belongs to, and so on. A bare `can.create({ type: "post" })` can be denied (e.g. `"not author"`) and hide the form even from users who could post. Stamp the same fields you will `put`: `can.create({ type: "card", boardId, authorHandle: me?.userHandle })`. And gate the **same database** you write to — `useVibe(dbName)` answers per `dbName`, so a gate on a different db won't reflect what the server does.
24
-
25
- ## The rule
26
-
27
- Gate every write affordance on `can.*`. Hold it while `pending`, and write your own copy when denied. Never branch write permission on `viewer` or on document fields — those drift from the platform's own answer. Rendering **other** users (authors, rosters) is `useViewer()`'s `<ViewerTag userHandle={...} />`, not `useVibe`. The current viewer's own pill and the "signed in as" label are system chrome in the Vibes Switch (the logo) — don't build them into the app. Asking an anonymous visitor to sign in is a different thing and it IS yours: `useViewer().requestLogin()`, at the moment their work becomes worth keeping (see use-viewer docs). A resolved denial is one such moment — write the sign-in copy in your app's own words and wire it to that callable, never to a sentence about the logo. (The one exception is inline avatar self-edit: a guarded no-prop `{viewer && <ViewerTag />}` lets any signed-in member change their own photo in place — see use-viewer docs.)
28
-
29
- ```jsx
30
- import { useVibe } from "use-vibes";
31
- import { Button, Input, Skeleton } from "@vibes.diy/look";
32
-
33
- function PromptBar({ database, boardId }) {
34
- const { can, me } = useVibe("aestheticBoard");
35
- // The candidate carries the same keys the write will: the board it belongs
36
- // to, and who is writing it.
37
- const v = can.create({ type: "tile", boardId, authorHandle: me?.userHandle });
38
- // 1. still resolving — a quiet placeholder, no refusal wording, no empty hole
39
- if (v.pending) return <Skeleton className="h-10 w-full" aria-busy="true" />;
40
- // 2. resolved denial — your words, informed by v.reason, never v.reason itself
41
- if (!v.ok) return <p className="text-muted-foreground">Sign in to add a tile.</p>;
42
- // 3. allowed
43
- return (
44
- <form onSubmit={/* … */}>
45
- {/* no current-user pill — identity + sign-in live in the Vibes Switch (the logo) */}
46
- <Input placeholder="Add a tile…" />
47
- <Button type="submit">Post</Button>
48
- </form>
49
- );
50
- }
51
- ```
52
-
53
- ## Per-row affordances
54
-
55
- Per-row edit/delete affordances read the same way: `{can.edit(doc).ok && <EditButton doc={doc} />}`. By default every signed-in visitor is a first-class participant who creates and edits their own data, so on a fresh app these verdicts come back `ok` — which is the point. The gate is already in the right place when the app later grows a narrower answer, and nothing about the surface has to be rewritten to pick that answer up.
56
-
57
- ## The server is still the authority
58
-
59
- `can.*` is a fast, faithful preview, not the final word. A write can still be rejected server-side (the source may be stale, async, or unevaluable — in which case `can.*` optimistically returns `ok` and defers to the server). Keep the optimistic-write + rollback pattern: apply the change immediately, revert and surface an error if the `put` rejects.
@@ -1,319 +0,0 @@
1
- # useViewer Hook
2
-
3
- `useViewer()` is a **read-only window** into viewer identity. The platform owns who is signed in, and `useViewer()` lets your app see that and render it. Your app reflects the runtime's answer in its UI; it never invents one of its own.
4
-
5
- **The current viewer's pill is system chrome — you don't build it.** The platform shows the current user inside the **Vibes Switch**, the panel that opens when you click the logo. So your app does **not** need a header pill for whoever is looking — don't render a no-prop `<ViewerTag />` just to show "who's signed in".
6
-
7
- **The sign-in _ask_ is yours.** `requestLogin()` from `useViewer()` opens the platform sign-in UI from anywhere in your app, so you can word, place and time the ask yourself. Reach for it at the moment your app becomes worth keeping. The Switch is chrome outside your app's frame, and pointing at the logo is not a call to action — it is a scavenger hunt. See **Asking a visitor to sign in** below.
8
-
9
- Use `useViewer()` to render **other** people's identity — comment authors, rosters, "added by" labels — with `<ViewerTag userHandle={...} />`, and to read `viewer`/`can` when your UI needs to branch on who's looking. **Write surfaces are gated with `useVibe(dbName).can`** (see use-vibe docs), not with `viewer`.
10
-
11
- ## Basic Usage
12
-
13
- Start with a minimal component that reads the viewer identity. You don't render a current-user pill — that lives in the Vibes Switch (click the logo). Branch on `viewer` for any welcome/empty copy. Note there is **no sign-in button here**: the ask belongs at the moment the visitor has made something worth keeping, not on arrival — see **Asking a visitor to sign in** below.
14
-
15
- ### `App.jsx`
16
- ```jsx
17
- import React from "react";
18
- import { useFireproof } from "use-fireproof";
19
- import { useViewer } from "use-vibes";
20
-
21
- export default function App() {
22
- const { viewer, isViewerPending } = useViewer();
23
-
24
- if (isViewerPending) return null;
25
-
26
- return (
27
- <div>
28
- {/* No current-user pill here — that's the Vibes Switch's job. */}
29
- {viewer ? (
30
- <p>Welcome back, {viewer.displayName ?? viewer.userHandle}!</p>
31
- ) : (
32
- <p>Everything you make here is saved on this device.</p>
33
- )}
34
- </div>
35
- );
36
- }
37
- ```
38
-
39
- ## What you get
40
-
41
- - `viewer` — `{ userHandle, displayName? }` or `null` for anonymous visitors. Avatars are not on the payload — render them with `<ViewerTag userHandle={...} />`, which resolves the avatar from the handle. Don't build avatar URLs yourself.
42
- - `isViewerPending` — `true` while the platform is still resolving the viewer identity (e.g. on first render before the parent shell has pushed the identity update). **Gate any auth-dependent UI on `!isViewerPending`** to avoid flashing the wrong state. Once it becomes `false`, `viewer` is either populated or definitively `null`.
43
- - `can(action)` — membership boolean for `"read"`/`"write"`/`"delete"`: is the viewer through the door? It answers about the viewer, not about a particular document, so prefer `useVibe(dbName).can.create/edit/delete` for write gating — that one is asked about the doc you are writing and comes back with a `reason`.
44
- - `viewerLapsed` — `true` when `viewer` is the identity this device *remembers* and the platform's answer came back anonymous: the sign-in lapsed (an expired session, a cold boot before auth restores), rather than the person signing out. Keep rendering their stuff — the local data is still theirs, and writes queue locally and sync once they sign back in — and offer a quiet "Sign back in to sync" nudge wired to `requestLogin()`. Never fall back to the anonymous copy ("Sign in to get started") while `viewerLapsed` is true; that reads as if their work is gone. `can()` and every write gate stay closed while lapsed, so gate *writes* on `can`, not on this flag, and `isViewerPending` is `false` (lapsed is a settled state, not a loading one).
45
- - `requestLogin()` — opens the platform sign-in UI. Fire-and-forget: there is nothing to await and no result to branch on, because the new identity arrives on its own through `viewer`. This is how your app asks — see **Asking a visitor to sign in** below.
46
- - `ViewerTag` — ready-made user pill; see the ViewerTag section below.
47
-
48
- ## Gating UI
49
-
50
- Gate the comment form on `useVibe("comments").can` — not on `viewer`. The current viewer never needs a pill here (that's in the Vibes Switch); just render the gated form, and on a denial a sentence in your app's own words — `reason` is a machine token that informs that sentence and is never printed:
51
-
52
- ### `App.jsx`
53
- ```jsx
54
- <<<<<<< SEARCH
55
- import { useViewer } from "use-vibes";
56
- =======
57
- import { useViewer, useVibe } from "use-vibes";
58
- >>>>>>> REPLACE
59
- ```
60
-
61
- ### `App.jsx`
62
- ```jsx
63
- <<<<<<< SEARCH
64
- const { viewer, isViewerPending } = useViewer();
65
- =======
66
- const { viewer, isViewerPending } = useViewer();
67
- const { can, ready, me } = useVibe("comments");
68
- >>>>>>> REPLACE
69
- ```
70
-
71
- ### `App.jsx`
72
- ```jsx
73
- <<<<<<< SEARCH
74
- {viewer ? (
75
- <p>Welcome back, {viewer.displayName ?? viewer.userHandle}!</p>
76
- ) : (
77
- <p>Everything you make here is saved on this device.</p>
78
- )}
79
- =======
80
- {/* write gate: useVibe().can, not viewer */}
81
- {!ready ? null : (() => {
82
- const v = can.create({ type: "comment", postId, authorHandle: me?.userHandle });
83
- return v.ok ? (
84
- <form>
85
- <input placeholder="Add a comment..." />
86
- <button type="submit">Post</button>
87
- </form>
88
- ) : (
89
- // Your own words, informed by v.reason. A signed-in viewer who is denied is
90
- // outside this thread's membership, so the sentence says that; the sign-in
91
- // ask is for the anonymous viewer.
92
- <p>{me?.userHandle ? "Comments here are for members of this thread." : "Sign in to add a comment."}</p>
93
- );
94
- })()}
95
- >>>>>>> REPLACE
96
- ```
97
-
98
- ## Tagging content with the viewer (write/render pattern)
99
-
100
- When one user writes content others will see (comments, posts, messages), **stamp `authorHandle` on the doc at write time**, and stamp the id of the thing it belongs to beside it (`postId`, `boardId`, `listId`). Render the author with `<ViewerTag userHandle={doc.authorHandle} />` which resolves display name and avatar automatically. Do not stamp `displayName` or `avatarUrl` on docs — ViewerTag handles that from the handle alone.
101
-
102
- Wire up a full comment thread with Fireproof and viewer attribution:
103
-
104
- ### `App.jsx`
105
- ```jsx
106
- <<<<<<< SEARCH
107
- import { useViewer, useVibe } from "use-vibes";
108
- =======
109
- import { useViewer, useVibe } from "use-vibes";
110
- >>>>>>> REPLACE
111
- ```
112
-
113
- ```jsx
114
- <<<<<<< SEARCH
115
- const { viewer, isViewerPending } = useViewer();
116
- const { can, ready, me } = useVibe("comments");
117
- =======
118
- const { viewer, isViewerPending, ViewerTag } = useViewer();
119
- const { can, ready, me } = useVibe("comments");
120
- const { useLiveQuery, database } = useFireproof("comments");
121
- // Every comment names the post it belongs to. A thread shows one post, so
122
- // its id is fixed here. An app where each person has a space of their own
123
- // derives the id from the viewer's handle instead (`default-${me.userHandle}`)
124
- // once `ready` says the handle is known, so the first write lands in a space
125
- // that is theirs and stays theirs.
126
- const postId = "post:welcome";
127
- const { docs: comments } = useLiveQuery("postId", { key: postId });
128
- const [body, setBody] = React.useState("");
129
- >>>>>>> REPLACE
130
- ```
131
-
132
- ```jsx
133
- <<<<<<< SEARCH
134
- {/* write gate: useVibe().can, not viewer */}
135
- {!ready ? null : (() => {
136
- const v = can.create({ type: "comment", authorHandle: me?.userHandle });
137
- return v.ok ? (
138
- <form>
139
- <input placeholder="Add a comment..." />
140
- <button type="submit">Post</button>
141
- </form>
142
- ) : (
143
- // Your own words, informed by v.reason. A signed-in viewer who is denied is
144
- // outside this thread's membership, so the sentence says that; the sign-in
145
- // ask is for the anonymous viewer.
146
- <p>{me?.userHandle ? "Comments here are for members of this thread." : "Sign in to add a comment."}</p>
147
- );
148
- })()}
149
- =======
150
- {/* render OTHER users with userHandle — that's what ViewerTag is for here */}
151
- <ul>
152
- {comments.map((c) => (
153
- <li key={c._id}>
154
- <ViewerTag userHandle={c.authorHandle} />
155
- <p>{c.body}</p>
156
- </li>
157
- ))}
158
- </ul>
159
-
160
- {/* write gate: useVibe().can, not viewer */}
161
- {!ready ? null : (() => {
162
- const v = can.create({ type: "comment", postId, authorHandle: me?.userHandle });
163
- return v.ok ? (
164
- <form onSubmit={(e) => { e.preventDefault(); post(); }}>
165
- <input value={body} onChange={(e) => setBody(e.target.value)} placeholder="Add a comment..." />
166
- <button type="submit">Post</button>
167
- </form>
168
- ) : (
169
- // Your own words, informed by v.reason. A signed-in viewer who is denied is
170
- // outside this thread's membership, so the sentence says that; the sign-in
171
- // ask is for the anonymous viewer.
172
- <p>{me?.userHandle ? "Comments here are for members of this thread." : "Sign in to add a comment."}</p>
173
- );
174
- })()}
175
- >>>>>>> REPLACE
176
- ```
177
-
178
- Also add the `post` handler before `if (isViewerPending)`:
179
-
180
- ```jsx
181
- <<<<<<< SEARCH
182
- if (isViewerPending) return null;
183
- =======
184
- async function post() {
185
- if (!body.trim()) return;
186
- await database.put({
187
- type: "comment",
188
- postId, // the id of the thing this record belongs to, on the record itself
189
- body: body.trim(),
190
- createdAt: Date.now(),
191
- authorHandle: me?.userHandle,
192
- });
193
- setBody("");
194
- }
195
-
196
- if (isViewerPending) return null;
197
- >>>>>>> REPLACE
198
- ```
199
-
200
- Key points:
201
-
202
- - **Stamp `authorHandle` at write time** — persist the author's handle on the doc. Render with `<ViewerTag userHandle={authorHandle} />` which resolves display name and avatar automatically.
203
- - **Name the thing it belongs to** — every record carries the id of its parent object (`postId`, `boardId`, `listId`), written at create and left alone afterwards. Queries read off it, and so does everything the app grows later.
204
- - **Give each person a default object of their own** — `` `default-${me.userHandle}` `` — so someone who has just arrived types once and it lands, with no object for them to create first. An object someone does make carries `creatorHandle: me.userHandle`.
205
- - **Avatars are stable** — ViewerTag resolves the avatar from the handle; if the author changes their avatar, the URL stays the same and the bytes update. ViewerTag handles this for you.
206
- - **One source of identity** — persist `authorHandle` on the doc. ViewerTag does the rest.
207
-
208
- ## Asking a visitor to sign in
209
-
210
- An anonymous visitor is having a legitimately good time. Under local-first their work is already saved on their device and your app should keep working for them — the account buys **sync, followers and durability**, not permission. So the ask is an **offer to keep what they already have — never a wall in front of it**. A visitor who never signs in should still get a working app; one who does should feel like they kept something.
211
-
212
- Three rules separate a CTA that converts from the two shapes that don't.
213
-
214
- **Ask at the moment of value.** The ask lands when the person has just made something they'd hate to lose — posted their first comment, starred their first pick, saved their first note. A prompt on arrival asks a stranger to commit before they know what the app does; a prompt after the first save offers to keep something they can already see.
215
-
216
- **Wire the ask to `requestLogin()`, never to a sentence about the logo.** Copy like "sign in via the logo" points at platform chrome outside your app's frame and sends the reader hunting for it. It is not a call to action and it does not convert. Render a real control: `<button onClick={requestLogin}>Keep these</button>`.
217
-
218
- **Only ask once identity has settled.** `isViewerPending` is `true` while the platform is still resolving who's looking, and a CTA rendered in that window shows signed-in people a SIGN IN button on every load. Compute the settled-signed-out state once and gate on it — `const settledSignedOut = !isViewerPending && !viewer;`.
219
-
220
- Layer it onto the comment app: the visitor can already read and post, and once they've posted, we offer to keep it.
221
-
222
- ```jsx
223
- <<<<<<< SEARCH
224
- const { viewer, isViewerPending, ViewerTag } = useViewer();
225
- =======
226
- const { viewer, isViewerPending, ViewerTag, requestLogin } = useViewer();
227
- >>>>>>> REPLACE
228
- ```
229
-
230
- ```jsx
231
- <<<<<<< SEARCH
232
- const [body, setBody] = React.useState("");
233
- =======
234
- const [body, setBody] = React.useState("");
235
-
236
- // Settled AND anonymous — never merely anonymous, or signed-in viewers flash
237
- // this on every load while their identity is still resolving (#3639).
238
- const settledSignedOut = !isViewerPending && !viewer;
239
- // The moment of value: a comment THIS visitor wrote. An anonymous write
240
- // stamps `authorHandle: me?.userHandle` with `me` still null, so an undefined
241
- // author is our own unclaimed work — which is exactly what signing in keeps.
242
- // Reading `comments.length` instead would nag people who only ever read.
243
- const hasSomethingToKeep = comments.some((c) => c.authorHandle === undefined);
244
- >>>>>>> REPLACE
245
- ```
246
-
247
- ```jsx
248
- <<<<<<< SEARCH
249
- {/* write gate: useVibe().can, not viewer */}
250
- =======
251
- {settledSignedOut && hasSomethingToKeep && (
252
- <div>
253
- {/* Says what they GET. Never "you must sign in to continue" — they
254
- can continue, and their comment above is already saved locally.
255
- And never "post it under your name": signing in syncs the comment
256
- they already wrote, it does not rewrite the author stamped on it.
257
- Promise continuity, which is true — not attribution, which isn't. */}
258
- <p>Your comment is saved on this device.</p>
259
- <button onClick={requestLogin}>Sign in to keep it on all your devices</button>
260
- </div>
261
- )}
262
-
263
- {/* write gate: useVibe().can, not viewer */}
264
- >>>>>>> REPLACE
265
- ```
266
-
267
- Copy that works, and copy that doesn't:
268
-
269
- | Write this | Not this |
270
- | --- | --- |
271
- | "Sign in to sync your picks everywhere" | "Sign in to continue" |
272
- | "Keep these on all your devices" | "You must sign in to save" |
273
- | "Saved on this device — sign in to sync" | "Sign in via the Vibes DIY logo" |
274
-
275
- And never disable the app's real controls on `!viewer`. If favoriting works signed out, keep it working signed out — the platform refuses the *cloud* write on its own, calmly, while the local write still succeeds. Turning a working app into a signup wall is worse than never asking at all.
276
-
277
- Keep the guest's composed text across sign-in. Draft input lives in component state (or a local draft the app already saves) — and signing in never clears it. The new identity arrives on its own through `viewer`; the words they typed stay where they left them, and they continue from there. A form that resets on identity change makes the guest write everything twice.
278
-
279
- ## Notes
280
-
281
- - Never use Clerk user IDs. Only `userHandle` crosses into vibe code.
282
- - Avatar URLs are stable indirection URLs — when a user changes their avatar, the URL stays the same and the bytes update. Treat them as opaque strings.
283
- - To gate a write surface, use `useVibe(dbName).can` — asked about the document you are about to write. The server is the authority either way; `can` is the app's faithful preview of its answer.
284
-
285
- ## ViewerTag
286
-
287
- `ViewerTag` is a ready-made inline user pill. Import it directly with `import { ViewerTag } from "use-vibes";`, or get it alongside `viewer` from `useViewer()`. Both forms use the current viewer context and the same identity, pending, and avatar-edit behavior. **Use it to render _other_ people** — comment authors, roster rows, "added by" labels — by passing `userHandle`:
288
-
289
- ```jsx
290
- {/* Show another user read-only: */}
291
- <ViewerTag userHandle={comment.authorHandle} />
292
- {/* Placement override — className carries placement only: */}
293
- <ViewerTag userHandle={member.userHandle} className="mt-1" />
294
- ```
295
-
296
- **Tapping a tag opens that handle's profile.** Pass `onClick` and bind the handle the tag displays — the app owns the navigation and chooses the profile view. Opening a profile follows nobody: follow stays behind its own button. The tap carries the handle the tag shows, so each row opens its own person.
297
-
298
- ```jsx
299
- const { ViewerTag } = useViewer();
300
- const [profileHandle, setProfileHandle] = React.useState(null);
301
-
302
- {/* Pass the handle you render: a tap opens that handle's profile. */}
303
- <ViewerTag userHandle={member.userHandle} onClick={() => setProfileHandle(member.userHandle)} />
304
- {profileHandle && <ProfileView handle={profileHandle} />}
305
- ```
306
-
307
- **Hero avatars render natively.** `size` draws the avatar circle at that diameter and `avatarOnly` drops the handle text — and the pill chrome with it, so the header is the circle itself. `<ViewerTag userHandle={profileHandle} size={120} avatarOnly />` is a profile header, with or without a profile action. Give the tag its profile action and the clickable shape needs no wrapper: the tag is already one native button, and a plain tag without `onClick` stays a quiet span. A no-prop `<ViewerTag />` is the viewer's own photo action at every size — its click opens the photo picker, so `onClick` belongs on explicit references.
308
-
309
- **The current viewer's pill is system chrome, not app UI.** The platform already shows the current user inside the Vibes Switch that opens from the logo, so don't add a no-prop `<ViewerTag />` as a header pill just to show who's signed in; reach for `userHandle` to render someone else instead. To *ask* an anonymous visitor to sign in, use `requestLogin()` and your own copy (see **Asking a visitor to sign in**) — that ask belongs at the moment of value, which is a place only your app knows.
310
-
311
- **The one in-app reason to render a no-prop `<ViewerTag />` is inline avatar self-edit.** A no-prop tag shows the signed-in viewer a dashed edit ring that lets them change their _own_ avatar in place, and that works for **every** signed-in viewer — the Switch's avatar editing is owner-only. So if your app wants any member (not just the owner) to update their photo without leaving the app, render a guarded `{viewer && <ViewerTag />}` for that — otherwise leave the current-viewer pill to the Switch.
312
-
313
- **Undefined safety.** If `userHandle` is present in props but falsy (e.g. a missing field from a loop lookup), `ViewerTag` renders a dim italic placeholder instead of the edit ring. This prevents a broken data source from accidentally turning an arbitrary pill into a photo-edit control.
314
-
315
- **Anonymous & always-safe.** `ViewerTag` never throws regardless of login state. A no-prop `<ViewerTag />` renders a "Sign in" button for anonymous viewers — a fine fallback, but it is an identity chip in whatever spot you put it, not a CTA you can word or time. When the goal is conversion, write the prompt yourself and wire it to `requestLogin()`.
316
-
317
- **Theming.** `ViewerTag` reads the canonical palette variables — `--surface`, `--text-primary`, `--border`, and `--accent` (plus optional `--accent-text` to override the on-accent text color) — from the app's CSS with sensible fallbacks. If your app defines these on `:root` (which every generated theme does), `ViewerTag` inherits the theme automatically with no extra props. The one exception is the signed-out **Sign in** CTA, which deliberately stays the Vibes brand blue: it is a platform affordance matched to the Vibes Switch shown to the same visitor, so it is not theme-inherited.
318
-
319
- Pass `<ViewerTag userHandle={...} />` to render other people. Reach for the no-prop `<ViewerTag />` when you specifically want inline avatar self-edit for any signed-in member; for the current-user pill the Switch already covers it, and for a sign-in ask use `requestLogin()`.