ugly-app 0.1.951 → 0.1.953

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 (90) hide show
  1. package/README.md +211 -210
  2. package/dist/character/client/CharacterCanvas.d.ts.map +1 -1
  3. package/dist/character/client/CharacterCanvas.js +38 -3
  4. package/dist/character/client/CharacterCanvas.js.map +1 -1
  5. package/dist/character/client/CharacterCreator.d.ts +6 -10
  6. package/dist/character/client/CharacterCreator.d.ts.map +1 -1
  7. package/dist/character/client/CharacterCreator.js +60 -28
  8. package/dist/character/client/CharacterCreator.js.map +1 -1
  9. package/dist/character/client/index.d.ts +3 -1
  10. package/dist/character/client/index.d.ts.map +1 -1
  11. package/dist/character/client/index.js +1 -0
  12. package/dist/character/client/index.js.map +1 -1
  13. package/dist/character/client/lib/characterScene.d.ts +16 -1
  14. package/dist/character/client/lib/characterScene.d.ts.map +1 -1
  15. package/dist/character/client/lib/characterScene.js +52 -5
  16. package/dist/character/client/lib/characterScene.js.map +1 -1
  17. package/dist/character/client/lib/voiceCatalog.d.ts +54 -0
  18. package/dist/character/client/lib/voiceCatalog.d.ts.map +1 -0
  19. package/dist/character/client/lib/voiceCatalog.js +130 -0
  20. package/dist/character/client/lib/voiceCatalog.js.map +1 -0
  21. package/dist/character/shared/index.d.ts +1 -1
  22. package/dist/character/shared/index.d.ts.map +1 -1
  23. package/dist/character/shared/index.js +1 -1
  24. package/dist/character/shared/index.js.map +1 -1
  25. package/dist/character/shared/spec.d.ts.map +1 -1
  26. package/dist/character/shared/spec.js +3 -2
  27. package/dist/character/shared/spec.js.map +1 -1
  28. package/dist/character/shared/specAdapter.d.ts +23 -0
  29. package/dist/character/shared/specAdapter.d.ts.map +1 -1
  30. package/dist/character/shared/specAdapter.js +85 -2
  31. package/dist/character/shared/specAdapter.js.map +1 -1
  32. package/dist/cli/auth/cloudflareOauth.js +1 -1
  33. package/dist/cli/auth/cloudflareOauth.js.map +1 -1
  34. package/dist/cli/build.d.ts.map +1 -1
  35. package/dist/cli/build.js +11 -1
  36. package/dist/cli/build.js.map +1 -1
  37. package/dist/cli/version.d.ts +1 -1
  38. package/dist/cli/version.js +1 -1
  39. package/dist/client/audio/AudioPlayer.d.ts +0 -1
  40. package/dist/client/audio/AudioPlayer.d.ts.map +1 -1
  41. package/dist/client/audio/AudioPlayer.js +5 -13
  42. package/dist/client/audio/AudioPlayer.js.map +1 -1
  43. package/dist/client/audio/AudioRecorder.d.ts +0 -1
  44. package/dist/client/audio/AudioRecorder.d.ts.map +1 -1
  45. package/dist/client/audio/AudioRecorder.js +2 -8
  46. package/dist/client/audio/AudioRecorder.js.map +1 -1
  47. package/dist/client/audio/audioPlayer.worklet.js +3 -30
  48. package/dist/client/audio/audioPlayer.worklet.js.map +1 -1
  49. package/dist/client/audio/audioRecorder.worklet.js +3 -7
  50. package/dist/client/audio/audioRecorder.worklet.js.map +1 -1
  51. package/dist/client/callAI.d.ts +8 -0
  52. package/dist/client/callAI.d.ts.map +1 -1
  53. package/dist/client/callAI.js +8 -1
  54. package/dist/client/callAI.js.map +1 -1
  55. package/dist/server/App.d.ts.map +1 -1
  56. package/dist/server/App.js +1 -7
  57. package/dist/server/App.js.map +1 -1
  58. package/dist/shared/FrameworkRequests.d.ts +2 -2
  59. package/dist/vite/index.d.ts +0 -1
  60. package/dist/vite/index.d.ts.map +1 -1
  61. package/dist/vite/index.js +0 -1
  62. package/dist/vite/index.js.map +1 -1
  63. package/package.json +1 -1
  64. package/src/character/client/CharacterCanvas.tsx +39 -3
  65. package/src/character/client/CharacterCreator.tsx +154 -110
  66. package/src/character/client/index.ts +11 -1
  67. package/src/character/client/lib/characterScene.ts +54 -5
  68. package/src/character/client/lib/voiceCatalog.ts +171 -0
  69. package/src/character/shared/index.ts +1 -0
  70. package/src/character/shared/spec.ts +3 -2
  71. package/src/character/shared/specAdapter.ts +88 -2
  72. package/src/cli/auth/cloudflareOauth.ts +1 -1
  73. package/src/cli/build.ts +11 -1
  74. package/src/cli/version.ts +1 -1
  75. package/src/client/audio/AudioPlayer.ts +5 -25
  76. package/src/client/audio/AudioRecorder.ts +2 -20
  77. package/src/client/audio/audioPlayer.worklet.ts +6 -33
  78. package/src/client/audio/audioRecorder.worklet.ts +7 -11
  79. package/src/client/callAI.ts +11 -1
  80. package/src/server/App.ts +1 -7
  81. package/src/vite/index.ts +0 -6
  82. package/templates/vite.config.ts +2 -11
  83. package/dist/client/audio/workletLoader.d.ts +0 -19
  84. package/dist/client/audio/workletLoader.d.ts.map +0 -1
  85. package/dist/client/audio/workletLoader.js +0 -57
  86. package/dist/client/audio/workletLoader.js.map +0 -1
  87. package/src/client/audio/workletLoader.test.ts +0 -122
  88. package/src/client/audio/workletLoader.ts +0 -66
  89. package/src/client/audio/workletSync.test.ts +0 -43
  90. package/src/vite/audioWorkletsPlugin.test.ts +0 -45
package/README.md CHANGED
@@ -1,15 +1,15 @@
1
1
  # ugly-app
2
2
 
3
- A full-stack TypeScript framework for shipping production web apps. Scaffold
4
- with `npx ugly-app init my-app` and get an opinionated Node + React + Postgres
5
- stack with type-safe RPC over WebSocket and HTTP, real-time doc subscriptions,
6
- built-in auth, AI generation, storage, workers, cron, and a CLI for every
7
- workflow.
3
+ A full-stack TypeScript framework for shipping production web apps.
4
+ Scaffold with `npx ugly-app init my-app` and get an opinionated Node +
5
+ React + Postgres stack with type-safe RPC over WebSocket and HTTP,
6
+ real-time doc subscriptions, built-in auth, AI generation, storage,
7
+ workers, cron, and a CLI for every workflow.
8
8
 
9
9
  ugly-app is designed to run against [ugly.bot](https://ugly.bot), which
10
- provides auth, infra (Postgres, Qdrant, NATS, S3-compatible object storage),
11
- AI provider keys, push, email, and deployment. Your app talks to all of it
12
- through the project's dev tunnel and its `UGLY_BOT_TOKEN`.
10
+ provides auth, infra (Postgres, Qdrant, NATS, S3-compatible object
11
+ storage), AI provider keys, push, email, and deployment. Your app talks
12
+ to all of it through the project's dev tunnel and its `UGLY_BOT_TOKEN`.
13
13
 
14
14
  ```bash
15
15
  npx ugly-app init my-app
@@ -27,7 +27,6 @@ npm run dev
27
27
  | `ugly-app/server/adapter/workers` | Cloudflare Workers entry (Hono router + Durable Objects + Neon HTTP driver + R2). |
28
28
  | `ugly-app/server/ssr` | SSR renderer used by the Workers adapter. |
29
29
  | `ugly-app/conversation/{shared,server,client,engine}` | AI chat sessions with persisted history. |
30
- | `ugly-app/character/{shared,server,client}` | `CharacterSpec` store + WebGPU `CharacterCanvas` / `CharacterCreator`. |
31
30
  | `ugly-app/collab/{server,client}` | Yjs-based collaborative editing. |
32
31
  | `ugly-app/markdown/{shared,client}` | Markdown rendering + editor. |
33
32
  | `ugly-app/search/{shared,server}` | Search primitives. |
@@ -45,8 +44,8 @@ npm run dev
45
44
 
46
45
  ## Server — `createApp()`
47
46
 
48
- Single server entry point. Returns an `App` that owns Express, the WebSocket
49
- server, the typed DB, and the RPC dispatcher.
47
+ Single server entry point. Returns an `App` that owns Express, the
48
+ WebSocket server, the typed DB, and the RPC dispatcher.
50
49
 
51
50
  ```ts
52
51
  // server/index.ts
@@ -104,31 +103,29 @@ function createApp<
104
103
  ): App<CollectionMap<typeof BUILTIN_DEFS & Defs>, RegistryPages<R>>;
105
104
  ```
106
105
 
107
- Passing `pages` in the registry (`{ requests, messages, pages }`) upgrades
108
- `app.pushSend()` to a per-route typed API. `pages` is optional; apps that only
109
- call `configurator.setPages()` still work — they just get the loose
110
- `PageRegistry` typing on `pushSend`.
106
+ Passing `pages` in the registry (`{ requests, messages, pages }`)
107
+ upgrades `app.pushSend()` to a per-route typed API. `pages` is optional;
108
+ apps that only call `configurator.setPages()` still work — they just get
109
+ the loose `PageRegistry` typing on `pushSend`.
111
110
 
112
111
  ### The returned `App`
113
112
 
114
- `App` extends `AppRouter`, so `dispatch(name, input, userId)` is available for
115
- in-process invocation of any registered handler.
116
-
117
- | Member | Description |
118
- |--------|-------------|
113
+ | Field | Description |
114
+ |-------|-------------|
119
115
  | `start(port?)` | Start the server (default 3000; templates use 4321). |
120
- | `db` | `TypedDB<Map>` — map inferred from your collections + framework built-ins. |
116
+ | `db` | The `TypedDB<Map>` (map inferred from your collections + framework built-ins). |
121
117
  | `httpServer` | The underlying Node `http.Server`. |
122
118
  | `wss` | The main `WebSocketServer` (path set via `setWsPath`, default `/rpc`). |
123
- | `dispatch(name, input, userId)` | `Promise<unknown>` — invoke any registered RPC handler programmatically. |
119
+ | `dispatch(name, input, userId)` | Invoke a registered RPC handler programmatically (inherited from `AppRouter`). |
124
120
  | `registerRoutes(fn)` | Mount additional Express routes after creation. |
125
- | `pushSend(input)` | Typed push whose click-through target is a route from this app's `pages`. Mints a `https://ugly.bot/l/<code>` short link so the URL is always absolute and dock-app-routable. |
121
+ | `pushSend(input)` | Typed push whose click-through target is a route from this app's `pages`. The framework mints a `https://ugly.bot/l/<code>` short link so the click-through is always absolute and dock-app-routable. |
126
122
 
127
- Framework services start inside `app.start()`: schema drift check, NATS
128
- connection + KV buckets, data-proxy connection, event-counter flush, TTL
129
- cleanup for log tables, console/error capture, and ugly.bot log forwarding.
130
- Postgres, NATS, storage, and AI clients load **lazily** — a host without
131
- `DATABASE_URL` / `NATS_URL` / `R2_BUCKET` doesn't pay their startup cost.
123
+ Framework services start automatically inside `app.start()`: schema
124
+ drift check, NATS connection + KV buckets, data-proxy connection,
125
+ event-counter flush, TTL cleanup for log tables, console/error capture,
126
+ and ugly.bot log forwarding. Postgres, NATS, storage, and AI clients
127
+ are loaded **lazily** — a host without `DATABASE_URL` / `NATS_URL` /
128
+ `R2_BUCKET` doesn't pay their startup cost.
132
129
 
133
130
  ### `AppConfigurator`
134
131
 
@@ -159,8 +156,8 @@ Every method is optional; `setPages` is what mounts the SPA.
159
156
 
160
157
  ### Handler signatures
161
158
 
162
- Handlers are plain async functions — no context object. Access state via
163
- captured imports (`app.db`, `storage`, `pgQuery`, `uglyBotRequest`, …).
159
+ Handlers are plain async functions — no context object. Access state
160
+ via captured imports (`app.db`, `storage`, `pgQuery`, `uglyBotRequest`, …).
164
161
 
165
162
  ```ts
166
163
  // req() — public, userId may be null
@@ -172,37 +169,30 @@ getMe: async (userId: string, input) => { /* … */ }
172
169
 
173
170
  ### Built-in framework requests
174
171
 
175
- `createApp` registers several framework handlers reachable from any client via
176
- the normal RPC pipeline. App-provided handlers with the same name override the
177
- framework's defaults. Rate-limited handlers are marked below.
172
+ `createApp` registers several framework handlers reachable from any
173
+ client via the normal RPC pipeline. App-provided handlers with the same
174
+ name override the framework's defaults.
178
175
 
179
- | Name | Notes |
180
- |------|-------|
176
+ | Name | Purpose |
177
+ |------|---------|
181
178
  | `userGet` | Returns `{ userId, name, avatarUri }` for the given user (or caller). |
182
179
  | `initSession` / `captureEvent` | Session + event logging tagged with experiment branches (public — no auth). |
183
- | `textGen` | AI text proxy — server-validated, billed through ugly.bot. Rate-limited 20/60s. |
184
- | `imageGen` | AI image proxy. Rate-limited 10/60s. |
185
- | `kagiSearch` / `kagiEnrichWeb` / `kagiEnrichNews` | Web search via ugly.bot. Rate-limited 20/60s. |
186
- | `kagiSummarize` | URL / text summary. Rate-limited 10/60s. |
180
+ | `textGen` / `imageGen` | AI proxies — server-validated, billed through ugly.bot. |
181
+ | `kagiSearch` / `kagiSummarize` / `kagiEnrichWeb` / `kagiEnrichNews` | Web search via ugly.bot. |
187
182
  | `uploadUrl` | Issues a presigned PUT for the `temp` bucket. |
188
- | `shareLink` | Mint a `https://ugly.bot/l/<code>` short link with OG metadata. Rate-limited 60/60s. |
189
- | `feedbackReportCreateNoAuth` | Public, same-origin feedback endpoint used by browser telemetry. Rate-limited 10/60s. |
190
- | `errorLogCaptureNoAuth` | Public error-log capture. |
191
- | `perfSnapshotCaptureNoAuth` | Public perf snapshot capture. Rate-limited 60/60s. |
192
- | `submitFeedbackBot` | Bot-persona feedback submission. |
193
- | `feedbackReportResolve` | Admin resolve/decline. Rate-limited 120/60s. |
183
+ | `shareLink` | Mint a `https://ugly.bot/l/<code>` short link with OG metadata. |
184
+ | `feedbackReportCreateNoAuth` / `errorLogCaptureNoAuth` / `perfSnapshotCaptureNoAuth` | Same-origin, public endpoints used by browser telemetry. |
185
+ | `submitFeedbackBot` / `feedbackReportResolve` | Bot-persona feedback submission; admin resolve/decline. |
194
186
  | `adminGetPerfLogs` | Admin-only perf telemetry read. |
195
187
  | `adminCreateTestUser` / `adminListTestUsers` / `adminDeleteTestUser` | Admin-only synthetic-user management. Gated by `setIsAdmin()`. |
196
188
  | `projectPlanList` / `projectPlanCreate` / `projectPlanUpdate` / `projectPlanDelete` | Project plan CRUD used by Studio. |
197
189
 
198
- Add per-endpoint rate limits on your own handlers with `rateLimit: { max, window }` on the request definition (see below).
199
-
200
190
  ---
201
191
 
202
192
  ## Shared API definitions
203
193
 
204
- `shared/` is consumed by both server and client. Keep all Zod schemas, types,
205
- collections, and route declarations here.
194
+ `shared/` is consumed by both server and client. Keep all Zod schemas,
195
+ types, collections, and route declarations here.
206
196
 
207
197
  ### Requests (`shared/api.ts`)
208
198
 
@@ -232,8 +222,8 @@ export const requests = defineRequests({
232
222
  ```
233
223
 
234
224
  Every request is reachable as **both** `socket.request(name, input)`
235
- (WebSocket) and `POST /api/:name { input }` (HTTP). `z` is re-exported from
236
- Zod for convenience.
225
+ (WebSocket) and `POST /api/:name { input }` (HTTP). `z` is re-exported
226
+ from Zod for convenience.
237
227
 
238
228
  ### Collections (`shared/collections.ts`)
239
229
 
@@ -269,19 +259,20 @@ export const collections = defineCollections({
269
259
  - `public` — allow unauthenticated client reads.
270
260
  - `cascadeFrom` — parent collection: when the parent doc is deleted, docs in this collection are cascade-deleted.
271
261
  - `trackKeys?` — fields usable as NATS routing keys for scoped `trackDocs` subscriptions.
272
- - `getter?(ids)` — batch resolver for collections with no local table (e.g. `userPublic` resolves via ugly.bot).
262
+ - `getter?(ids)` — batch resolver for collections with no local table (framework pattern; e.g. `userPublic` resolves via ugly.bot).
273
263
  - `skipReadValidation?` — opt out of per-row zod validation on reads for very hot collections. Writes are always validated. Global kill-switch: `UGLY_DB_VALIDATE_READS=0`.
274
264
  - `search?: { fields, language? }` — full-text index over the named JSONB paths (Neon: Postgres FTS; D1: SQLite FTS5, ranked by bm25).
275
265
  - `vector?: { dimensions, metric?, filterable? }` — ANN index. Vector supplied out-of-band at write time (`setDoc(c, doc, { vec })`) — never stored in the doc JSON. Query with `getDocs(c, filter, { near })`.
276
266
 
277
267
  All documents extend `DBObject`: `{ _id, version, created, updated }`.
278
- Use `dbDefaults()` to stamp `version` / `created` / `updated` on inserts.
279
- **Always generate `_id` with `nanoid()`** — never `crypto.randomUUID()`,
280
- `Date.now()`, or `Math.random()` (see `CLAUDE.md`).
268
+ Use `dbDefaults()` to stamp `version` / `created` / `updated` on
269
+ inserts. **Always generate `_id` with `nanoid()`** — never
270
+ `crypto.randomUUID()`, `Date.now()`, or `Math.random()` (see
271
+ `CLAUDE.md`).
281
272
 
282
- After schema changes, run `npm run db:schema-gen` then `npm run db:migrate`.
283
- The app refuses to start when drift is detected (set `SCHEMA_CHECK_SKIP=true`
284
- only as a last resort).
273
+ After schema changes, run `npm run db:schema-gen` then `npm run
274
+ db:migrate`. The app refuses to start when drift is detected (set
275
+ `SCHEMA_CHECK_SKIP=true` only as a last resort).
285
276
 
286
277
  ---
287
278
 
@@ -304,20 +295,23 @@ export const pages = definePages({
304
295
  export type AppPages = typeof pages;
305
296
  ```
306
297
 
307
- `definePage<Params>(options?)` returns a `PageDef<Params>` — a runtime object
308
- carrying `PageMeta` plus a phantom `_params` used only for TypeScript
309
- inference. `PageMeta` fields:
298
+ `definePage<Params>(options?)` returns a `PageDef<Params>` — a runtime
299
+ object carrying `PageMeta` plus a phantom `_params` used only for
300
+ TypeScript inference.
301
+
302
+ **Options** (all optional):
310
303
 
311
- - `auth` (default `true`) — protected route. When `isAuthenticated()` returns false on this route, the router synchronously renders the framework's `<AuthRoot>` in place of the page (Mode A → `<LoginPopup>`, Mode B → `<MagicLinkForm>` plus optional Google button). Apps cannot override this fallback.
312
- - `ssr` (default `false`) — server-render the page for SEO. `{ auth: true, ssr: true }` is silently downgraded to `ssr: false` with a warning: the SSR document is served from a shared edge cache and must not depend on the viewer.
304
+ - `auth` (default `true`) — protected route. When `isAuthenticated()` returns false on this route, the router synchronously renders the framework's `<AuthRoot>` in place of the page (dispatches on `window.__UGLY_APP_AUTH_MODE__`: Mode A → `<LoginPopup>`, Mode B → `<MagicLinkForm>` plus optional Google button). Apps cannot override this fallback — the framework owns the login UX so no route can accidentally ship as auth-required with no way to log in.
305
+ - `ssr` (default `false`) — server-render the page for SEO. `{ auth: true, ssr: true }` is silently dropped to `ssr: false` with a warning: the SSR document is served from a shared edge cache and must not depend on the viewer.
313
306
  - `cacheQuery?: string[]` — query params that participate in the SSR edge-cache key. Anything NOT listed bypasses the cache instead of poisoning it (e.g. `cacheQuery: ['q']` on a search page).
314
- - `ssrCacheTimeout?: number` — edge cache lifetime in seconds. Default is `DEFAULT_SSR_CACHE_TIMEOUT` (1 year) because the `buildId` is already part of the cache key. Override only for pages that drift between deploys.
307
+ - `ssrCacheTimeout?: number` — edge cache lifetime in seconds. Default is effectively 1 year (`DEFAULT_SSR_CACHE_TIMEOUT`) because the `buildId` is part of the cache key. Override only for pages that drift between deploys.
315
308
 
316
309
  Path syntax: `:param` matches a single path segment; `*param` is greedy
317
- (captures slashes). Query-string params are declared in `Params` but never
318
- appear in the path template.
310
+ (captures slashes). Query-string params are declared in `Params` but
311
+ never appear in the path template.
319
312
 
320
- `definePages<T>(p)` is a pass-through identity — use it to name the registry.
313
+ `definePages<T>(p)` is a pass-through identity — use it to give the
314
+ registry a name.
321
315
 
322
316
  ### `createRouter()` (`ugly-app/client`)
323
317
 
@@ -338,23 +332,23 @@ export const {
338
332
  **Config:**
339
333
  - `pages` (required) — the `PageRegistry` from `shared/pages.ts`.
340
334
  - `allPages?` — the lazy route → element map (`PageMap<Pages>`). Prefer registering via `setAllPages()` from the browser entry when the app also server-renders — the Worker bundle has no code splitting, so a static import inlines every page (and its deps) into `worker.js`.
341
- - `ssrPages?: SsrPageMap<Pages>` — `Partial<{ [K]: ComponentType<Params> }>` of statically-imported components for `ssr: true` routes. Used by both the Worker's `renderToString` and the client's first hydration render so the initial paint resolves synchronously and never mismatches the server output.
335
+ - `ssrPages?` — `Partial<{ [K]: ComponentType<Params> }>` of statically-imported components for `ssr: true` routes. Used by both the Worker's `renderToString` and the client's first hydration render so the initial paint resolves synchronously and never mismatches the server output.
342
336
 
343
337
  **Returns:**
344
338
 
345
- - **`RouterProvider`** — props: `children`, `fallback?`, `isAuthenticated?: () => boolean`, `initialUrl?: { pathname, search }` (required for SSR + hydration). Manages route state, browser history, and the popup layer. Runs the per-route auth check synchronously — apps cannot override the login fallback.
346
- - **`RouterView`** — renders the active page with animated transitions. Props: `durationMs?`, `easing?: EasingFunction`, `transitionComponent?: React.ComponentType<RouterTransitionProps>` (replaces the default `ViewFlipper`), `renderPage?(state: RouterStateRaw) => ReactElement` (sync alternative to `allPages` loaders).
339
+ - **`RouterProvider`** — props: `children`, `fallback?`, `isAuthenticated?`, `initialUrl?` (`{ pathname, search }`, required for SSR + hydration). Manages route state, browser history, and the popup layer. Runs the per-route auth check synchronously — apps cannot override the login fallback.
340
+ - **`RouterView`** — renders the active page with animated transitions. Props: `durationMs?`, `easing?`, `transitionComponent?` (a `React.ComponentType<RouterTransitionProps>` that replaces the default `ViewFlipper`), `renderPage?(state) => ReactElement` (sync alternative to `allPages` loaders, called with `RouterStateRaw`).
347
341
  - **`useRouter()`** — returns `RouterContextValue<Pages>` (typed navigation + popup API). Throws if used outside `<RouterProvider>`.
348
342
  - **`Link`** — typed SPA link bound to this router. Renders a real `<a>` (right-click / cmd-click / SEO keep working) but intercepts a plain left-click and routes through `push` / `replace`. Props: `to`, `params` (both type-checked against `pages`), `replace?`, `children`, plus common anchor attrs (`className`, `style`, `target`, `aria-label`, `data-id`, `onClick`).
349
343
  - **`setAllPages(map)`** — register the lazy `PageMap<Pages>` after construction (typically from the browser entry, so the Worker's import graph stays free of every lazy page).
350
344
 
351
- A standalone `Link` is also exported from `ugly-app/client` for code that
352
- can't easily reach the typed one; it accepts an optional `router` prop and
353
- falls back to `<RouterProvider>` context.
345
+ A standalone `Link` is also exported from `ugly-app/client` for code
346
+ that can't easily reach the typed one; it accepts an optional `router`
347
+ prop and falls back to `<RouterProvider>` context.
354
348
 
355
- **Never use a bare `<a href="/route">`** for internal navigation — it triggers
356
- a full document reload (white flash + repaint). See the "client navigation"
357
- rule in `CLAUDE.md`.
349
+ **Never use a bare `<a href="/route">`** for internal navigation — it
350
+ triggers a full document reload (white flash + repaint). See the "client
351
+ navigation" rule in `CLAUDE.md`.
358
352
 
359
353
  ### Page map — `lazyPage` / `lazyPageLoader`
360
354
 
@@ -375,9 +369,10 @@ export const allPages = {
375
369
  - **`lazyPageLoader(factory)`** — lazy-imports an async loader `(params) => Promise<ReactElement>`. Use when a route needs data fetching before render. The loader file is the chunk boundary, so it can statically import its page component.
376
370
 
377
371
  Both wrappers recover from stale-deploy chunk 404s ("Failed to fetch
378
- dynamically imported module"): on the first failure they trigger a single
379
- `window.location.reload()` (guarded by `sessionStorage` so a genuinely broken
380
- chunk can't loop) to fetch fresh chunk hashes.
372
+ dynamically imported module"): on the first failure they trigger a
373
+ single `window.location.reload()` (guarded by `sessionStorage` so a
374
+ genuinely broken chunk can't loop) to fetch the fresh `index.html` with
375
+ new chunk hashes.
381
376
 
382
377
  ```ts
383
378
  // pages/SlowPageLoader.tsx
@@ -407,15 +402,15 @@ replace('search', { q: 'hello' }); // → /search?q=hello
407
402
  back(); // browser history back
408
403
  ```
409
404
 
410
- Route names and params are fully typed against `pages`. `push` / `replace`
411
- no-op with a `console.error` when `buildUrl()` produces a URL that doesn't
412
- match a registered route.
405
+ Route names and params are fully typed against `pages`. `push` /
406
+ `replace` no-op with a `console.error` when `buildUrl()` produces a URL
407
+ that doesn't match a registered route.
413
408
 
414
409
  ### Popups — `openPopup()`
415
410
 
416
- `useRouter().openPopup()` is the canonical modal / sheet / menu API. The
417
- router owns the popup layer, drives a spring animation, and stacks popups
418
- z-index-correctly above every page.
411
+ `useRouter().openPopup()` is the canonical modal / sheet / menu API.
412
+ The router owns the popup layer, drives a spring animation, and stacks
413
+ popups z-index-correctly above every page.
419
414
 
420
415
  ```tsx
421
416
  const { openPopup } = useRouter();
@@ -440,20 +435,20 @@ handle.hide(); // dismiss programmatically — same as router.closePopup(handle.
440
435
  - **`contextMenu`** — same as transient, intended for menus and pickers.
441
436
 
442
437
  `renderLayer` receives `{ content, spring, hide }`: `spring` is a
443
- `createAnimatedValue()` result driving 0 → 1; `hide` closes the popup. The
444
- default layer animates open with `easeOut` (250 ms) and closed with `easeIn`
445
- (200 ms). Popups render as siblings of the router's children (managed inside
446
- `RouterProvider`), so they stack above every page.
438
+ `createAnimatedValue()` result driving 0 → 1; `hide` closes the popup.
439
+ The default layer animates open with `easeOut` (250 ms) and closed with
440
+ `easeIn` (200 ms). Popups render as siblings of the router's children
441
+ (managed inside `RouterProvider`), so they stack above every page.
447
442
 
448
443
  ### Scroll containers
449
444
 
450
445
  `html, body, #root` are `overflow: hidden` — the document itself never
451
- scrolls, so a page that just renders tall content is clipped. For pages that
452
- own their scrolling (or render outside the normal authed chrome — e.g. a
453
- public page special-cased before the auth gate), wrap the content in
454
- `SimpleScrollView` (exported from `ugly-app/client`). For long / virtualized
455
- lists with scroll-position persistence, use the richer `ScrollView`. See the
456
- "page scrolling" rule in `CLAUDE.md`.
446
+ scrolls, so a page that just renders tall content is clipped. For pages
447
+ that own their scrolling (or render outside the normal authed chrome —
448
+ e.g. a public page special-cased before the auth gate), wrap the
449
+ content in `SimpleScrollView` (exported from `ugly-app/client`). For
450
+ long / virtualized lists with scroll-position persistence, use the
451
+ richer `ScrollView`. See the "page scrolling" rule in `CLAUDE.md`.
457
452
 
458
453
  ---
459
454
 
@@ -476,9 +471,6 @@ bootstrapApp({
476
471
  });
477
472
  ```
478
473
 
479
- `bootstrapApp(options): void` — returns nothing; it renders directly to the
480
- DOM.
481
-
482
474
  **`BootstrapAppOptions`:**
483
475
 
484
476
  | Field | Description |
@@ -496,28 +488,28 @@ DOM.
496
488
  | `silentSso?` | Apex-domain apps that use ugly.bot as their auth authority but don't share its cookie. When `true`, a logged-out boot attempts a top-level SSO redirect (once per tab) to adopt an existing ugly.bot session. Leave unset for `*.ugly.bot` subdomains and Mode B apps. |
497
489
  | `testMode?` | Skip ugly.bot silent SSO and trust the auth cookie the fixture set. Only honored when `UGLY_APP_TEST_MODE=1`. |
498
490
 
499
- `bootstrapApp` reads `window.__AUTH_TOKEN__` (injected by the server after
500
- cookie verification). If absent, it renders unauthenticated immediately — the
501
- router's per-route auth guard surfaces `<AuthRoot>` only when the user opens a
502
- protected route. If the token is present, it connects the socket, mounts
503
- `<AppProvider>`, and renders.
491
+ `bootstrapApp` reads `window.__AUTH_TOKEN__` (injected by the server
492
+ after cookie verification). If absent, it renders unauthenticated
493
+ immediately — the router's per-route auth guard surfaces `<AuthRoot>`
494
+ only when the user opens a protected route. If the token is present, it
495
+ connects the socket, mounts `<AppProvider>`, and renders.
504
496
 
505
497
  After render, a hidden iframe silently calls
506
- `${UGLY_BOT_URL}/oauth/silent`: if it returns a fresh code, the cookie is
507
- refreshed via `POST /auth/verify`; if the returned account differs from the
508
- current session, the page reloads onto the live account (guarded once per
509
- from→to pair to prevent loops). Any `?ugly_oauth_code=…` in the URL
510
- (redirect-fallback OAuth code from ugly.bot) is redeemed at the top of
511
- bootstrap before anything renders.
498
+ `${UGLY_BOT_URL}/oauth/silent`: if it returns a fresh code, the cookie
499
+ is refreshed via `POST /auth/verify`; if the returned account differs
500
+ from the current session, the page reloads onto the live account
501
+ (guarded once per from→to pair to prevent loops). Any
502
+ `?ugly_oauth_code=…` in the URL (redirect-fallback OAuth code from
503
+ ugly.bot) is redeemed at the top of bootstrap before anything renders.
512
504
 
513
- If `bootstrapApp` is loaded at `/auth/magic-link/verify` (Mode B), it renders
514
- `<MagicLinkCallback>` instead of the regular app shell.
505
+ If `bootstrapApp` is loaded at `/auth/magic-link/verify` (Mode B), it
506
+ renders `<MagicLinkCallback>` instead of the regular app shell.
515
507
 
516
508
  ### `AppProvider` & `useApp()`
517
509
 
518
- `bootstrapApp` mounts `<AppProvider>` automatically after socket connect. Use
519
- `useApp()` inside any page to access the active user, socket, and app-scoped
520
- services.
510
+ `bootstrapApp` mounts `<AppProvider>` automatically after socket
511
+ connect. Use `useApp()` inside any page to access the active user,
512
+ socket, and app-scoped services.
521
513
 
522
514
  ```ts
523
515
  const {
@@ -525,26 +517,28 @@ const {
525
517
  user, // UserBase doc
526
518
  socket, // AppSocket — typed RPC client
527
519
  uglyBotSocket, // UglyBotSocket | null — direct platform socket for STT/TTS
528
- showPopup, // (content: ReactElement) => id — flat overlay layer (see note)
529
- hidePopup, // (id: string) => void
530
- hideAllPopups, // () => void
520
+ showPopup, // flat overlay layer (see note) — returns popup id
521
+ hidePopup,
522
+ hideAllPopups,
531
523
  runAsync, // (label, async () => {…}, options?) — shows loading overlay
532
- splashDone, // (step: string) => void — mark a splash-screen step complete
524
+ splashDone, // (step: string) — mark a splash-screen step complete
533
525
  localizer, // (key, params?) => string — alias for useLocalizer()
534
526
  } = useApp();
535
527
  ```
536
528
 
537
- `useApp<TAsyncOptions>()` is generic in the async-options type so apps can
538
- pass a custom option through to a custom `loadingOverlay` element. When
539
- `runAsync` is pending, `AppProvider` `cloneElement`s the overlay with
540
- `{ label, asyncOptions }`. `useAppOptional()` returns `null` outside the
541
- provider; `useLocalizer()` prefers `<StringsProvider>` data, falls back to
542
- the `AppProvider` `localizer` prop, and finally to identity.
529
+ `useApp<TAsyncOptions>()` is generic in the async-options type so apps
530
+ can pass a custom option through to a custom `loadingOverlay` element.
531
+ `AppProvider` `cloneElement`s the overlay with `{ label, asyncOptions
532
+ }` while `runAsync` is pending. `useAppOptional()` returns `null`
533
+ outside the provider; `useLocalizer()` returns a localizer that prefers
534
+ `<StringsProvider>` data, falls back to the `AppProvider` `localizer`
535
+ prop, and finally to identity.
543
536
 
544
537
  Two popup APIs coexist: `useRouter().openPopup()` (spring-animated,
545
- dismissible, mode-aware — **the default choice**) and `useApp().showPopup()`
546
- (a flat overlay layer returning a string id, for content that must sit
547
- *outside* the router transition). Prefer the router's.
538
+ dismissible, mode-aware — **the default choice**) and
539
+ `useApp().showPopup()` (a flat overlay layer returning a string id, for
540
+ content that must sit *outside* the router transition). Prefer the
541
+ router's.
548
542
 
549
543
  ### Direct socket access
550
544
 
@@ -571,14 +565,14 @@ from `ugly-app/client`.
571
565
  ugly-app uses HttpOnly cookies and server-side JWT injection — no
572
566
  `localStorage`, no client-side token handling.
573
567
 
574
- Two modes, picked from the `auth` block in the project's `.uglyapp` config:
568
+ Two modes, picked from the `auth` block in the project's `.uglyapp`
569
+ config:
575
570
 
576
571
  - **Mode A — `mode: 'uglybot'`** (default when no `auth` block is present). Auth is delegated to ugly.bot OAuth. AI / email / push proxies bill the end user's ugly.bot credits.
577
572
  - **Mode B — `mode: 'self'`**. The app issues its own sessions via magic-link email and/or Google OAuth. AI / email / push proxies bill the developer's ugly.bot account using the project's `AI_PROXY_TOKEN`.
578
573
 
579
- `window.__UGLY_APP_AUTH_MODE__` is injected into every page so client code
580
- (incl. `<AuthRoot>`) can branch correctly. In Mode B with Google configured,
581
- `window.__UGLY_APP_GOOGLE_CLIENT_ID__` is also injected.
574
+ `window.__UGLY_APP_AUTH_MODE__` is injected into every page so client
575
+ code (incl. `<AuthRoot>`) can branch correctly.
582
576
 
583
577
  ### Mode A — ugly.bot OAuth (default)
584
578
 
@@ -590,9 +584,9 @@ Two modes, picked from the `auth` block in the project's `.uglyapp` config:
590
584
 
591
585
  ### Mode B — self-issued sessions
592
586
 
593
- When `.uglyapp` sets `auth.mode: 'self'`, `buildAuth()` wires the magic-link
594
- provider as primary; if `providers.google.clientId` is configured it adds
595
- Google as an extra provider on the same router.
587
+ When `.uglyapp` sets `auth.mode: 'self'`, `buildAuth()` wires the
588
+ magic-link provider as primary; if `providers.google.clientId` is
589
+ configured it adds Google as an extra provider on the same router.
596
590
 
597
591
  - `<AuthRoot>` renders `<MagicLinkForm>` (plus the Google button when configured) instead of `<LoginPopup>`.
598
592
  - Background ugly.bot silent SSO is a no-op.
@@ -602,9 +596,9 @@ Google as an extra provider on the same router.
602
596
 
603
597
  ### Token-in-URL embed
604
598
 
605
- Any GET request with `?token=<JWT>` will, if the token verifies, set the
606
- cookie and 302-redirect to the same URL without the token parameter — useful
607
- for embedding any page in an iframe.
599
+ Any GET request with `?token=<JWT>` will, if the token verifies, set
600
+ the cookie and 302-redirect to the same URL without the token
601
+ parameter — useful for embedding any page in an iframe.
608
602
 
609
603
  ### Built-in auth routes
610
604
 
@@ -638,8 +632,8 @@ configurator.setAuth({
638
632
  ## Database — `TypedDB`
639
633
 
640
634
  Access via `app.db`. All methods accept a `CollectionDef` (from
641
- `defineCollections()`) or, on the `raw*` / `getQuery*` variants, a plain
642
- collection name string.
635
+ `defineCollections()`) or, on the `raw*` / `getQuery*` variants, a
636
+ plain collection name string.
643
637
 
644
638
  ### Writing
645
639
 
@@ -664,10 +658,10 @@ await db.batch(async () => {
664
658
  });
665
659
  ```
666
660
 
667
- Supported update operators: `$inc`, `$addToSet`, `$pull`, `$unset`, `$set`.
668
- All keys are dot-notation, fully typed against the collection's schema.
669
- Partial updates go through optimistic concurrency to prevent lost updates
670
- on concurrent writers.
661
+ Supported update operators: `$inc`, `$addToSet`, `$pull`, `$unset`,
662
+ `$set`. All keys are dot-notation, fully typed against the collection's
663
+ schema. Partial updates go through optimistic concurrency to prevent
664
+ lost updates on concurrent writers.
671
665
 
672
666
  ### Reading
673
667
 
@@ -701,8 +695,8 @@ await db.deleteWhere(collections.note, { userId }); // typed bulk delete
701
695
  await db.deleteQuery(collections.note, { userId }); // legacy untyped bulk delete
702
696
  ```
703
697
 
704
- Pass `deleteHandlers` as the 5th argument to `createApp` to run per-collection
705
- `onDelete` callbacks.
698
+ Pass `deleteHandlers` as the 5th argument to `createApp` to run
699
+ per-collection `onDelete` callbacks.
706
700
 
707
701
  ### Search
708
702
 
@@ -751,9 +745,10 @@ Imports available from `ugly-app`:
751
745
 
752
746
  ## AI providers
753
747
 
754
- AI calls are proxied through ugly.bot — your app never holds a provider key.
755
- Pass `UGLY_BOT_TOKEN` in the environment and the framework handles routing,
756
- balance tracking, retries, and per-user billing.
748
+ AI calls are proxied through ugly.bot — your app never holds a
749
+ provider key. Pass `UGLY_BOT_TOKEN` in the environment and the
750
+ framework handles routing, balance tracking, retries, and per-user
751
+ billing.
757
752
 
758
753
  ### Server-side text generation
759
754
 
@@ -775,9 +770,9 @@ const { message } = await uglyBotRequest<{ message: { content: string } }>('text
775
770
  });
776
771
  ```
777
772
 
778
- Available models are exposed via `textGenModels` / `textGenModelData` from
779
- `ugly-app` — the platform supports Claude, GPT, Gemini, Together, Groq,
780
- Fireworks, Kimi, and Kie families.
773
+ Available models are exposed via `textGenModels` / `textGenModelData`
774
+ from `ugly-app` — the platform supports Claude, GPT, Gemini, Together,
775
+ Groq, Fireworks, Kimi, and Kie families.
781
776
 
782
777
  ### Server-side image generation
783
778
 
@@ -788,8 +783,8 @@ const imageGen = createImageGen(userId);
788
783
  const url = await imageGen.generate('A red panda eating noodles', { model: 'flux_schnell' });
789
784
  ```
790
785
 
791
- `imageGenModels` / `imageGenModelData` enumerate available models (Together
792
- FLUX, FAL, Google Imagen, Wavespeed, Kie Kolors).
786
+ `imageGenModels` / `imageGenModelData` enumerate available models
787
+ (Together FLUX, FAL, Google Imagen, Wavespeed, Kie Kolors).
793
788
 
794
789
  ### Embeddings
795
790
 
@@ -814,8 +809,8 @@ await search.enrichNews({ query: 'topic' });
814
809
 
815
810
  ### Client-side AI calls
816
811
 
817
- Calls from React components go through the framework RPC pipeline — no token
818
- plumbing in the browser:
812
+ Calls from React components go through the framework RPC pipeline — no
813
+ token plumbing in the browser:
819
814
 
820
815
  ```ts
821
816
  import { callTextGen, callJsonGen, callImageGen } from 'ugly-app/client';
@@ -827,8 +822,9 @@ const image = await callImageGen({ prompt: 'a corgi astronaut', model: 'flux_sch
827
822
 
828
823
  ### STT / TTS
829
824
 
830
- Speech goes **directly** from the browser to ugly.bot — never proxied through
831
- your app server (see the "STT/TTS routes to ugly.bot" rule in `CLAUDE.md`).
825
+ Speech goes **directly** from the browser to ugly.bot — never proxied
826
+ through your app server (see the "STT/TTS routes to ugly.bot" rule in
827
+ `CLAUDE.md`).
832
828
 
833
829
  ```ts
834
830
  import { useSTT, useTTS, AudioPlayer, AudioRecorder } from 'ugly-app/client';
@@ -862,23 +858,24 @@ const { uploadUrl, resultUrl } = await storage.presignedPut('temp', key);
862
858
  await storage.delete('public', destKey);
863
859
  ```
864
860
 
865
- Client-side, use `socket.uploadFile(file, key)` — it requests a presigned URL
866
- via the built-in `uploadUrl` framework request and streams the upload. In
867
- dev, uploads go through a same-origin `/_s3` proxy to avoid CORS with local
868
- MinIO.
861
+ Client-side, use `socket.uploadFile(file, key)` — it requests a
862
+ presigned URL via the built-in `uploadUrl` framework request and
863
+ streams the upload. In dev, uploads go through a same-origin `/_s3`
864
+ proxy to avoid CORS with local MinIO.
869
865
 
870
- `STORAGE_KEY_PREFIX` (env) prefixes all keys — useful for per-environment
871
- isolation.
866
+ `STORAGE_KEY_PREFIX` (env) prefixes all keys — useful for
867
+ per-environment isolation.
872
868
 
873
- On Cloudflare Workers, storage uses the R2 binding directly; presigned PUTs
874
- are not exposed (browser uploads must go through a Worker endpoint).
869
+ On Cloudflare Workers, storage uses the R2 binding directly; presigned
870
+ PUTs are not exposed (browser uploads must go through a Worker
871
+ endpoint).
875
872
 
876
873
  ### Static assets — `static/`
877
874
 
878
875
  Large, rarely-changing files (3D models, textures, audio, fonts) go in the
879
876
  project-root `static/` folder. `ugly-app build:static` hashes them and
880
- generates `shared/StaticAssets.ts`; publish uploads only the files whose
881
- bytes changed.
877
+ generates `shared/StaticAssets.ts`; publish uploads only the files whose bytes
878
+ changed.
882
879
 
883
880
  ```ts
884
881
  import { staticUrl } from '../shared/StaticAssets'
@@ -888,14 +885,15 @@ const url = staticUrl('models/char2.ugm')
888
885
  ```
889
886
 
890
887
  The bucket gets its own custom domain, so **Cloudflare's CDN serves these
891
- directly** — no Worker invocation per request, and R2 answers `Range`
892
- natively for progressive loaders. Publish also sets the bucket CORS policy
893
- and a `Cross-Origin-Resource-Policy: cross-origin` response rule, without
894
- which COEP `require-corp` would silently block them.
888
+ directly** — no Worker invocation per request, and R2 answers `Range` natively
889
+ for progressive loaders. (A Worker reading through the R2 binding bypasses the
890
+ edge cache, which is why these do not go through it.) Publish also sets the
891
+ bucket CORS policy and a `Cross-Origin-Resource-Policy: cross-origin` response
892
+ rule, without which COEP `require-corp` would silently block them.
895
893
 
896
894
  Unlike `/{buildId}/assets/*`, these URLs do not change when you deploy, so
897
- returning users do not re-download unchanged files. Assets that no build
898
- from the last `retentionDays` (default 7) references are deleted from R2
895
+ returning users do not re-download unchanged files. Assets that no build from
896
+ the last `retentionDays` (default 7) references are deleted from R2
899
897
  automatically, along with expired `builds/` prefixes.
900
898
 
901
899
  Requires a provisioned app domain — publish fails rather than emitting URLs
@@ -905,8 +903,8 @@ nothing serves. Configure in `.uglyapp`:
905
903
  "staticAssets": { "dir": "static", "retentionDays": 7 }
906
904
  ```
907
905
 
908
- Dev serves the same files off disk at same-origin, so a newly added asset
909
- works before it has ever been published.
906
+ Dev serves the same files off disk at same-origin, so a newly added asset works
907
+ before it has ever been published.
910
908
 
911
909
  ### `hostBundle` — serve the vite bundle from the CDN too
912
910
 
@@ -921,20 +919,19 @@ prefixed, a byte-identical chunk keeps its URL across deploys — so returning
921
919
  users re-download only what actually changed, and each deploy uploads a
922
920
  handful of KB instead of the whole bundle.
923
921
 
924
- The first deploy only provisions; the bundle moves to the CDN on the next
925
- one. That is deliberate — a single pass would point the bundle at a hostname
926
- whose TLS certificate may not have issued yet.
927
-
928
- **Workers are handled for you, but know why.** The bundle is then
929
- cross-origin, and `new Worker(url)` *rejects a cross-origin script URL
930
- outright* — that is structural, and no CORS or CORP header lifts it.
931
- `bootstrapApp` installs a shim that routes such URLs through a same-origin
932
- `blob:` importing the real script, so `new Worker(new URL('./x.worker.ts',
933
- import.meta.url))` keeps working. `SharedWorker` is **not** shimmed: every
934
- blob URL is unique, so the same trick would give each caller a private
935
- worker and break sharing. Register service workers by root-absolute path
936
- (`/sw.js`) — `register()` resolves against the document, so those stay
937
- same-origin.
922
+ The first deploy only provisions; the bundle moves to the CDN on the next one.
923
+ That is deliberate — a single pass would point the bundle at a hostname whose
924
+ TLS certificate may not have issued yet.
925
+
926
+ **Workers are handled for you, but know why.** The bundle is then cross-origin,
927
+ and `new Worker(url)` *rejects a cross-origin script URL outright* — that is
928
+ structural, and no CORS or CORP header lifts it. `bootstrapApp` installs a
929
+ shim that routes such URLs through a same-origin `blob:` importing the real
930
+ script, so `new Worker(new URL('./x.worker.ts', import.meta.url))` keeps
931
+ working. `SharedWorker` is **not** shimmed: every blob URL is unique, so the
932
+ same trick would give each caller a private worker and break sharing. Register
933
+ service workers by root-absolute path (`/sw.js`) — `register()` resolves
934
+ against the document, so those stay same-origin.
938
935
 
939
936
  ---
940
937
 
@@ -966,15 +963,16 @@ const cronHandlers: WorkerHandlers<typeof cronTasks> = {
966
963
  configurator.setWorkers(cronTasks, cronHandlers);
967
964
  ```
968
965
 
969
- Each worker can have `inputSchema`, `outputSchema`, `schedule`, `timeout`,
970
- `description`. Workers without a schedule are still invocable via
971
- `POST /_workers/run` (auth: localhost in dev, `Authorization: Bearer $CRON_SECRET`
972
- in prod). Scheduled workers also appear in `/_cron/manifest` for the deploy
973
- orchestrator.
966
+ Each worker can have `inputSchema`, `outputSchema`, `schedule`,
967
+ `timeout`, `description`. Workers without a schedule are still
968
+ invocable via `POST /_workers/run` (auth: localhost in dev,
969
+ `Authorization: Bearer $CRON_SECRET` in prod). Scheduled workers also
970
+ appear in `/_cron/manifest` for the deploy orchestrator.
974
971
 
975
- On the Workers adapter, scheduled workers dispatch through Cloudflare Cron
976
- Triggers and durable queueing goes through Cloudflare Queues. On the Node
977
- adapter, `enqueueWorker` runs the handler inline (in-process queues only).
972
+ On the Workers adapter, scheduled workers dispatch through Cloudflare
973
+ Cron Triggers and durable queueing goes through Cloudflare Queues. On
974
+ the Node adapter, `enqueueWorker` runs the handler inline (in-process
975
+ queues only).
978
976
 
979
977
  ---
980
978
 
@@ -989,9 +987,9 @@ configurator.setStrings({
989
987
  });
990
988
  ```
991
989
 
992
- The framework injects `window.__LANG__`, `window.__STRINGS_VERSION__`, and
993
- `window.__CRITICAL_STRINGS__` into SSR HTML. Use `useLocalizer()` /
994
- `useStrings()` / `useLang()` / `useChangeLanguage()` on the client.
990
+ The framework injects `window.__LANG__`, `window.__STRINGS_VERSION__`,
991
+ and `window.__CRITICAL_STRINGS__` into SSR HTML. Use `useLocalizer()`
992
+ / `useStrings()` / `useLang()` / `useChangeLanguage()` on the client.
995
993
 
996
994
  ---
997
995
 
@@ -1016,9 +1014,10 @@ export const experiments: Experiment[] = [
1016
1014
  configurator.setExperiments(experiments);
1017
1015
  ```
1018
1016
 
1019
- Bucketing is deterministic: `hash(experimentId + userId)` (or `sessionId`
1020
- for unauthenticated users). The framework's `initSession` / `captureEvent`
1021
- requests automatically tag events with the user's branch assignments.
1017
+ Bucketing is deterministic: `hash(experimentId + userId)` (or
1018
+ `sessionId` for unauthenticated users). The framework's `initSession`
1019
+ / `captureEvent` requests automatically tag events with the user's
1020
+ branch assignments.
1022
1021
 
1023
1022
  ---
1024
1023
 
@@ -1044,15 +1043,17 @@ requests automatically tag events with the user's branch assignments.
1044
1043
 
1045
1044
  ## Two-adapter architecture
1046
1045
 
1047
- The same developer source compiles for two runtimes via `src/server/adapter/`:
1046
+ The same developer source compiles for two runtimes via
1047
+ `src/server/adapter/`:
1048
1048
 
1049
1049
  - **Adapter A — Node + TCP** (`src/server/adapter/node/`): default for `npm run dev` and any non-Workers deploy. Wraps `pg.Pool`, `nats.js`, AWS S3 SDK.
1050
1050
  - **Adapter B — Cloudflare Workers** (`src/server/adapter/workers/`): used when the Studio publish flow deploys to Cloudflare. Hono router + Durable Objects (one `CollectionDO` per project-collection, plus a `SessionDO` per user WS) + `@neondatabase/serverless` HTTP driver + R2 binding + Cloudflare Cron Triggers + Cloudflare Queues.
1051
1051
 
1052
- Developer-facing APIs (`createTypedDB`, `subscribeDoc`, `createStorageClient`,
1053
- `setCronTasks`, `setWorkers`) don't change between adapters. Local Workers dev
1054
- boots via `npm run dev:workers`, which starts a small Node HTTP proxy speaking
1055
- Neon's wire format so the Worker can talk to a real Postgres.
1052
+ Developer-facing APIs (`createTypedDB`, `subscribeDoc`,
1053
+ `createStorageClient`, `setCronTasks`, `setWorkers`) don't change
1054
+ between adapters. Local Workers dev boots via `npm run dev:workers`,
1055
+ which starts a small Node HTTP proxy speaking Neon's wire format so
1056
+ the Worker can talk to a real Postgres.
1056
1057
 
1057
1058
  ---
1058
1059
 
@@ -1107,8 +1108,8 @@ Browser-visible variables must be prefixed `VITE_` and consumed via
1107
1108
  | `ugly-app feedback:dev` / `feedback:prod` | Query user feedback. |
1108
1109
  | `ugly-app feedback:submit` / `feedback:resolve` | Manage feedback (run with `--help` for flags). |
1109
1110
 
1110
- Inside a scaffolded project, the same commands are available via `npm run …`
1111
- scripts — see `templates/CLAUDE.md`.
1111
+ Inside a scaffolded project, the same commands are available via `npm
1112
+ run …` scripts — see `templates/CLAUDE.md`.
1112
1113
 
1113
1114
  ---
1114
1115