ugly-app 0.1.873 → 0.1.875

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 (33) hide show
  1. package/README.md +139 -107
  2. package/dist/cli/version.d.ts +1 -1
  3. package/dist/cli/version.js +1 -1
  4. package/dist/client/components/DeviceSimulator.d.ts.map +1 -1
  5. package/dist/client/components/DeviceSimulator.js +1 -12
  6. package/dist/client/components/DeviceSimulator.js.map +1 -1
  7. package/dist/client/deviceProfiles.d.ts +47 -0
  8. package/dist/client/deviceProfiles.d.ts.map +1 -0
  9. package/dist/client/deviceProfiles.js +22 -0
  10. package/dist/client/deviceProfiles.js.map +1 -0
  11. package/dist/client/inspect/keyboard.js +28 -4
  12. package/dist/client/inspect/keyboard.js.map +1 -1
  13. package/dist/playwright/index.d.ts +2 -0
  14. package/dist/playwright/index.d.ts.map +1 -1
  15. package/dist/playwright/index.js +5 -0
  16. package/dist/playwright/index.js.map +1 -1
  17. package/dist/playwright/mobile.d.ts +45 -0
  18. package/dist/playwright/mobile.d.ts.map +1 -0
  19. package/dist/playwright/mobile.js +114 -0
  20. package/dist/playwright/mobile.js.map +1 -0
  21. package/dist/server/sqlite/SqliteDoc.d.ts.map +1 -1
  22. package/dist/server/sqlite/SqliteDoc.js +37 -15
  23. package/dist/server/sqlite/SqliteDoc.js.map +1 -1
  24. package/package.json +1 -1
  25. package/src/cli/version.ts +1 -1
  26. package/src/client/components/DeviceSimulator.tsx +4 -15
  27. package/src/client/deviceProfiles.ts +32 -0
  28. package/src/client/inspect/keyboard.ts +28 -4
  29. package/src/playwright/index.ts +14 -0
  30. package/src/playwright/mobile.ts +148 -0
  31. package/src/server/sqlite/SqliteDoc.test.ts +51 -0
  32. package/src/server/sqlite/SqliteDoc.ts +47 -22
  33. package/templates/.claude/skills/eval-swarm/SKILL.md +155 -14
package/README.md CHANGED
@@ -1,21 +1,22 @@
1
1
  # ugly-app
2
2
 
3
- A full-stack TypeScript framework for shipping production web apps with one CLI. Scaffold with `npx ugly-app init my-app` and get an opinionated Express + React + PostgreSQL stack with built-in auth, type-safe RPC over WebSocket and HTTP, real-time document tracking, AI generation, storage, and a CLI for every workflow.
3
+ A full-stack TypeScript framework for shipping production web apps with one CLI. Scaffold with `npx ugly-app init my-app` and get an opinionated Express + React + PostgreSQL stack with built-in auth, type-safe RPC over WebSocket and HTTP, real-time document tracking, AI generation, storage, cron/workers, and a CLI for every workflow.
4
4
 
5
- ugly-app is designed to be deployed and operated through [ugly.bot](https://ugly.bot) — the platform handles auth, infra (PostgreSQL, Qdrant, NATS, S3-compatible object storage), AI provider keys, and deployment. Your app talks to all of this through the project's dev tunnel and the per-app `UGLY_BOT_TOKEN`.
5
+ ugly-app is designed to be deployed and operated through [ugly.bot](https://ugly.bot) — the platform provides auth, infra (PostgreSQL, Qdrant, NATS, S3-compatible object storage), AI provider keys, and deployment. Your app talks to all of this through the project's dev tunnel and the per-app `UGLY_BOT_TOKEN`.
6
6
 
7
7
  ## What's included
8
8
 
9
9
  - **Server**: Express + WebSocket with type-safe RPC and Zod validation
10
- - **Client**: React + Vite with typed routing, lazy pages, animated transitions, popup management
11
- - **Database**: PostgreSQL (JSONB) via the data proxy, with full-text search (`search`) and vector search (Qdrant `vector`)
12
- - **Auth**: HttpOnly cookies + JWT, ugly.bot OAuth out of the box, extensible via `AuthProvider`
13
- - **AI**: Text, image, embeddings, web search — all proxied through ugly.bot (no per-provider keys in your app)
10
+ - **Client**: React + Vite with typed routing, lazy pages, animated transitions, popup layer
11
+ - **Database**: PostgreSQL (JSONB) via the data proxy, with full-text search and vector search (Qdrant)
12
+ - **Auth**: HttpOnly cookies + JWT; ugly.bot OAuth out of the box, magic-link / Google via `mode: 'self'`
13
+ - **AI**: Text, JSON, image, embeddings, web search — all proxied through ugly.bot (no per-provider keys in your app)
14
14
  - **Realtime**: NATS pub/sub and document change subscriptions (`trackDoc` / `trackDocs`)
15
15
  - **Storage**: S3-compatible buckets with presigned uploads
16
16
  - **Workers & cron**: `setWorkers()` registers named async tasks with optional Zod input schemas and cron schedules
17
17
  - **Localization**: Strings tables with critical-string SSR injection
18
18
  - **Experiments**: Deterministic A/B bucketing tied to event logging
19
+ - **Two-adapter deploy**: Same source runs on Node (Express) or Cloudflare Workers (Hono + Durable Objects)
19
20
  - **CLI**: `ugly-app` commands for dev, build, deploy, migrations, logs, AI, and auth
20
21
 
21
22
  ## Quick start
@@ -26,7 +27,7 @@ cd my-app
26
27
  npm run dev
27
28
  ```
28
29
 
29
- The scaffold gives you a working app at `http://localhost:4321` with todo CRUD, AI chat, file upload, auth demo, collab editing, and ~20 other test pages wired up.
30
+ The scaffold gives you a working app at `http://localhost:4321` with todo CRUD, AI chat, file upload, auth demo, collab editing, and other test pages wired up.
30
31
 
31
32
  ---
32
33
 
@@ -40,10 +41,11 @@ The single server entry point. Returns an `App` that owns Express, the WebSocket
40
41
  import {
41
42
  createApp,
42
43
  type AppConfigurator,
44
+ type InboundEmail,
43
45
  type RequestHandlers,
44
46
  } from 'ugly-app';
45
- import { dbDefaults } from 'ugly-app/shared';
46
47
  import { nanoid } from 'nanoid';
48
+ import { dbDefaults } from 'ugly-app/shared';
47
49
  import { requests, messages } from '../shared/api';
48
50
  import { collections } from '../shared/collections';
49
51
  import { pages } from '../shared/pages';
@@ -53,7 +55,9 @@ const app = createApp(
53
55
  {
54
56
  createTodo: async (userId, { text }) => {
55
57
  const _id = nanoid();
56
- await app.db.setDoc(collections.todo, { _id, userId, text, done: false, ...dbDefaults() });
58
+ await app.db.setDoc(collections.todo, {
59
+ _id, userId, text, done: false, ...dbDefaults(),
60
+ });
57
61
  return { id: _id };
58
62
  },
59
63
  } satisfies RequestHandlers<typeof requests>,
@@ -69,37 +73,43 @@ await app.start(parseInt(process.env['PORT'] ?? '4321'));
69
73
  **Signature:**
70
74
 
71
75
  ```typescript
72
- function createApp<R extends AppRegistryBase, Defs extends CollectionDefRegistry>(
76
+ function createApp<
77
+ R extends AppRegistryBase,
78
+ Defs extends CollectionDefRegistry,
79
+ >(
73
80
  registry: R, // { requests, messages }
74
81
  requests: Partial<RequestHandlers<R['requests']>>, // handler implementations
75
82
  appDefs: Defs, // collections from defineCollections()
76
83
  configure?: (c: AppConfigurator) => void,
77
84
  deleteHandlers?: DeleteHandlers<Defs>, // per-collection onDelete hooks
78
- ): App<CollectionMap<typeof BUILTIN_DEFS & Defs>>;
85
+ ): App<CollectionMap<typeof BUILTIN_DEFS & Defs>, RegistryPages<R>>;
79
86
  ```
80
87
 
81
- The returned `App` object has:
82
- - `start(port?)` — start the server (default port 3000; templates use 4321)
83
- - `db` the `TypedDB` instance
84
- - `httpServer` — the underlying Node `http.Server`
85
- - `wss` the main `WebSocketServer` (path set by `setWsPath`, default `/rpc`)
86
- - `dispatch(name, input, userId)` invoke an RPC handler programmatically
87
- - `registerRoutes(fn)` mount more Express routes after creation
88
- - `pushSend({ userId, page, query, ...notification })` send a push notification whose click-through target is a route from this app's `pages` table. `page` is type-checked against route keys (when `pages` is provided on the createApp registry), `query` against that route's params. The framework builds the absolute URL and stamps project identity so the client can open the dock app.
88
+ The returned `App` object exposes:
89
+
90
+ | Field | Description |
91
+ |-------|-------------|
92
+ | `start(port?)` | Start the server (default port 3000; templates use 4321). |
93
+ | `db` | The `TypedDB<Map>` instance (map is inferred from your collections + framework built-ins). |
94
+ | `httpServer` | The underlying Node `http.Server`. |
95
+ | `wss` | The main `WebSocketServer` (path set by `setWsPath`, default `/rpc`). |
96
+ | `dispatch(name, input, userId)` | Invoke an RPC handler programmatically (inherited from `AppRouter`). |
97
+ | `registerRoutes(fn)` | Mount more Express routes after creation. |
98
+ | `pushSend(input)` | Send a push notification whose click-through target is a route from this app's `pages` table. `page` is type-checked against route keys (when `pages` is provided on the registry), `query` against that route's params. The framework builds the absolute URL and stamps project identity so the client can open the dock app. |
89
99
 
90
- Framework-managed background services start automatically: schema drift check, NATS connection + KV buckets (`TTS`, `RATELIMIT`), data-proxy connection, event counter flush, TTL cleanup for log tables, console / error capture, and ugly.bot log forwarding.
100
+ Framework-managed background services start automatically inside `app.start()`: schema drift check, NATS connection + KV buckets, data-proxy connection, event-counter flush, TTL cleanup for log tables, console/error capture, and ugly.bot log forwarding. Individual subsystems (Postgres, NATS, storage, AI providers) are loaded lazily so a host without `DATABASE_URL` / `NATS_URL` / `R2_BUCKET` doesn't pay their startup cost.
91
101
 
92
102
  ### `AppConfigurator`
93
103
 
94
- Passed to the optional fourth argument of `createApp`. Every method is optional.
104
+ Passed to the optional 4th argument of `createApp`. Every method is optional (except `setPages` if you want the SPA served).
95
105
 
96
106
  | Method | Description |
97
107
  |--------|-------------|
98
- | `setPages({ pages, renderPage?, clientDistPath? })` | Mount the SPA. In dev, runs Vite in middleware mode; in prod, serves `dist/client`. Provide `renderPage` for SSR on pages with `ssr: true`. |
99
- | `setUserHelper(helper)` | Customize how the framework reads / writes the `user` collection during WebSocket auth (default looks up by id in a generic `user` collection). |
100
- | `setOnUserCreate(handler)` | Called on first login with `(userId, { email?, phone? }, db)` — your chance to create the user record. |
101
- | `setAuth(provider)` | Replace the default ugly.bot OAuth provider. Must implement `verify(code)` and `authUrl(origin)`. |
102
- | `setOnSocketMessage(handler)` | Single raw-WebSocket message handler. Return `true` to consume, `false` to fall through. |
108
+ | `setPages({ pages, renderPage?, clientDistPath? })` | Mount the SPA. In dev, runs Vite in middleware mode; in prod, serves `dist/client`. Provide `renderPage(routeName, params) => Promise<string>` for SSR on pages marked `ssr: true`. |
109
+ | `setUserHelper(helper)` | Customize how the framework reads/writes the `user` collection during WebSocket auth (default looks up by id in a generic `user` collection). |
110
+ | `setOnUserCreate(handler)` | Called on first login with `(userId, { email?, phone? }, db)` — the app's chance to create the user record. |
111
+ | `setAuth(provider)` | Replace the default auth provider. Must implement `verify(code)`, `authUrl(origin)`, optional `registerRoutes(router, { db, onUserCreate })`. |
112
+ | `setOnSocketMessage(handler)` | Single raw-WebSocket message handler. `(ws, userId, msg) => boolean` — return `true` to consume, `false` to fall through. |
103
113
  | `addSocketMessageHandler(handler)` | Append to the handler chain; first to return `true` wins. |
104
114
  | `setWsPath(path)` | Override the WebSocket path (default `/rpc`). |
105
115
  | `setOnWsAuth(handler)` | `(ws, userId, req) => void` — fires after a socket session authenticates. |
@@ -108,16 +118,16 @@ Passed to the optional fourth argument of `createApp`. Every method is optional.
108
118
  | `setHealthHandler(fn)` | Override the default `GET /health` response. |
109
119
  | `setExperiments(experiments)` | Register `Experiment` definitions for `initSession` / `captureEvent` bucketing. |
110
120
  | `setIsAdmin(fn)` | `(userId, db) => boolean \| Promise<boolean>` — gate for admin-only framework requests (`adminCreateTestUser` / `list` / `delete`). Defaults to matching `MAINTAIN_BOT_USER_ID`. |
111
- | `setOnEmail(handler)` | Handle inbound emails routed to `{domain}@ugly.bot` (called via internal HTTP). |
121
+ | `setOnEmail(handler)` | Handle inbound emails routed to `{domain}@ugly.bot` (delivered via internal HTTP). |
112
122
  | `setCronTasks(tasks, handlers)` | Legacy cron-only registry. Prefer `setWorkers()`. |
113
- | `setWorkers(workers, handlers)` | Register named async tasks with optional Zod input schema and cron schedule. Powers `/_workers/manifest`, `POST /_workers/run`, and the cron orchestrator. |
123
+ | `setWorkers(workers, handlers)` | Register named async tasks with optional Zod input schema and cron schedule. Powers `GET /_workers/manifest`, `POST /_workers/run`, and the cron orchestrator. |
114
124
  | `setStrings(config)` | Localization config — framework injects language + critical strings into SSR HTML and exposes `resolveLanguage` / `getCriticalStrings`. |
115
125
  | `registerRoutes(fn)` | Mount custom Express routes. |
116
126
  | `setWorkerQueue(queue)` | Register a `WorkerQueue` with `start()` / `stop()` for app lifecycle management. |
117
127
 
118
128
  ### Handler signatures
119
129
 
120
- Handlers are plain async functions — no context object:
130
+ Handlers are plain async functions — no context object. Access state via captured imports (`app.db`, `storage`, `pgQuery`, `uglyBotRequest`, etc.).
121
131
 
122
132
  ```typescript
123
133
  // req() — public, userId may be null
@@ -127,11 +137,9 @@ getPublicData: async (userId: string | null, input) => { ... }
127
137
  getMe: async (userId: string, input) => { ... }
128
138
  ```
129
139
 
130
- Inside a handler, access state via captured imports — `app.db`, `storage`, `pgQuery`, `uglyBotRequest`, etc. There is no injected context.
131
-
132
140
  ### Built-in framework requests
133
141
 
134
- `createApp` automatically registers several framework handlers, accessible from any client via the normal RPC pipeline:
142
+ `createApp` automatically registers several framework handlers, accessible from any client via the normal RPC pipeline. App-provided handlers with the same name override the framework's defaults.
135
143
 
136
144
  | Name | Purpose |
137
145
  |------|---------|
@@ -142,12 +150,10 @@ Inside a handler, access state via captured imports — `app.db`, `storage`, `pg
142
150
  | `kagiSearch` / `kagiSummarize` / `kagiEnrichWeb` / `kagiEnrichNews` | Web search via ugly.bot. |
143
151
  | `uploadUrl` | Issues a presigned PUT for the `temp` bucket. |
144
152
  | `shareLink` | Mint a `https://ugly.bot/l/<code>` short link with OG metadata (see the "sharing links" rule in `CLAUDE.md`). |
145
- | `feedbackReportCreateNoAuth` / `errorLogCaptureNoAuth` | Same-origin, public endpoints used by the built-in browser telemetry (feedback + client-side error capture) to write into the project's own Postgres. |
153
+ | `feedbackReportCreateNoAuth` / `errorLogCaptureNoAuth` | Same-origin, public endpoints used by the built-in browser telemetry to write into the project's own Postgres. |
146
154
  | `submitFeedbackBot` | Forwards `db.captureFeedback` writes for the maintain-bot persona. |
147
- | `feedbackReportResolve` | Admin-only — resolve or decline a feedback report (Workers-only via D1 telemetry). |
148
- | `adminCreateTestUser` / `adminListTestUsers` / `adminDeleteTestUser` | Admin-only synthetic-user management. Gated by `setIsAdmin()` (or `MAINTAIN_BOT_USER_ID`). |
149
-
150
- App-provided handlers with the same name override the framework's defaults.
155
+ | `feedbackReportResolve` | Admin-only — resolve or decline a feedback report. |
156
+ | `adminCreateTestUser` / `adminListTestUsers` / `adminDeleteTestUser` | Admin-only synthetic-user management. Gated by `setIsAdmin()` (or `MAINTAIN_BOT_USER_ID`). |
151
157
 
152
158
  ---
153
159
 
@@ -214,7 +220,7 @@ export const collections = defineCollections({
214
220
  - `search?: { fields, language? }` — PostgreSQL full-text search columns.
215
221
  - `vector?: { dimensions, source }` — Qdrant vector index over the named JSONB path.
216
222
 
217
- All documents extend `DBObject`: `{ _id, version, created, updated }`. Use `dbDefaults()` to stamp the latter three on inserts.
223
+ All documents extend `DBObject`: `{ _id, version, created, updated }`. Use `dbDefaults()` to stamp `version`/`created`/`updated` on inserts. **Always generate `_id` with `nanoid()`** — never `crypto.randomUUID()`, `Date.now()`, or `Math.random()` (see `CLAUDE.md`).
218
224
 
219
225
  After schema changes, run `npm run db:schema-gen` and then `npm run db:migrate`. The app refuses to start when drift is detected (set `SCHEMA_CHECK_SKIP=true` only as a last resort).
220
226
 
@@ -226,18 +232,22 @@ import { definePage, definePages } from 'ugly-app/shared';
226
232
  export const pages = definePages({
227
233
  '': definePage<{}>({ auth: false }), // /
228
234
  'user/:userId': definePage<{ userId: string }>(), // /user/abc
229
- 'search': definePage<{ q?: string }>({ auth: false }), // /search?q=foo
235
+ 'search': definePage<{ q?: string }>({ auth: false, cacheQuery: ['q'] }),
230
236
  'blog/*slug': definePage<{ slug: string }>({ ssr: true }), // /blog/any/path
231
237
  });
232
238
  export type AppPages = typeof pages;
233
239
  ```
234
240
 
235
- - `:param` matches a single path segment; `*param` is greedy (captures slashes).
236
- - The generic on `definePage<Params>()` is **phantom** — never set at runtime, used for client-side type inference.
237
- - `auth` defaults to `true`. `ssr` defaults to `false`. `{ auth: true, ssr: true }` is dropped to `ssr: false` with a warning SSR output is served from a shared edge cache and must not depend on the viewer.
238
- - `cacheQuery?: string[]` — query params that participate in the SSR edge-cache key. Unlisted params bypass the cache rather than poison it (e.g. `cacheQuery: ['q']` on a search page).
239
- - `ssrCacheTimeout?: number` — override the default 1-year edge TTL for pages whose content drifts between deploys.
240
- - Query-string params are declared in `Params` but never appear in the path template.
241
+ `definePage<Params>(options?)` returns a `PageDef<Params>` a runtime object carrying `PageMeta` plus a phantom `_params` used only for TypeScript inference. Options:
242
+
243
+ - `auth` (default `true`) protected route; router surfaces the framework's `<AuthRoot>` on the client and the server injects `window.__AUTH_TOKEN__` only for a verified cookie.
244
+ - `ssr` (default `false`)server-render the page for SEO. `{ auth: true, ssr: true }` is dropped to `ssr: false` with a warning: the SSR document is served from a shared edge cache and must not depend on the viewer.
245
+ - `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).
246
+ - `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.
247
+
248
+ Path syntax: `:param` matches a single path segment; `*param` is greedy (captures slashes). Query-string params are declared in `Params` but never appear in the path template.
249
+
250
+ `definePages<T>(p)` is a pass-through identity function that widens no types — use it to give the registry a name.
241
251
 
242
252
  ---
243
253
 
@@ -268,20 +278,22 @@ bootstrapApp({
268
278
  |-------|-------------|
269
279
  | `requests` | Your `RequestRegistry` (merged with framework requests internally). |
270
280
  | `messages?` | Your `MessageRegistry` (merged with framework messages). |
271
- | `RouterProvider` | The `RouterProvider` returned from `createRouter()`. |
281
+ | `RouterProvider` | The `RouterProvider` component returned from `createRouter()`. |
272
282
  | `render` | Callback returning the app's UI tree (typically `<RouterView />`). |
273
283
  | `root?` | Root element / selector (default `'#root'`). |
274
284
  | `fallback?` | UI for unmatched routes (default: tiny "404"). |
275
285
  | `socketUrl?` | Override the WebSocket path (default `/rpc`). |
276
286
  | `strings?` | Localization config — when present, wraps the tree with `<StringsProvider>`. |
277
287
  | `keyboard?: false` | Disable the framework `<KeyboardProvider>` wrapper. |
278
- | `silentSso?` | Apex-domain apps (ugly.chat, ugly.press, …) that use ugly.bot as their auth authority but don't share its cookie. When true, a logged-out boot attempts a non-blocking top-level SSO redirect to adopt an existing ugly.bot session. Leave unset for `*.ugly.bot` subdomains and Mode B (self-auth) apps. |
288
+ | `silentSso?` | Apex-domain apps (ugly.chat, ugly.press, …) 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, guarded) to adopt an existing ugly.bot session. Leave unset for `*.ugly.bot` subdomains and Mode B (self-auth) apps. |
289
+
290
+ `bootstrapApp` reads `window.__AUTH_TOKEN__` (injected by the server after cookie verification). If absent, it renders unauthenticated immediately — the router's per-route auth guard surfaces the framework's `<AuthRoot>` (Mode A → `<LoginPopup>`; Mode B → magic-link form + optional Google button) only when the user opens a protected route. If the token is present, it connects the socket, mounts `<AppProvider>`, and renders.
279
291
 
280
- `bootstrapApp` reads `window.__AUTH_TOKEN__` (injected by the server after cookie verification). If absent, it renders unauthenticated immediately the router's per-route auth guard surfaces the framework's `<AuthRoot>` (Mode A `<LoginPopup>`; Mode B magic-link form + optional Google button) when a user opens a protected route. If the token is present, it connects the socket, mounts `<AppProvider>`, and renders.
292
+ After render (both logged-in and logged-out), a hidden iframe silently calls `${UGLY_BOT_URL}/oauth/silent`: if it returns a fresh code, the cookie is refreshed via `POST /auth/verify`; if the returned account differs from the current session, the page reloads onto the live account (guarded once per from→to pair to prevent loops). Any `?ugly_oauth_code=…` on the URL (redirect-fallback OAuth code from ugly.bot) is redeemed at the very top of bootstrap before anything renders.
281
293
 
282
- After render (both logged-in and logged-out), a hidden iframe silently talks to `${UGLY_BOT_URL}/oauth/silent`: if it returns a fresh code, the cookie is refreshed via `POST /auth/verify`; if the returned account differs from the current session, the page reloads onto the live account. `?ugly_oauth_code=…` on the URL (a redirect-fallback OAuth code from ugly.bot) is redeemed at the top of bootstrap before anything renders.
294
+ If `bootstrapApp` is loaded at `/auth/magic-link/verify` (Mode B), it renders `<MagicLinkCallback>` instead of the regular app shell that path is the fallback target when the server route is shadowed by a static-assets handler.
283
295
 
284
- If `bootstrapApp` is loaded at `/auth/magic-link/verify` (Mode B), it renders the `<MagicLinkCallback>` component instead of the regular app shell that path is the fallback target when the server route is shadowed by a static-assets handler.
296
+ The previous first-paint-blocking `<AutoLoginGate>` iframe was removed: logged-out landing pages now paint immediately.
285
297
 
286
298
  ### Routing — `createRouter()`
287
299
 
@@ -291,21 +303,31 @@ import { createRouter } from 'ugly-app/client';
291
303
  import { pages } from '../shared/pages';
292
304
  import { allPages } from './allPages';
293
305
 
294
- export const { RouterProvider, RouterView, useRouter } = createRouter({
295
- pages,
296
- allPages,
297
- });
306
+ export const {
307
+ RouterProvider,
308
+ RouterView,
309
+ useRouter,
310
+ Link,
311
+ setAllPages,
312
+ } = createRouter({ pages, allPages });
298
313
  ```
299
314
 
300
- Config: `pages` (required), `allPages?` (lazy route → element map; may be registered later via `setAllPages`), `ssrPages?` (a `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).
315
+ **Config:**
316
+ - `pages` (required) — the `PageRegistry` from `shared/pages.ts`.
317
+ - `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`.
318
+ - `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.
301
319
 
302
- `createRouter` returns:
320
+ **`createRouter` returns:**
303
321
 
304
- - **`RouterProvider`** — props: `children`, `fallback?`, `isAuthenticated?`, `initialUrl?` (`{ pathname, search }`; required for SSR, used on the client during hydration so the first render matches the server's). Manages route state, browser history, and the popup layer. When `isAuthenticated()` returns false and the current route declares `auth: true`, the framework's `<AuthRoot>` renders in place of the page synchronously — apps cannot override this fallback. (Silent SSO happens in `bootstrapApp`, not in the router the previous first-paint-blocking `<AutoLoginGate>` iframe was removed so logged-out landing pages paint immediately.)
305
- - **`RouterView`** — renders the active page with animated transitions. Props: `durationMs?`, `easing?`, `transitionComponent?` (replaces `ViewFlipper`), `renderPage?` (sync alternative to `allPages` loaders, called with `RouterStateRaw`).
306
- - **`useRouter()`** — returns the router context (see below).
322
+ - **`RouterProvider`** — props: `children`, `fallback?`, `isAuthenticated?`, `initialUrl?` (`{ pathname, search }`, required for SSR + hydration). Manages route state, browser history, and the popup layer. When `isAuthenticated()` returns false on a route with `auth: true`, the framework's `<AuthRoot>` renders synchronously in place of the page — apps cannot override this fallback (the framework owns the login UX so an app can't accidentally ship an auth-required route with no working login button).
323
+ - **`RouterView`** — renders the active page with animated transitions. Props: `durationMs?`, `easing?`, `transitionComponent?` (replaces the default `ViewFlipper`), `renderPage?(state) => ReactElement` (sync alternative to `allPages` loaders, called with `RouterStateRaw`).
324
+ - **`useRouter()`** — returns the `RouterContextValue<Pages>` (typed navigation + popup API). Throws if used outside `<RouterProvider>`.
307
325
  - **`Link`** — typed SPA link bound to this router's pages. 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 the usual anchor attrs.
308
- - **`setAllPages(map)`** — register the lazy `PageMap<Pages>` after construction (typically from the browser entry). Lets the Worker bundle skip every lazy page's import graph while the client still gets code-split routes.
326
+ - **`setAllPages(map)`** — register the lazy `PageMap<Pages>` after construction (typically from the browser entry). Keeps the Worker's import graph free of every lazy page while the client still gets code-split routes.
327
+
328
+ A standalone `Link` is also exported from `ugly-app/client` for code that can't easily reach the typed one; it accepts an optional `router` prop and falls back to `<RouterProvider>` context.
329
+
330
+ **Never use a bare `<a href="/route">`** for internal navigation — it triggers a full document reload (white flash + repaint). See the "client navigation" rule in `CLAUDE.md`.
309
331
 
310
332
  ### Page map — `lazyPage` / `lazyPageLoader`
311
333
 
@@ -325,6 +347,8 @@ export const allPages = {
325
347
  - **`lazyPage(factory)`** — lazy-imports a default-exported `React.ComponentType<Params>`. The page receives route params as props.
326
348
  - **`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.
327
349
 
350
+ Both wrappers recover from stale-deploy chunk 404s ("Failed to fetch dynamically imported module"): on the first failure they trigger a single `window.location.reload()` (guarded by `sessionStorage` so a genuinely broken chunk can't loop) to fetch the fresh `index.html` with new chunk hashes.
351
+
328
352
  Example loader:
329
353
 
330
354
  ```typescript
@@ -340,7 +364,7 @@ export default async function PageLoader({ id }: { id: string }) {
340
364
 
341
365
  ```typescript
342
366
  const {
343
- current, // { routeName, params } for the active route (typed)
367
+ current, // { routeName, params } typed union over all pages
344
368
  transitionType, // 'PUSH' | 'REPLACE' | 'POP' | 'NONE' — how we got here
345
369
  push,
346
370
  replace,
@@ -353,16 +377,13 @@ const {
353
377
  push('user/:userId', { userId: '123' }); // → /user/123
354
378
  replace('search', { q: 'hello' }); // → /search?q=hello
355
379
  back(); // browser history back
356
-
357
- current.routeName; // typed union of all route keys
358
- current.params; // typed params for the current route
359
380
  ```
360
381
 
361
- All route names and params are fully typed against `pages`. Internally `push` / `replace` are no-ops when `buildUrl()` produces a URL that doesn't match a registered route (and emit a `console.error`).
382
+ Route names and params are fully typed against `pages`. `push` / `replace` no-op with a `console.error` when `buildUrl()` produces a URL that doesn't match a registered route.
362
383
 
363
384
  ### Popups — `openPopup()`
364
385
 
365
- Always use `useRouter().openPopup()` for modals, sheets, and menus. The router owns the popup layer, manages the spring animation, and stacks popups z-index-correctly.
386
+ Use `useRouter().openPopup()` for modals, sheets, and menus. The router owns the popup layer, drives the spring animation, and stacks popups z-index-correctly.
366
387
 
367
388
  ```tsx
368
389
  const { openPopup } = useRouter();
@@ -380,12 +401,13 @@ const handle = openPopup(<MyContent />, {
380
401
  handle.hide(); // dismiss programmatically
381
402
  ```
382
403
 
383
- Modes:
404
+ `openPopup` returns a `PopupHandle = { id, hide }`. Modes:
405
+
384
406
  - **`block`** (default) — 40% opacity backdrop, **does not** dismiss on backdrop click.
385
407
  - **`transient`** — 20% opacity backdrop, **dismisses** on backdrop click.
386
408
  - **`contextMenu`** — same as transient, intended for menus and pickers.
387
409
 
388
- `renderLayer` receives `{ content, spring, hide }` `spring` is an `AnimatedValueRef` driving 0 → 1, `hide` closes the popup.
410
+ `renderLayer` receives `{ content, spring, hide }`: `spring` is a `createAnimatedValue()` result driving 0 → 1, `hide` closes the popup.
389
411
 
390
412
  ### `AppProvider` & `useApp()`
391
413
 
@@ -393,34 +415,20 @@ Modes:
393
415
 
394
416
  ```typescript
395
417
  const {
396
- userId, // current user id
418
+ userId, // current user id (string)
397
419
  user, // UserBase doc
398
420
  socket, // AppSocket — typed RPC client
399
- uglyBotSocket, // optional UglyBotSocket for direct platform calls (STT/TTS, etc.)
421
+ uglyBotSocket, // UglyBotSocket | null — for direct platform calls (STT/TTS, etc.)
400
422
  showPopup, // legacy popup API (prefer useRouter().openPopup)
401
423
  hidePopup,
402
424
  hideAllPopups,
403
- runAsync, // runAsync(label, async () => { ... }, options?) — shows loading overlay while pending
404
- splashDone, // mark a splash-screen step complete
405
- localizer, // (key, params?) => string — alias for useLocalizer
425
+ runAsync, // (label, async () => {...}, options?) — shows loading overlay while pending
426
+ splashDone, // (step: string) — mark a splash-screen step complete
427
+ localizer, // (key, params?) => string — alias for useLocalizer()
406
428
  } = useApp();
407
429
  ```
408
430
 
409
- `useApp` is generic in `TAsyncOptions` so apps can pass an option type through to a custom `loadingOverlay` element. `useAppOptional()` returns `null` outside the provider; `useLocalizer()` returns a localizer that prefers `<StringsProvider>` data, falls back to the `AppProvider` localizer prop, and finally to identity.
410
-
411
- ### `Link` component
412
-
413
- Prefer the typed `Link` returned by `createRouter()` — no `router` prop, `to` / `params` are checked against your pages, and it uses the enclosing `<RouterProvider>` context automatically.
414
-
415
- ```tsx
416
- // Typed Link — from your `client/router.ts`
417
- const { Link } = createRouter({ pages, allPages });
418
- <Link to="user/:userId" params={{ userId: '123' }}>View profile</Link>
419
- ```
420
-
421
- A standalone `Link` is also exported from `ugly-app/client` for code that can't easily reach the typed one; it accepts an optional `router` prop and falls back to `<RouterProvider>` context.
422
-
423
- Never use a bare `<a href="/route">` for internal navigation — it triggers a full document reload (white flash + repaint). `Link` intercepts plain left-clicks and lets `ctrl/cmd+click` open in a new tab as expected.
431
+ `useApp<TAsyncOptions>()` is generic in the async-options type so apps can pass a custom option through to a custom `loadingOverlay` element (accepted as an `AppProvider` prop). `useAppOptional()` returns `null` outside the provider; `useLocalizer()` returns a localizer that prefers `<StringsProvider>` data, falls back to the `AppProvider` `localizer` prop, and finally to identity.
424
432
 
425
433
  ### Direct socket access
426
434
 
@@ -431,8 +439,8 @@ Never use a bare `<a href="/route">` for internal navigation — it triggers a f
431
439
  | `request(name, input)` | Invoke a typed RPC handler. |
432
440
  | `getDoc(collection, id)` | Server-mediated doc fetch. |
433
441
  | `getDocs(collection, filter?, opts?)` | Filtered query. |
434
- | `trackDoc(collection, id, cb)` | Live subscription — returns unsubscribe. |
435
- | `trackDocs(collection, params, cb)` | Live filtered subscription. |
442
+ | `trackDoc(collection, id, cb)` | Live subscription (with initial snapshot) — returns unsubscribe. |
443
+ | `trackDocs(collection, params, cb)` | Live filtered subscription (with initial snapshot). |
436
444
  | `uploadFile(file, key)` | Presigned upload to the `temp` bucket. |
437
445
  | `connectionState` | `'connecting' \| 'connected' \| 'reconnecting' \| 'disconnected' \| 'idle-disconnected'`. |
438
446
  | `disconnect()` | Close the connection. |
@@ -450,17 +458,17 @@ Two modes, picked from the `auth` block in the project's `.uglyapp` config:
450
458
  - **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.
451
459
  - **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`.
452
460
 
453
- `window.__UGLY_APP_AUTH_MODE__` is injected into every page so client code (incl. `<AuthRoot>` and the router's `<AutoLoginGate>`) can branch correctly.
461
+ `window.__UGLY_APP_AUTH_MODE__` is injected into every page so client code (incl. `<AuthRoot>`) can branch correctly.
454
462
 
455
463
  ### Mode A — ugly.bot OAuth (default)
456
464
 
457
465
  1. Every page request: the server checks the `auth_token` cookie. If it verifies through `${UGLY_BOT_URL}/verify`, the server injects `window.__AUTH_TOKEN__` into the HTML.
458
466
  2. Client boot: if `__AUTH_TOKEN__` is present, `bootstrapApp` opens the WebSocket with the token. Otherwise it renders logged-out immediately (no first-paint block).
459
- 3. Background account-sync (always): `bootstrapApp` opens a hidden iframe to `${UGLY_BOT_URL}/oauth/silent` on every load. If it returns a one-time code, the client POSTs `/auth/verify` to refresh the cookie. If the refreshed account's `userId` differs from the current session, the client calls `/auth/logout` and reloads onto the live account (guarded once per from→to pair to prevent loops).
460
- 4. Apex apps (`silentSso: true` in `bootstrapApp`): if the hidden iframe can't read ugly.bot's cookie (Safari / 3p-cookie blocking), a **top-level** redirect to `/oauth/silent` (first-party, bypasses 3p blocking) runs instead — one-shot, marked done so it can't loop. Skipped for accounts already confirmed live.
467
+ 3. Background account-sync (always): `bootstrapApp` opens a hidden iframe to `${UGLY_BOT_URL}/oauth/silent` on every load. If it returns a one-time code, the client POSTs `/auth/verify` to refresh the cookie. If the refreshed account's `userId` differs from the current session, the client calls `/auth/logout` and reloads onto the live account (guarded once per from→to pair).
468
+ 4. Apex apps (`silentSso: true`): if the hidden iframe can't read ugly.bot's cookie (Safari / 3p-cookie blocking), a **top-level** redirect to `/oauth/silent` (first-party, bypasses 3p blocking) runs instead — one-shot, marked done in `localStorage` so it can't loop. Skipped for accounts already confirmed live.
461
469
  5. Manual login: on protected routes, the per-route auth guard surfaces `<AuthRoot>` → `<LoginPopup>`, which opens `${UGLY_BOT_URL}/oauth` in a popup. On completion, the platform redirects back with either a `postMessage` code or `?ugly_oauth_code=…`; `bootstrapApp` redeems either at the top of boot.
462
470
 
463
- ### Mode B — Self-issued sessions
471
+ ### Mode B — self-issued sessions
464
472
 
465
473
  When `.uglyapp` sets `auth.mode: 'self'`, `buildAuth()` wires the magic-link provider as primary; if `providers.google.clientId` is configured it adds Google as an extra provider on the same router.
466
474
 
@@ -498,7 +506,7 @@ configurator.setAuth({
498
506
  });
499
507
  ```
500
508
 
501
- ### Server-side helpers (`ugly-app`)
509
+ ### Server-side helpers
502
510
 
503
511
  - `verifyToken(token)` — verifies a token against ugly.bot and returns the `userId`.
504
512
  - `getRequestUser(req)` — synchronous decode of the per-project `UGLY_PROJECT_TOKEN` cookie set by ugly.bot's wake-on-traffic gate. Returns `{ userId } | null` without a network round-trip; safe to use as the primary auth check in deployed-app handlers.
@@ -507,7 +515,7 @@ configurator.setAuth({
507
515
 
508
516
  ## Database — `TypedDB`
509
517
 
510
- Access via `app.db` or via `import { app } from './your-app-module'`. All methods accept a `CollectionDef` (from `defineCollections`) or a plain collection name string.
518
+ Access via `app.db`. All methods accept a `CollectionDef` (from `defineCollections()`) or a plain collection name string.
511
519
 
512
520
  ### Writing
513
521
 
@@ -523,7 +531,7 @@ await db.setDocOp(collections.note, id, { $inc: { views: 1 } }); // Mong
523
531
  await db.setDocOpOrIgnore(collections.note, id, { $inc: { views: 1 } });
524
532
  ```
525
533
 
526
- Supported update operators: `$inc`, `$addToSet`, `$pull`, `$unset`, `$set`. All keys are dot-notation, fully typed against the collection's schema.
534
+ Supported update operators: `$inc`, `$addToSet`, `$pull`, `$unset`, `$set`. All keys are dot-notation, fully typed against the collection's schema. Partial updates go through optimistic concurrency to prevent lost updates on concurrent writers.
527
535
 
528
536
  ### Reading
529
537
 
@@ -532,8 +540,8 @@ const doc = await db.getDoc(collections.note, id);
532
540
  const docs = await db.getDocs(collections.note, { userId }, { sort: { created: -1 }, limit: 20 });
533
541
 
534
542
  // Typed SQL-native query API (preferred for new code)
535
- const notes = await db.find(collections.note, { userId, done: { $ne: true } }, { sort: { created: -1 }, limit: 20 });
536
- const count = await db.findCount(collections.note, { userId });
543
+ const notes = await db.find(collections.note, { userId, done: { $ne: true } }, { sort: { created: -1 }, limit: 20 });
544
+ const count = await db.findCount(collections.note, { userId });
537
545
  const sample = await db.findRandom(collections.note, { userId }, 5);
538
546
 
539
547
  // Aggregation pipelines (legacy / advanced)
@@ -578,9 +586,10 @@ const k = db.cacheKey('prefix', id);
578
586
 
579
587
  ```typescript
580
588
  import { createUserHelper, dbDefaults } from 'ugly-app';
589
+ import { nanoid } from 'nanoid';
581
590
 
582
- const newDoc = { _id: crypto.randomUUID(), ...dbDefaults(), title: 'Hi' };
583
- // ^^^^^^^^^^^^^^^ { version: 1, created, updated }
591
+ const newDoc = { _id: nanoid(), ...dbDefaults(), title: 'Hi' };
592
+ // ^^^^^^^^^^^^^^^ { version: 1, created, updated }
584
593
 
585
594
  const userHelper = createUserHelper<User>(collections.user);
586
595
  const user = await userHelper.get(db, userId);
@@ -600,7 +609,7 @@ Imports available from `ugly-app`:
600
609
 
601
610
  ## AI
602
611
 
603
- AI calls are proxied through ugly.bot — your app never holds an AI provider key. Pass `UGLY_BOT_TOKEN` in the environment and the framework handles routing, balance tracking, retries, and per-user billing.
612
+ AI calls are proxied through ugly.bot — your app never holds a provider key. Pass `UGLY_BOT_TOKEN` in the environment and the framework handles routing, balance tracking, retries, and per-user billing.
604
613
 
605
614
  ### Server-side text generation
606
615
 
@@ -704,6 +713,8 @@ Client-side, use `socket.uploadFile(file, key)` — it requests a presigned URL
704
713
 
705
714
  `STORAGE_KEY_PREFIX` (env) prefixes all keys — useful for per-environment isolation.
706
715
 
716
+ On Cloudflare Workers, storage uses the R2 binding directly; presigned PUTs are not exposed (browser uploads must go through a Worker endpoint).
717
+
707
718
  ---
708
719
 
709
720
  ## Workers & cron
@@ -736,6 +747,8 @@ configurator.setWorkers(cronTasks, cronHandlers);
736
747
 
737
748
  Each worker can have `inputSchema`, `outputSchema`, `schedule`, `timeout`, `description`. Workers without a schedule are still invocable via `POST /_workers/run` (auth: localhost in dev, `Authorization: Bearer $CRON_SECRET` in prod). Scheduled workers also appear in `/_cron/manifest` for the deploy orchestrator.
738
749
 
750
+ On the Workers adapter, scheduled workers dispatch through Cloudflare Cron Triggers and durable queueing goes through Cloudflare Queues. On the Node adapter, `enqueueWorker` runs the handler inline (in-process queues only).
751
+
739
752
  ---
740
753
 
741
754
  ## Localization
@@ -805,17 +818,34 @@ Bucketing is deterministic: `hash(experimentId + userId)` (or `sessionId` for un
805
818
  | `ugly-app` | Server: `createApp`, `TypedDB`, auth, AI clients, NATS, storage, email, push, workers. |
806
819
  | `ugly-app/shared` | Cross-tier: `defineRequests`, `defineCollections`, `definePage`, `defineWorkers`, Zod, experiments, time constants. |
807
820
  | `ugly-app/client` | React: `bootstrapApp`, `createRouter`, `lazyPage`, `AppProvider`, components, animations, audio, AI helpers. |
808
- | `ugly-app/conversation/{shared,server,client}` | AI chat sessions with persisted history. |
821
+ | `ugly-app/server/adapter/workers` | Cloudflare Workers entry (Hono router + Durable Objects + Neon HTTP driver + R2). |
822
+ | `ugly-app/conversation/{shared,server,client,engine}` | AI chat sessions with persisted history. |
809
823
  | `ugly-app/collab/{server,client}` | Yjs-based collaborative editing. |
810
824
  | `ugly-app/markdown/{shared,client}` | Markdown rendering + editor. |
825
+ | `ugly-app/search/{shared,server}` | Search primitives. |
826
+ | `ugly-app/agent/{shared,server,client}` | Agent-mode SSE + tool orchestration. |
811
827
  | `ugly-app/webrtc`, `ugly-app/webrtc/server` | WebRTC video rooms. |
812
828
  | `ugly-app/three/{server,client}` | Three.js scene helpers. |
829
+ | `ugly-app/native`, `ugly-app/native/server` | Native task host + conformance harness. |
830
+ | `ugly-app/inspect`, `ugly-app/inspect/agent`, `ugly-app/inspect/ui` | UX inspection agent + host bundle. |
813
831
  | `ugly-app/worker` | Worker queue runtime. |
814
- | `ugly-app/playwright` | Test utilities. |
832
+ | `ugly-app/playwright` | E2E test utilities (`inspectWindow`, `expectClean`, `setDevice`, …). |
833
+ | `ugly-app/testing` | Test-mode helpers. |
815
834
  | `ugly-app/vite`, `ugly-app/eslint` | Build-tool plugins. |
816
835
 
817
836
  ---
818
837
 
838
+ ## Two-adapter architecture
839
+
840
+ The same developer source compiles for two runtimes via `src/server/adapter/`:
841
+
842
+ - **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.
843
+ - **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.
844
+
845
+ Developer-facing APIs (`createTypedDB`, `subscribeDoc`, `createStorageClient`, `setCronTasks`, `setWorkers`) don't change between adapters. Local Workers dev boots via `npm run dev:workers`, which starts a small Node HTTP proxy speaking Neon's wire format so the Worker can talk to a real Postgres.
846
+
847
+ ---
848
+
819
849
  ## Environment variables
820
850
 
821
851
  | Variable | Description |
@@ -831,13 +861,15 @@ Bucketing is deterministic: `hash(experimentId + userId)` (or `sessionId` for un
831
861
  | `NATS_PREFIX` / `COMPOSE_PROJECT_NAME` | NATS subject prefix for per-env isolation. |
832
862
  | `CLOCK_ENABLED` | `true` to enable `setOnMinuteTick` / `setOnHourlyTick`. |
833
863
  | `CRON_SECRET` | Bearer secret for `POST /api/_cron/:taskName` and prod `POST /_workers/run`. |
834
- | `MAINTAIN_BOT_USER_ID` | User id allowed to access admin-only handlers. |
864
+ | `MAINTAIN_BOT_USER_ID` | User id allowed to access admin-only handlers when `setIsAdmin()` is unset. |
835
865
  | `INTERNAL_EMAIL_SECRET` | Shared secret for `/internal/email-callback`. |
836
866
  | `JWT_SECRET` | Required when using `getRequestUser()` for the per-project session cookie. |
837
867
  | `APP_DOMAIN` | App domain; combined with `NATS_PREFIX` for `getRequestUser()` validation. |
838
868
  | `LOG_CAPTURE_URL` | Studio override for client log capture (empty → ugly.bot default). |
839
869
  | `UGLY_APP_HMR` | Set to `false` to disable Vite HMR in dev. |
840
870
  | `SCHEMA_CHECK_SKIP` | `true` to start despite schema drift (unsafe). |
871
+ | `NEON_PROXY_URL` | Set by `dev:workers` — Neon-wire endpoint the local Neon HTTP driver dials. |
872
+ | `CLOUDFLARE_WORKERS` | Set to `1` inside the Workers adapter for runtime detection. |
841
873
 
842
874
  Browser-visible variables must be prefixed `VITE_` and consumed via `import.meta.env.VITE_*`.
843
875
 
@@ -864,7 +896,7 @@ Browser-visible variables must be prefixed `VITE_` and consumed via `import.meta
864
896
  | `ugly-app feedback:dev` / `feedback:prod` | Query user feedback. |
865
897
  | `ugly-app feedback:submit` / `feedback:resolve` | Manage feedback (run with `--help` for flags). |
866
898
 
867
- Inside a scaffolded project, the same commands are available via `npm run …` scripts — see `templates/CLAUDE.md` for the full list.
899
+ Inside a scaffolded project, the same commands are available via `npm run …` scripts — see `templates/CLAUDE.md`.
868
900
 
869
901
  ---
870
902
 
@@ -883,4 +915,4 @@ The framework refuses to start when drift is detected (set `SCHEMA_CHECK_SKIP=tr
883
915
 
884
916
  ## Tech stack
885
917
 
886
- Node.js · TypeScript · Express · React 19 · Vite · PostgreSQL (JSONB) · Qdrant · NATS · S3-compatible storage · Zod · JWT (jose) · ugly.bot platform
918
+ Node.js · TypeScript · Express · React 19 · Vite · PostgreSQL (JSONB) · Qdrant · NATS · S3-compatible storage · Zod · JWT (jose) · Cloudflare Workers + Durable Objects (Adapter B) · ugly.bot platform
@@ -1,2 +1,2 @@
1
- export declare const CLI_VERSION = "0.1.873";
1
+ export declare const CLI_VERSION = "0.1.875";
2
2
  //# sourceMappingURL=version.d.ts.map
@@ -1,3 +1,3 @@
1
1
  // Auto-generated by prebuild — do not edit manually
2
- export const CLI_VERSION = "0.1.873";
2
+ export const CLI_VERSION = "0.1.875";
3
3
  //# sourceMappingURL=version.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"DeviceSimulator.d.ts","sourceRoot":"","sources":["../../../src/client/components/DeviceSimulator.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,SAAS,EAAuB,MAAM,OAAO,CAAC;AAwDhF,wBAAgB,eAAe,CAAC,EAC9B,QAAQ,GACT,EAAE;IACD,QAAQ,EAAE,SAAS,CAAC;CACrB,GAAG,KAAK,CAAC,SAAS,CA0KlB"}
1
+ {"version":3,"file":"DeviceSimulator.d.ts","sourceRoot":"","sources":["../../../src/client/components/DeviceSimulator.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,SAAS,EAAuB,MAAM,OAAO,CAAC;AA6ChF,wBAAgB,eAAe,CAAC,EAC9B,QAAQ,GACT,EAAE;IACD,QAAQ,EAAE,SAAS,CAAC;CACrB,GAAG,KAAK,CAAC,SAAS,CA0KlB"}
@@ -1,17 +1,6 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from 'react';
3
- const SIMULATOR_PROFILES = {
4
- ios: {
5
- safeArea: { top: 59, right: 0, bottom: 34, left: 0 },
6
- keyboardHeight: 320,
7
- viewport: { width: 390, height: 844 },
8
- },
9
- android: {
10
- safeArea: { top: 25, right: 0, bottom: 48, left: 0 },
11
- keyboardHeight: 300,
12
- viewport: { width: 360, height: 780 },
13
- },
14
- };
3
+ import { SIMULATOR_PROFILES, } from '../deviceProfiles.js';
15
4
  function isKeyboardInput(el) {
16
5
  if (!el)
17
6
  return false;