ugly-app 0.1.949 → 0.1.951

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 (48) hide show
  1. package/README.md +210 -211
  2. package/dist/cli/build.d.ts.map +1 -1
  3. package/dist/cli/build.js +28 -0
  4. package/dist/cli/build.js.map +1 -1
  5. package/dist/cli/version.d.ts +1 -1
  6. package/dist/cli/version.js +1 -1
  7. package/dist/client/audio/AudioPlayer.d.ts +1 -0
  8. package/dist/client/audio/AudioPlayer.d.ts.map +1 -1
  9. package/dist/client/audio/AudioPlayer.js +13 -5
  10. package/dist/client/audio/AudioPlayer.js.map +1 -1
  11. package/dist/client/audio/AudioRecorder.d.ts +1 -0
  12. package/dist/client/audio/AudioRecorder.d.ts.map +1 -1
  13. package/dist/client/audio/AudioRecorder.js +8 -2
  14. package/dist/client/audio/AudioRecorder.js.map +1 -1
  15. package/dist/client/audio/audioPlayer.worklet.js +30 -3
  16. package/dist/client/audio/audioPlayer.worklet.js.map +1 -1
  17. package/dist/client/audio/audioRecorder.worklet.js +7 -3
  18. package/dist/client/audio/audioRecorder.worklet.js.map +1 -1
  19. package/dist/client/audio/workletLoader.d.ts +19 -0
  20. package/dist/client/audio/workletLoader.d.ts.map +1 -0
  21. package/dist/client/audio/workletLoader.js +57 -0
  22. package/dist/client/audio/workletLoader.js.map +1 -0
  23. package/dist/server/App.d.ts.map +1 -1
  24. package/dist/server/App.js +7 -1
  25. package/dist/server/App.js.map +1 -1
  26. package/dist/vite/audioWorkletsPlugin.d.ts +36 -0
  27. package/dist/vite/audioWorkletsPlugin.d.ts.map +1 -0
  28. package/dist/vite/audioWorkletsPlugin.js +99 -0
  29. package/dist/vite/audioWorkletsPlugin.js.map +1 -0
  30. package/dist/vite/index.d.ts +1 -0
  31. package/dist/vite/index.d.ts.map +1 -1
  32. package/dist/vite/index.js +1 -0
  33. package/dist/vite/index.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/cli/build.ts +33 -0
  36. package/src/cli/version.ts +1 -1
  37. package/src/client/audio/AudioPlayer.ts +25 -5
  38. package/src/client/audio/AudioRecorder.ts +20 -2
  39. package/src/client/audio/audioPlayer.worklet.ts +33 -6
  40. package/src/client/audio/audioRecorder.worklet.ts +11 -7
  41. package/src/client/audio/workletLoader.test.ts +122 -0
  42. package/src/client/audio/workletLoader.ts +66 -0
  43. package/src/client/audio/workletSync.test.ts +43 -0
  44. package/src/server/App.ts +7 -1
  45. package/src/vite/audioWorkletsPlugin.test.ts +45 -0
  46. package/src/vite/audioWorkletsPlugin.ts +104 -0
  47. package/src/vite/index.ts +6 -0
  48. package/templates/vite.config.ts +11 -2
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.
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.
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.
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
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`.
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`.
13
13
 
14
14
  ```bash
15
15
  npx ugly-app init my-app
@@ -27,6 +27,7 @@ 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`. |
30
31
  | `ugly-app/collab/{server,client}` | Yjs-based collaborative editing. |
31
32
  | `ugly-app/markdown/{shared,client}` | Markdown rendering + editor. |
32
33
  | `ugly-app/search/{shared,server}` | Search primitives. |
@@ -44,8 +45,8 @@ npm run dev
44
45
 
45
46
  ## Server — `createApp()`
46
47
 
47
- Single server entry point. Returns an `App` that owns Express, the
48
- WebSocket server, the typed DB, and the RPC dispatcher.
48
+ Single server entry point. Returns an `App` that owns Express, the WebSocket
49
+ server, the typed DB, and the RPC dispatcher.
49
50
 
50
51
  ```ts
51
52
  // server/index.ts
@@ -103,29 +104,31 @@ function createApp<
103
104
  ): App<CollectionMap<typeof BUILTIN_DEFS & Defs>, RegistryPages<R>>;
104
105
  ```
105
106
 
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`.
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`.
110
111
 
111
112
  ### The returned `App`
112
113
 
113
- | Field | Description |
114
- |-------|-------------|
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
+ |--------|-------------|
115
119
  | `start(port?)` | Start the server (default 3000; templates use 4321). |
116
- | `db` | The `TypedDB<Map>` (map inferred from your collections + framework built-ins). |
120
+ | `db` | `TypedDB<Map>` — map inferred from your collections + framework built-ins. |
117
121
  | `httpServer` | The underlying Node `http.Server`. |
118
122
  | `wss` | The main `WebSocketServer` (path set via `setWsPath`, default `/rpc`). |
119
- | `dispatch(name, input, userId)` | Invoke a registered RPC handler programmatically (inherited from `AppRouter`). |
123
+ | `dispatch(name, input, userId)` | `Promise<unknown>` — invoke any registered RPC handler programmatically. |
120
124
  | `registerRoutes(fn)` | Mount additional Express routes after creation. |
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. |
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. |
122
126
 
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.
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.
129
132
 
130
133
  ### `AppConfigurator`
131
134
 
@@ -156,8 +159,8 @@ Every method is optional; `setPages` is what mounts the SPA.
156
159
 
157
160
  ### Handler signatures
158
161
 
159
- Handlers are plain async functions — no context object. Access state
160
- via captured imports (`app.db`, `storage`, `pgQuery`, `uglyBotRequest`, …).
162
+ Handlers are plain async functions — no context object. Access state via
163
+ captured imports (`app.db`, `storage`, `pgQuery`, `uglyBotRequest`, …).
161
164
 
162
165
  ```ts
163
166
  // req() — public, userId may be null
@@ -169,30 +172,37 @@ getMe: async (userId: string, input) => { /* … */ }
169
172
 
170
173
  ### Built-in framework requests
171
174
 
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.
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.
175
178
 
176
- | Name | Purpose |
177
- |------|---------|
179
+ | Name | Notes |
180
+ |------|-------|
178
181
  | `userGet` | Returns `{ userId, name, avatarUri }` for the given user (or caller). |
179
182
  | `initSession` / `captureEvent` | Session + event logging tagged with experiment branches (public — no auth). |
180
- | `textGen` / `imageGen` | AI proxies — server-validated, billed through ugly.bot. |
181
- | `kagiSearch` / `kagiSummarize` / `kagiEnrichWeb` / `kagiEnrichNews` | Web search via ugly.bot. |
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. |
182
187
  | `uploadUrl` | Issues a presigned PUT for the `temp` bucket. |
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. |
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. |
186
194
  | `adminGetPerfLogs` | Admin-only perf telemetry read. |
187
195
  | `adminCreateTestUser` / `adminListTestUsers` / `adminDeleteTestUser` | Admin-only synthetic-user management. Gated by `setIsAdmin()`. |
188
196
  | `projectPlanList` / `projectPlanCreate` / `projectPlanUpdate` / `projectPlanDelete` | Project plan CRUD used by Studio. |
189
197
 
198
+ Add per-endpoint rate limits on your own handlers with `rateLimit: { max, window }` on the request definition (see below).
199
+
190
200
  ---
191
201
 
192
202
  ## Shared API definitions
193
203
 
194
- `shared/` is consumed by both server and client. Keep all Zod schemas,
195
- types, collections, and route declarations here.
204
+ `shared/` is consumed by both server and client. Keep all Zod schemas, types,
205
+ collections, and route declarations here.
196
206
 
197
207
  ### Requests (`shared/api.ts`)
198
208
 
@@ -222,8 +232,8 @@ export const requests = defineRequests({
222
232
  ```
223
233
 
224
234
  Every request is reachable as **both** `socket.request(name, input)`
225
- (WebSocket) and `POST /api/:name { input }` (HTTP). `z` is re-exported
226
- from Zod for convenience.
235
+ (WebSocket) and `POST /api/:name { input }` (HTTP). `z` is re-exported from
236
+ Zod for convenience.
227
237
 
228
238
  ### Collections (`shared/collections.ts`)
229
239
 
@@ -259,20 +269,19 @@ export const collections = defineCollections({
259
269
  - `public` — allow unauthenticated client reads.
260
270
  - `cascadeFrom` — parent collection: when the parent doc is deleted, docs in this collection are cascade-deleted.
261
271
  - `trackKeys?` — fields usable as NATS routing keys for scoped `trackDocs` subscriptions.
262
- - `getter?(ids)` — batch resolver for collections with no local table (framework pattern; e.g. `userPublic` resolves via ugly.bot).
272
+ - `getter?(ids)` — batch resolver for collections with no local table (e.g. `userPublic` resolves via ugly.bot).
263
273
  - `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`.
264
274
  - `search?: { fields, language? }` — full-text index over the named JSONB paths (Neon: Postgres FTS; D1: SQLite FTS5, ranked by bm25).
265
275
  - `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 })`.
266
276
 
267
277
  All documents extend `DBObject`: `{ _id, version, created, updated }`.
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`).
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`).
272
281
 
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).
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).
276
285
 
277
286
  ---
278
287
 
@@ -295,23 +304,20 @@ export const pages = definePages({
295
304
  export type AppPages = typeof pages;
296
305
  ```
297
306
 
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):
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:
303
310
 
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.
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.
306
313
  - `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).
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.
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.
308
315
 
309
316
  Path syntax: `:param` matches a single path segment; `*param` is greedy
310
- (captures slashes). Query-string params are declared in `Params` but
311
- never appear in the path template.
317
+ (captures slashes). Query-string params are declared in `Params` but never
318
+ appear in the path template.
312
319
 
313
- `definePages<T>(p)` is a pass-through identity — use it to give the
314
- registry a name.
320
+ `definePages<T>(p)` is a pass-through identity — use it to name the registry.
315
321
 
316
322
  ### `createRouter()` (`ugly-app/client`)
317
323
 
@@ -332,23 +338,23 @@ export const {
332
338
  **Config:**
333
339
  - `pages` (required) — the `PageRegistry` from `shared/pages.ts`.
334
340
  - `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`.
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.
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.
336
342
 
337
343
  **Returns:**
338
344
 
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`).
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).
341
347
  - **`useRouter()`** — returns `RouterContextValue<Pages>` (typed navigation + popup API). Throws if used outside `<RouterProvider>`.
342
348
  - **`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`).
343
349
  - **`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).
344
350
 
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.
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.
348
354
 
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`.
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`.
352
358
 
353
359
  ### Page map — `lazyPage` / `lazyPageLoader`
354
360
 
@@ -369,10 +375,9 @@ export const allPages = {
369
375
  - **`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.
370
376
 
371
377
  Both wrappers recover from stale-deploy chunk 404s ("Failed to fetch
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.
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.
376
381
 
377
382
  ```ts
378
383
  // pages/SlowPageLoader.tsx
@@ -402,15 +407,15 @@ replace('search', { q: 'hello' }); // → /search?q=hello
402
407
  back(); // browser history back
403
408
  ```
404
409
 
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.
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.
408
413
 
409
414
  ### Popups — `openPopup()`
410
415
 
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.
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.
414
419
 
415
420
  ```tsx
416
421
  const { openPopup } = useRouter();
@@ -435,20 +440,20 @@ handle.hide(); // dismiss programmatically — same as router.closePopup(handle.
435
440
  - **`contextMenu`** — same as transient, intended for menus and pickers.
436
441
 
437
442
  `renderLayer` receives `{ content, spring, hide }`: `spring` is a
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.
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.
442
447
 
443
448
  ### Scroll containers
444
449
 
445
450
  `html, body, #root` are `overflow: hidden` — the document itself never
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`.
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`.
452
457
 
453
458
  ---
454
459
 
@@ -471,6 +476,9 @@ bootstrapApp({
471
476
  });
472
477
  ```
473
478
 
479
+ `bootstrapApp(options): void` — returns nothing; it renders directly to the
480
+ DOM.
481
+
474
482
  **`BootstrapAppOptions`:**
475
483
 
476
484
  | Field | Description |
@@ -488,28 +496,28 @@ bootstrapApp({
488
496
  | `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. |
489
497
  | `testMode?` | Skip ugly.bot silent SSO and trust the auth cookie the fixture set. Only honored when `UGLY_APP_TEST_MODE=1`. |
490
498
 
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.
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.
496
504
 
497
505
  After render, a hidden iframe silently calls
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.
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.
504
512
 
505
- If `bootstrapApp` is loaded at `/auth/magic-link/verify` (Mode B), it
506
- renders `<MagicLinkCallback>` instead of the regular app shell.
513
+ If `bootstrapApp` is loaded at `/auth/magic-link/verify` (Mode B), it renders
514
+ `<MagicLinkCallback>` instead of the regular app shell.
507
515
 
508
516
  ### `AppProvider` & `useApp()`
509
517
 
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.
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.
513
521
 
514
522
  ```ts
515
523
  const {
@@ -517,28 +525,26 @@ const {
517
525
  user, // UserBase doc
518
526
  socket, // AppSocket — typed RPC client
519
527
  uglyBotSocket, // UglyBotSocket | null — direct platform socket for STT/TTS
520
- showPopup, // flat overlay layer (see note) — returns popup id
521
- hidePopup,
522
- hideAllPopups,
528
+ showPopup, // (content: ReactElement) => id — flat overlay layer (see note)
529
+ hidePopup, // (id: string) => void
530
+ hideAllPopups, // () => void
523
531
  runAsync, // (label, async () => {…}, options?) — shows loading overlay
524
- splashDone, // (step: string) — mark a splash-screen step complete
532
+ splashDone, // (step: string) => void — mark a splash-screen step complete
525
533
  localizer, // (key, params?) => string — alias for useLocalizer()
526
534
  } = useApp();
527
535
  ```
528
536
 
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.
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.
536
543
 
537
544
  Two popup APIs coexist: `useRouter().openPopup()` (spring-animated,
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.
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.
542
548
 
543
549
  ### Direct socket access
544
550
 
@@ -565,14 +571,14 @@ from `ugly-app/client`.
565
571
  ugly-app uses HttpOnly cookies and server-side JWT injection — no
566
572
  `localStorage`, no client-side token handling.
567
573
 
568
- Two modes, picked from the `auth` block in the project's `.uglyapp`
569
- config:
574
+ Two modes, picked from the `auth` block in the project's `.uglyapp` config:
570
575
 
571
576
  - **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.
572
577
  - **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`.
573
578
 
574
- `window.__UGLY_APP_AUTH_MODE__` is injected into every page so client
575
- code (incl. `<AuthRoot>`) can branch correctly.
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.
576
582
 
577
583
  ### Mode A — ugly.bot OAuth (default)
578
584
 
@@ -584,9 +590,9 @@ code (incl. `<AuthRoot>`) can branch correctly.
584
590
 
585
591
  ### Mode B — self-issued sessions
586
592
 
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.
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.
590
596
 
591
597
  - `<AuthRoot>` renders `<MagicLinkForm>` (plus the Google button when configured) instead of `<LoginPopup>`.
592
598
  - Background ugly.bot silent SSO is a no-op.
@@ -596,9 +602,9 @@ configured it adds Google as an extra provider on the same router.
596
602
 
597
603
  ### Token-in-URL embed
598
604
 
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.
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.
602
608
 
603
609
  ### Built-in auth routes
604
610
 
@@ -632,8 +638,8 @@ configurator.setAuth({
632
638
  ## Database — `TypedDB`
633
639
 
634
640
  Access via `app.db`. All methods accept a `CollectionDef` (from
635
- `defineCollections()`) or, on the `raw*` / `getQuery*` variants, a
636
- plain collection name string.
641
+ `defineCollections()`) or, on the `raw*` / `getQuery*` variants, a plain
642
+ collection name string.
637
643
 
638
644
  ### Writing
639
645
 
@@ -658,10 +664,10 @@ await db.batch(async () => {
658
664
  });
659
665
  ```
660
666
 
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.
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.
665
671
 
666
672
  ### Reading
667
673
 
@@ -695,8 +701,8 @@ await db.deleteWhere(collections.note, { userId }); // typed bulk delete
695
701
  await db.deleteQuery(collections.note, { userId }); // legacy untyped bulk delete
696
702
  ```
697
703
 
698
- Pass `deleteHandlers` as the 5th argument to `createApp` to run
699
- per-collection `onDelete` callbacks.
704
+ Pass `deleteHandlers` as the 5th argument to `createApp` to run per-collection
705
+ `onDelete` callbacks.
700
706
 
701
707
  ### Search
702
708
 
@@ -745,10 +751,9 @@ Imports available from `ugly-app`:
745
751
 
746
752
  ## AI providers
747
753
 
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.
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.
752
757
 
753
758
  ### Server-side text generation
754
759
 
@@ -770,9 +775,9 @@ const { message } = await uglyBotRequest<{ message: { content: string } }>('text
770
775
  });
771
776
  ```
772
777
 
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.
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.
776
781
 
777
782
  ### Server-side image generation
778
783
 
@@ -783,8 +788,8 @@ const imageGen = createImageGen(userId);
783
788
  const url = await imageGen.generate('A red panda eating noodles', { model: 'flux_schnell' });
784
789
  ```
785
790
 
786
- `imageGenModels` / `imageGenModelData` enumerate available models
787
- (Together FLUX, FAL, Google Imagen, Wavespeed, Kie Kolors).
791
+ `imageGenModels` / `imageGenModelData` enumerate available models (Together
792
+ FLUX, FAL, Google Imagen, Wavespeed, Kie Kolors).
788
793
 
789
794
  ### Embeddings
790
795
 
@@ -809,8 +814,8 @@ await search.enrichNews({ query: 'topic' });
809
814
 
810
815
  ### Client-side AI calls
811
816
 
812
- Calls from React components go through the framework RPC pipeline — no
813
- token plumbing in the browser:
817
+ Calls from React components go through the framework RPC pipeline — no token
818
+ plumbing in the browser:
814
819
 
815
820
  ```ts
816
821
  import { callTextGen, callJsonGen, callImageGen } from 'ugly-app/client';
@@ -822,9 +827,8 @@ const image = await callImageGen({ prompt: 'a corgi astronaut', model: 'flux_sch
822
827
 
823
828
  ### STT / TTS
824
829
 
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`).
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`).
828
832
 
829
833
  ```ts
830
834
  import { useSTT, useTTS, AudioPlayer, AudioRecorder } from 'ugly-app/client';
@@ -858,24 +862,23 @@ const { uploadUrl, resultUrl } = await storage.presignedPut('temp', key);
858
862
  await storage.delete('public', destKey);
859
863
  ```
860
864
 
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.
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.
865
869
 
866
- `STORAGE_KEY_PREFIX` (env) prefixes all keys — useful for
867
- per-environment isolation.
870
+ `STORAGE_KEY_PREFIX` (env) prefixes all keys — useful for per-environment
871
+ isolation.
868
872
 
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).
873
+ On Cloudflare Workers, storage uses the R2 binding directly; presigned PUTs
874
+ are not exposed (browser uploads must go through a Worker endpoint).
872
875
 
873
876
  ### Static assets — `static/`
874
877
 
875
878
  Large, rarely-changing files (3D models, textures, audio, fonts) go in the
876
879
  project-root `static/` folder. `ugly-app build:static` hashes them and
877
- generates `shared/StaticAssets.ts`; publish uploads only the files whose bytes
878
- changed.
880
+ generates `shared/StaticAssets.ts`; publish uploads only the files whose
881
+ bytes changed.
879
882
 
880
883
  ```ts
881
884
  import { staticUrl } from '../shared/StaticAssets'
@@ -885,15 +888,14 @@ const url = staticUrl('models/char2.ugm')
885
888
  ```
886
889
 
887
890
  The bucket gets its own custom domain, so **Cloudflare's CDN serves these
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.
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.
893
895
 
894
896
  Unlike `/{buildId}/assets/*`, these URLs do not change when you deploy, so
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
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
897
899
  automatically, along with expired `builds/` prefixes.
898
900
 
899
901
  Requires a provisioned app domain — publish fails rather than emitting URLs
@@ -903,8 +905,8 @@ nothing serves. Configure in `.uglyapp`:
903
905
  "staticAssets": { "dir": "static", "retentionDays": 7 }
904
906
  ```
905
907
 
906
- Dev serves the same files off disk at same-origin, so a newly added asset works
907
- before it has ever been published.
908
+ Dev serves the same files off disk at same-origin, so a newly added asset
909
+ works before it has ever been published.
908
910
 
909
911
  ### `hostBundle` — serve the vite bundle from the CDN too
910
912
 
@@ -919,19 +921,20 @@ prefixed, a byte-identical chunk keeps its URL across deploys — so returning
919
921
  users re-download only what actually changed, and each deploy uploads a
920
922
  handful of KB instead of the whole bundle.
921
923
 
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.
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.
935
938
 
936
939
  ---
937
940
 
@@ -963,16 +966,15 @@ const cronHandlers: WorkerHandlers<typeof cronTasks> = {
963
966
  configurator.setWorkers(cronTasks, cronHandlers);
964
967
  ```
965
968
 
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.
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.
971
974
 
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).
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).
976
978
 
977
979
  ---
978
980
 
@@ -987,9 +989,9 @@ configurator.setStrings({
987
989
  });
988
990
  ```
989
991
 
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.
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.
993
995
 
994
996
  ---
995
997
 
@@ -1014,10 +1016,9 @@ export const experiments: Experiment[] = [
1014
1016
  configurator.setExperiments(experiments);
1015
1017
  ```
1016
1018
 
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.
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.
1021
1022
 
1022
1023
  ---
1023
1024
 
@@ -1043,17 +1044,15 @@ branch assignments.
1043
1044
 
1044
1045
  ## Two-adapter architecture
1045
1046
 
1046
- The same developer source compiles for two runtimes via
1047
- `src/server/adapter/`:
1047
+ The same developer source compiles for two runtimes via `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`,
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.
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.
1057
1056
 
1058
1057
  ---
1059
1058
 
@@ -1108,8 +1107,8 @@ Browser-visible variables must be prefixed `VITE_` and consumed via
1108
1107
  | `ugly-app feedback:dev` / `feedback:prod` | Query user feedback. |
1109
1108
  | `ugly-app feedback:submit` / `feedback:resolve` | Manage feedback (run with `--help` for flags). |
1110
1109
 
1111
- Inside a scaffolded project, the same commands are available via `npm
1112
- run …` scripts — see `templates/CLAUDE.md`.
1110
+ Inside a scaffolded project, the same commands are available via `npm run …`
1111
+ scripts — see `templates/CLAUDE.md`.
1113
1112
 
1114
1113
  ---
1115
1114